知乎屏蔽词管理器

页面内管理屏蔽词 + 导入/导出 + 累计/本次屏蔽统计 + 可查看屏蔽列表,性能飞起,无需改脚本操作

// ==UserScript==
// @name         知乎屏蔽词管理器
// @namespace    http://tampermonkey.net/
// @version      1.0.8
// @description  页面内管理屏蔽词 + 导入/导出 + 累计/本次屏蔽统计 + 可查看屏蔽列表,性能飞起,无需改脚本操作
// @author       You
// @match        https://www.zhihu.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const ITEM_SEL  = '.ContentItem';
    const TITLE_SEL = '.ContentItem-title a';

    let keywords = [];
    let sessionCount = 0;
    let totalCount = 0;
    const blockedItems = [];
    const seen = new WeakSet();

    // 载入词库 & 累计数
    async function loadConfig() {
        const storedList = await GM_getValue('BLOCK_KEYWORDS', []);
        keywords = Array.isArray(storedList) ? storedList : [];
        totalCount = Number(await GM_getValue('BLOCKED_TOTAL', 0));
    }
    // 保存累计数
    async function saveTotal(count) {
        await GM_setValue('BLOCKED_TOTAL', count);
    }
    async function saveKeywords(list) {
        await GM_setValue('BLOCK_KEYWORDS', list);
        keywords = list;
        resetAll();
    }

    function resetAll() {
        sessionCount = 0;
        blockedItems.length = 0;
        document.querySelectorAll(ITEM_SEL).forEach(el => {
            el.style.display = '';
            seen.delete(el);
            io.observe(el);
        });
        updateButtons();
    }

    // UI 按钮区
    const ui = document.createElement('div');
    Object.assign(ui.style, {
        position: 'fixed', bottom: '20px', right: '20px',
        zIndex: 10000, display: 'flex', flexDirection: 'column', gap: '6px',
        fontSize: '12px', fontFamily: 'sans-serif'
    });
    document.body.appendChild(ui);

    // 累计 / 本次 屏蔽按钮
    const countBtn = document.createElement('div');
    Object.assign(countBtn.style, {
        background: 'rgba(0,0,0,0.6)', color: '#fff',
        padding: '6px 10px', borderRadius: '4px',
        cursor: 'pointer', userSelect: 'none',
    });
    countBtn.title = '点击查看本次 & 历史屏蔽列表';
    ui.appendChild(countBtn);
    countBtn.addEventListener('click', showBlockedList);

    // 管理按钮
    const manageBtn = document.createElement('div');
    Object.assign(manageBtn.style, {
        background: 'rgba(0,0,0,0.6)', color: '#fff',
        padding: '6px 10px', borderRadius: '4px',
        cursor: 'pointer', userSelect: 'none'
    });
    manageBtn.textContent = '⚙️ 管理';
    manageBtn.title = '点击管理屏蔽词';
    ui.appendChild(manageBtn);
    manageBtn.addEventListener('click', showKeywordPanel);

    function updateButtons() {
        countBtn.textContent = `累计屏蔽 ${totalCount} 条 / 本次 ${sessionCount} 条`;
    }

    // 屏蔽逻辑
    function tryHide(el) {
        if (seen.has(el)) return;
        seen.add(el);
        const a = el.querySelector(TITLE_SEL);
        if (!a) return;
        const txt = a.textContent.trim();
        if (keywords.some(k => txt.includes(k))) {
            el.style.display = 'none';
            sessionCount++;
            totalCount++;
            saveTotal(totalCount);
            blockedItems.push({ title: txt, href: a.href });
            updateButtons();
        }
    }

    // IntersectionObserver 性能方案
    const io = new IntersectionObserver(entries => {
        entries.forEach(ent => {
            if (ent.isIntersecting) {
                tryHide(ent.target);
                io.unobserve(ent.target);
            }
        });
    }, { rootMargin: '200px', threshold: 0.01 });

    // 样式
    const baseCSS = `
    .tm-mask {position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.3);z-index:9998;}
    .tm-dialog {
        position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
        background:#fff;padding:20px;border-radius:8px;
        box-shadow:0 2px 10px rgba(0,0,0,0.2);
        z-index:9999;max-width:80%;max-height:80%;overflow:auto;
        font-size:14px;font-family:sans-serif;
    }
    .tm-dialog h3 {margin-top:0;font-size:16px;}
    .tm-dialog textarea {width:100%;box-sizing:border-box;margin:10px 0;font-family:monospace;}
    .tm-dialog .btns {text-align:right;margin-top:10px;}
    .tm-dialog button {margin-left:8px;padding:6px 12px;border:none;border-radius:4px;cursor:pointer;}
    .tm-dialog input[type=file] {display:none;}
    `;
    function appendStyle() {
        if (document.getElementById('tm-base-style')) return;
        const style = document.createElement('style');
        style.id = 'tm-base-style';
        style.textContent = baseCSS;
        document.head.appendChild(style);
    }

    // 查看列表(本次+累计)
    function showBlockedList() {
        if (document.getElementById('tm-blocker-list')) return;
        const panel = document.createElement('div');
        panel.id = 'tm-blocker-list';
        panel.innerHTML = `
            <div class="tm-mask"></div>
            <div class="tm-dialog">
                <h3>累计屏蔽 ${totalCount} 条 / 本次 ${sessionCount} 条</h3>
                <ul style="list-style:none;padding:0;margin:10px 0;">
                  ${blockedItems.length
                    ? blockedItems.map(item =>
                        `<li style="margin-bottom:6px;">
                           <a href="${item.href}" target="_blank" style="color:#337ab7;text-decoration:none;">
                             ${item.title}
                           </a>
                        </li>`
                      ).join('')
                    : '<li>本次暂无屏蔽记录</li>'}
                </ul>
                <div class="btns">
                  <button id="tm-close-list">关闭</button>
                </div>
            </div>`;
        document.body.appendChild(panel);
        appendStyle();
        panel.querySelector('#tm-close-list').onclick = () => panel.remove();
    }

    // 管理面板(导入/导出/保存/取消)
    function showKeywordPanel() {
        if (document.getElementById('tm-keyword-panel')) return;
        const panel = document.createElement('div');
        panel.id = 'tm-keyword-panel';
        panel.innerHTML = `
            <div class="tm-mask"></div>
            <div class="tm-dialog">
                <h3>🛠 屏蔽词管理</h3>
                <textarea rows="10" placeholder="一行一个词">${keywords.join('\n')}</textarea>
                <div class="btns">
                    <button data-act="import">导入</button>
                    <button data-act="export">导出</button>
                    <button data-act="save">保存</button>
                    <button data-act="cancel">取消</button>
                </div>
                <input type="file" id="tm-import-file" accept=".json,.txt">
            </div>`;
        document.body.appendChild(panel);
        appendStyle();

        const textarea = panel.querySelector('textarea');
        const fileInput = panel.querySelector('#tm-import-file');

        panel.addEventListener('click', e => {
            const act = e.target.getAttribute('data-act');
            if (!act) return;
            if (act === 'save') {
                const arr = textarea.value.trim().split('\n').map(s => s.trim()).filter(Boolean);
                saveKeywords([...new Set(arr)]);
                panel.remove();
            }
            else if (act === 'cancel') {
                panel.remove();
            }
            else if (act === 'export') {
                const blob = new Blob([JSON.stringify(keywords, null, 2)], { type: 'application/json' });
                const url = URL.createObjectURL(blob);
                const a   = document.createElement('a');
                a.href    = url;
                a.download= 'blocked_keywords.json';
                a.click();
                URL.revokeObjectURL(url);
            }
            else if (act === 'import') {
                fileInput.click();
            }
        });

        fileInput.addEventListener('change', e => {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = evt => {
                let arr;
                try {
                    const obj = JSON.parse(evt.target.result);
                    if (Array.isArray(obj)) arr = obj.map(String);
                } catch {
                    arr = evt.target.result.split('\n').map(s => s.trim()).filter(Boolean);
                }
                if (arr) saveKeywords([...new Set(arr)]);
                panel.remove();
            };
            reader.readAsText(file);
        });
    }

    // 启动
    (async function init() {
        await loadConfig();
        document.querySelectorAll(ITEM_SEL).forEach(el => io.observe(el));
        updateButtons();
        new MutationObserver(muts => {
            muts.forEach(m => {
                m.addedNodes.forEach(node => {
                    if (node.nodeType === 1) {
                        if (node.matches(ITEM_SEL)) io.observe(node);
                        else node.querySelectorAll(ITEM_SEL).forEach(el => io.observe(el));
                    }
                });
            });
        }).observe(document.body, { childList: true, subtree: true });
    })();

})();