ITD Feed Filter

Фильтрация ленты в ИТД

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ITD Feed Filter
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Фильтрация ленты в ИТД
// @author       0wn3df1x
// @license      MIT
// @icon         https://xn--d1ah4a.com/favicon.ico
// @match        *://*.xn--d1ah4a.com/*
// @match        *://*.итд.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    const customStyles = document.createElement('style');
    customStyles.innerHTML = `
        #itd-filter-list::-webkit-scrollbar { width: 6px; }
        #itd-filter-list::-webkit-scrollbar-track { background: transparent; }
        #itd-filter-list::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.3); border-radius: 3px; }
        #itd-filter-list::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
    `;
    document.head.appendChild(customStyles);

    // Функция для извлечения всего полезного текста из объекта поста
    function extractTextPayload(p, elements) {
        if (!p) return;
        if (p.content) elements.push(p.content);
        if (p.author) {
            if (p.author.username) elements.push(p.author.username);
            if (p.author.displayName) elements.push(p.author.displayName);
        }
        if (p.dominantEmoji) elements.push(p.dominantEmoji);

        if (p.spans && Array.isArray(p.spans)) {
            p.spans.forEach(span => {
                if (span.tag) {
                    elements.push(span.tag);
                    if (span.type === 'hashtag') elements.push('#' + span.tag);
                }
            });
        }
    }

    function checkSpam(post, words) {
        if (!post) return false;

        let elements = [];

        // Проверяем сам пост
        extractTextPayload(post, elements);

        // Если это репост, обязательно проверяем и оригинальный пост
        if (post.originalPost) {
            extractTextPayload(post.originalPost, elements);
        }

        const textPayload = elements.join(' ').toLowerCase();
        return words.some(w => textPayload.includes(w));
    }

    function getActiveWords() {
        return GM_getValue('itd_filter_words', []).map(w => w.toLowerCase());
    }

    function isProfilePage() {
        return window.location.pathname.startsWith('/@');
    }

    const _window = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

    // --- 1. ПЕРЕХВАТ FETCH ---
    const origFetch = _window.fetch;
    _window.fetch = async function(...args) {
        const urlStr = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url ? args[0].url : '');

        if (urlStr.includes('/api/posts') && (!args[1] || args[1].method === 'GET' || !args[1].method)) {
            const filterActive = GM_getValue('itd_filter_active', true);
            const words = getActiveWords();

            if (!filterActive || words.length === 0 || isProfilePage()) {
                return origFetch.apply(this, args);
            }

            try {
                let response = await origFetch.apply(this, args);
                if (!response.ok) return response;

                let json = await response.clone().json();

                if (json && json.data && json.data.posts) {
                    json.data.posts = json.data.posts.filter(p => !checkSpam(p, words));

                    let maxRecursion = 5;
                    while (json.data.posts.length < 5 && json.data.pagination && json.data.pagination.hasMore && maxRecursion > 0) {
                        maxRecursion--;

                        const nextCursor = json.data.pagination.nextCursor;
                        if (!nextCursor) break;

                        const nextUrl = new URL(urlStr, _window.location.origin);
                        nextUrl.searchParams.set('cursor', nextCursor);

                        const nextArgs = [...args];
                        nextArgs[0] = nextUrl.toString();

                        const nextRes = await origFetch.apply(this, nextArgs);
                        if (!nextRes.ok) break;

                        const nextJson = await nextRes.json();
                        if (nextJson && nextJson.data && nextJson.data.posts) {
                            const cleanNextPosts = nextJson.data.posts.filter(p => !checkSpam(p, words));
                            json.data.posts = json.data.posts.concat(cleanNextPosts);
                            json.data.pagination = nextJson.data.pagination;
                        } else {
                            break;
                        }
                    }

                    const newHeaders = new Headers(response.headers);
                    newHeaders.delete('content-length');

                    return new Response(JSON.stringify(json), {
                        status: response.status,
                        statusText: response.statusText,
                        headers: newHeaders
                    });
                }
            } catch (e) {
                console.error("[ITD Filter] Error in Ghost Fetch:", e);
            }
        }
        return origFetch.apply(this, args);
    };

    // --- 2. ПЕРЕХВАТ JSON.PARSE ---
    const origParse = _window.JSON.parse;
    _window.JSON.parse = function(text, reviver) {
        const data = origParse(text, reviver);
        try {
            const filterActive = GM_getValue('itd_filter_active', true);
            const words = getActiveWords();
            if (filterActive && words.length > 0 && data && data.data && Array.isArray(data.data.posts) && !isProfilePage()) {
                data.data.posts = data.data.posts.filter(p => !checkSpam(p, words));
            }
        } catch(e) {}
        return data;
    };

    // --- 3. ВСТРАИВАНИЕ КНОПКИ-ШЕСТЕРЕНКИ ---
    function injectTools() {
        const panel = document.querySelector('.yhENW-7a');
        if (!panel) return;

        const tabs = Array.from(panel.querySelectorAll('button.DPhGAlZS'));
        const forYouBtn = tabs.find(btn => btn.textContent.trim().includes('Для вас'));

        if (forYouBtn && !forYouBtn.querySelector('.itd-filter-btn')) {
            const toolsContainer = document.createElement('span');
            toolsContainer.className = 'itd-filter-btn';
            toolsContainer.style.cssText = `
                display: inline-flex;
                align-items: center;
                justify-content: center;
                width: 28px;
                height: 28px;
                margin-right: 8px;
                z-index: 10;
                transform: translateY(3px);
                background: var(--bg-tertiary);
                color: var(--text-secondary);
                border-radius: 50%;
                cursor: pointer;
                transition: background 0.2s ease, color 0.2s ease;
            `;

            toolsContainer.innerHTML = `
                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <circle cx="12" cy="12" r="3"></circle>
                    <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
                </svg>
            `;

            toolsContainer.onmouseover = () => {
                toolsContainer.style.background = 'var(--bg-hover)';
                toolsContainer.style.color = 'var(--text-primary)';
            };
            toolsContainer.onmouseout = () => {
                toolsContainer.style.background = 'var(--bg-tertiary)';
                toolsContainer.style.color = 'var(--text-secondary)';
            };

            forYouBtn.insertBefore(toolsContainer, forYouBtn.firstChild);

            toolsContainer.addEventListener('click', (e) => {
                e.stopPropagation();
                e.preventDefault();
                openModal();
            });
        }
    }

    // --- 4. МОДАЛЬНОЕ ОКНО ---
    function openModal() {
        if (document.getElementById('itd-native-filter-modal')) return;

        let initialWords = JSON.stringify(GM_getValue('itd_filter_words', []));
        let initialActive = GM_getValue('itd_filter_active', true);

        const overlay = document.createElement('div');
        overlay.id = 'itd-native-filter-modal';
        overlay.style.cssText = `
            position: fixed; inset: 0; z-index: 10000;
            display: flex; align-items: center; justify-content: center;
            background: rgba(0, 0, 0, 0.6);
            backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
        `;

        const modal = document.createElement('div');
        modal.style.cssText = `
            background: var(--block-bg);
            color: var(--text-primary);
            padding: 24px;
            border-radius: 24px;
            width: 420px;
            max-width: 90vw;
            box-shadow: var(--shadow-elevated);
            display: flex; flex-direction: column; gap: 20px;
            max-height: 85vh;
            border: 1px solid var(--border-color);
        `;

        modal.innerHTML = `
            <div style="display: flex; justify-content: space-between; align-items: center;">
                <h2 style="font-size: 20px; font-weight: 600; margin: 0; color: var(--text-primary);">Настройки фильтра</h2>

                <label style="display: flex; align-items: center; gap: 12px; cursor: pointer; font-size: 14px; color: var(--text-secondary); font-weight: 500;">
                    Вкл
                    <div id="itd-toggle-ui" style="position: relative; width: 44px; height: 24px; background: ${initialActive ? 'var(--toggle-active-bg)' : 'var(--bg-tertiary)'}; border-radius: 12px; transition: background 0.2s;">
                        <div id="itd-toggle-knob" style="position: absolute; top: 2px; left: ${initialActive ? '22px' : '2px'}; width: 20px; height: 20px; background: var(--bg-primary); border-radius: 50%; transition: left 0.2s; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
                    </div>
                    <input type="checkbox" id="itd-filter-toggle" ${initialActive ? 'checked' : ''} style="display: none;">
                </label>
            </div>

            <div style="display: flex; gap: 8px;">
                <input type="text" id="itd-filter-input" placeholder="Хештег или слово..."
                    style="flex-grow: 1; padding: 12px 16px; border-radius: 12px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); outline: none; font-size: 14px; transition: border 0.2s;">
                <button id="itd-filter-add"
                    style="padding: 0 20px; background: var(--accent-primary); color: white; border: none; border-radius: 12px; cursor: pointer; font-weight: 600; font-size: 18px; transition: opacity 0.2s;">+</button>
            </div>

            <div id="itd-filter-list" style="overflow-y: auto; border: 1px solid var(--border-color); border-radius: 12px; padding: 8px; background: var(--bg-secondary); flex-grow: 1; min-height: 150px; max-height: 250px; display: flex; flex-direction: column; gap: 4px;"></div>

            <div style="display: flex; gap: 8px;">
                <button id="itd-filter-export" style="flex: 1; padding: 10px; background: var(--bg-tertiary); color: var(--text-primary); border: none; border-radius: 12px; cursor: pointer; font-weight: 500; font-size: 13px; transition: background 0.2s;">Экспорт</button>
                <button id="itd-filter-import" style="flex: 1; padding: 10px; background: var(--bg-tertiary); color: var(--text-primary); border: none; border-radius: 12px; cursor: pointer; font-weight: 500; font-size: 13px; transition: background 0.2s;">Импорт</button>
                <button id="itd-filter-clear"  style="flex: 1; padding: 10px; background: rgba(239, 68, 68, 0.1); color: #ef4444; border: none; border-radius: 12px; cursor: pointer; font-weight: 500; font-size: 13px; transition: background 0.2s;">Очистить</button>
            </div>

            <textarea id="itd-filter-io" placeholder="Вставьте список через точку с запятой (;)"
                style="width: 100%; height: 80px; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px; display: none; resize: vertical; font-size: 13px; outline: none;"></textarea>

            <button id="itd-filter-close"
                style="width: 100%; padding: 12px; background: var(--text-primary); color: var(--bg-primary); border: none; border-radius: 16px; cursor: pointer; font-weight: 600; font-size: 15px; margin-top: 8px; transition: opacity 0.2s;">Готово</button>
        `;

        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        const addHover = (id) => {
            const el = document.getElementById(id);
            if(el) {
                el.onmouseover = () => el.style.opacity = '0.8';
                el.onmouseout = () => el.style.opacity = '1';
            }
        };
        addHover('itd-filter-add'); addHover('itd-filter-export'); addHover('itd-filter-import'); addHover('itd-filter-clear'); addHover('itd-filter-close');

        const inputEl = document.getElementById('itd-filter-input');
        inputEl.onfocus = () => inputEl.style.borderColor = 'var(--accent-primary)';
        inputEl.onblur = () => inputEl.style.borderColor = 'var(--border-color)';

        let filterWords = GM_getValue('itd_filter_words', []);
        const listContainer = document.getElementById('itd-filter-list');
        const ioArea = document.getElementById('itd-filter-io');
        const toggleCheckbox = document.getElementById('itd-filter-toggle');
        const toggleUi = document.getElementById('itd-toggle-ui');
        const toggleKnob = document.getElementById('itd-toggle-knob');

        function saveState() {
            GM_setValue('itd_filter_words', filterWords);
            GM_setValue('itd_filter_active', toggleCheckbox.checked);
        }

        function renderList() {
            listContainer.innerHTML = '';
            if (filterWords.length === 0) {
                listContainer.innerHTML = '<div style="color: var(--text-tertiary); text-align: center; padding: 30px 10px; font-size: 13px;">Список пуст</div>';
                return;
            }
            filterWords.forEach((word, index) => {
                const item = document.createElement('div');
                item.style.cssText = `
                    display: flex; justify-content: space-between; align-items: center;
                    padding: 8px 12px; background: var(--block-bg);
                    border: 1px solid var(--border-color); border-radius: 8px;
                `;

                const textSpan = document.createElement('span');
                textSpan.textContent = word;
                textSpan.style.cssText = 'word-break: break-word; font-size: 14px; font-weight: 500; color: var(--text-primary);';

                const removeBtn = document.createElement('button');
                removeBtn.innerHTML = `
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"></path></svg>
                `;
                removeBtn.style.cssText = `
                    background: transparent; color: var(--text-tertiary); border: none;
                    border-radius: 50%; width: 24px; height: 24px; cursor: pointer;
                    display: flex; justify-content: center; align-items: center; transition: color 0.2s;
                `;
                removeBtn.onmouseover = () => removeBtn.style.color = '#ef4444';
                removeBtn.onmouseout = () => removeBtn.style.color = 'var(--text-tertiary)';

                removeBtn.onclick = () => {
                    filterWords.splice(index, 1);
                    saveState();
                    renderList();
                };

                item.appendChild(textSpan);
                item.appendChild(removeBtn);
                listContainer.appendChild(item);
            });
        }

        toggleCheckbox.onchange = (e) => {
            const isChecked = e.target.checked;
            toggleUi.style.background = isChecked ? 'var(--toggle-active-bg)' : 'var(--bg-tertiary)';
            toggleKnob.style.left = isChecked ? '22px' : '2px';
            saveState();
        };

        document.getElementById('itd-filter-add').onclick = () => {
            const val = inputEl.value.trim().toLowerCase();
            if (val && !filterWords.includes(val)) {
                filterWords.push(val);
                inputEl.value = '';
                saveState();
                renderList();
            }
        };

        inputEl.onkeypress = (e) => { if (e.key === 'Enter') document.getElementById('itd-filter-add').click(); };

        document.getElementById('itd-filter-clear').onclick = () => {
            if (confirm('Очистить фильтр?')) {
                filterWords = [];
                saveState();
                renderList();
            }
        };

        document.getElementById('itd-filter-export').onclick = () => {
            ioArea.style.display = 'block';
            ioArea.value = filterWords.join(';');
            ioArea.select();
        };

        document.getElementById('itd-filter-import').onclick = () => {
            if (ioArea.style.display === 'none' || ioArea.value.trim() === '') {
                ioArea.style.display = 'block';
                ioArea.value = '';
            } else {
                const newWords = ioArea.value.split(';').map(w => w.trim().toLowerCase()).filter(w => w !== '');
                newWords.forEach(w => {
                    if (!filterWords.includes(w)) filterWords.push(w);
                });
                ioArea.value = '';
                ioArea.style.display = 'none';
                saveState();
                renderList();
            }
        };

        function closeModal() {
            saveState();
            const currentWords = JSON.stringify(filterWords);
            const currentActive = toggleCheckbox.checked;

            if (currentWords !== initialWords || currentActive !== initialActive) {
                location.reload();
            } else {
                overlay.remove();
            }
        }

        document.getElementById('itd-filter-close').onclick = closeModal;
        overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };

        renderList();
    }

    const observer = new MutationObserver(() => {
        if (document.body) {
            injectTools();
        }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });

})();