监督Sedoruee的神奇小工具

null

// ==UserScript==
// @name         监督Sedoruee的神奇小工具
// @namespace    https://github.com/sedoruee
// @version      2.0.5.2
// @description  null
// @author       Sedoruee
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM.setValue
// @grant        GM_addStyle
// @grant        GM_deleteValue
// @grant        GM.info
// @run-at       document-start
// @match        *://bgm.tv/*
// @match        *://chii.in/*
// @match        *://bangumi.tv/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
// @connect      *
// @license      MIT
// ==/UserScript==
(function() {
    'use strict';

    const RULES_URL = 'https://bgm.tv/group/topic/426086';
    const DECRYPTION_KEY = 'Tx4d1gx2F';
    const CACHE_KEY = 'bgm_s_rules_cache_v1';
    let forwardRules = [];
    let reverseRules = [];
    let sensitiveWords = [];
    let blockedElementsRules = []; // New array for blocking rules

    const EDIT_PAGE_URL = 'https://bgm.tv/group/topic/425388/edit';
    const LOG_PANEL_ID = 'bgm-script-log-panel';

    function createEncryptorPanel() {
        const panel = document.createElement('div');
        panel.id = 'bgm-encryptor-panel';
        panel.style.cssText = `margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;`;
        panel.innerHTML = `
            <h2 style="font-size: 14px; margin: 0 0 10px; padding-bottom: 5px; border-bottom: 1px solid #ccc;">规则加密/解密工具</h2>
            <textarea id="encryptor-input" placeholder='输入JSON规则。示例:\n{\n  "pattern": "白粉",\n  "sensitive_word": true\n},\n{\n  "url_pattern": "*/group/topic/426184",\n  "selector": ".block-me-class",\n  "block_element": true\n}' style="width: 98%; height: 120px; margin-bottom: 10px; font-family: monospace; resize: vertical;"></textarea>
            <button id="encryptor-button" style="padding: 5px 15px; background-color: #2b88ff; color: white; border: none; border-radius: 4px; cursor: pointer;">加密并复制</button>
            <div id="encryptor-status" style="display: inline-block; margin-left: 10px; font-size: 12px;"></div>
            <textarea id="encryptor-output" readonly placeholder="加密结果将显示在此处..." style="width: 98%; height: 60px; margin-top: 10px; font-family: monospace; background-color: #eee;"></textarea>
        `;
        return panel;
    }

    function handleEncryption() {
        const inputArea = document.getElementById('encryptor-input');
        const outputArea = document.getElementById('encryptor-output');
        const statusArea = document.getElementById('encryptor-status');
        const rawText = inputArea.value.trim();
        statusArea.textContent = '';
        outputArea.value = '';
        if (!rawText) {
            statusArea.style.color = 'red';
            statusArea.textContent = '错误:输入内容不能为空。';
            return;
        }
        try {
            JSON.parse(rawText);
        } catch (e) {
            statusArea.style.color = 'red';
            statusArea.textContent = '错误:输入的不是有效的JSON格式。';
            return;
        }
        const encrypted = CryptoJS.AES.encrypt(rawText, DECRYPTION_KEY);
        const finalOutput = `[Qe3t]${encrypted.toString()}[/Qe3t]`;
        outputArea.value = finalOutput;
        outputArea.select();
        document.execCommand('copy');
        statusArea.style.color = 'green';
        statusArea.textContent = '加密成功,已复制到剪贴板!';
    }

    function createLogPanel() {
        const logPanel = document.createElement('div');
        logPanel.id = LOG_PANEL_ID;
        logPanel.innerHTML = '<h2 style="font-size: 14px; margin: 0 0 5px; padding-bottom: 5px; border-bottom: 1px solid #ccc;">脚本日志</h2>';
        logPanel.style.cssText = `margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9; max-height: 300px; overflow-y: auto;`;
        return logPanel;
    }

    function logToPanel(message, level = 'INFO') {
        const logPanel = document.getElementById(LOG_PANEL_ID);
        if (!logPanel) {
            console.log(`[BGM SCRIPT LOG] ${level}: ${message}`);
            return;
        }
        const entry = document.createElement('div');
        const timestamp = new Date().toLocaleTimeString('en-GB');
        let color = '#333';
        switch (level) {
            case 'SUCCESS': color = '#28a745'; break;
            case 'ERROR':   color = '#dc3545'; break;
            case 'WARN':    color = '#ffc107'; break;
            case 'DEBUG':   color = '#6c757d'; break;
        }
        entry.style.cssText = `color: ${color}; margin: 2px 0; font-size: 12px; line-height: 1.4; border-bottom: 1px solid #eee; padding-bottom: 2px;`;
        entry.innerHTML = `<span style="font-family: 'Courier New', Courier, monospace; font-weight: bold; min-width: 80px; display: inline-block;">${timestamp}</span> [${level}] ${message}`;
        logPanel.appendChild(entry);
        logPanel.scrollTop = logPanel.scrollHeight;
    }

    function escapeRegex(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    async function saveRulesToStorage(rulesData) {
        try {
            await GM.setValue(CACHE_KEY, JSON.stringify(rulesData));
            logToPanel('新规则已成功存入本地缓存。', 'DEBUG');
        } catch (e) {
            logToPanel(`储存规则到缓存失败: ${e.message}`, 'ERROR');
        }
    }

    async function loadRulesFromStorage() {
        try {
            const storedJson = await GM.getValue(CACHE_KEY, null);
            return storedJson ? JSON.parse(storedJson) : null;
        } catch (e) {
            logToPanel(`从缓存加载规则失败: ${e.message}`, 'WARN');
            await GM.deleteValue(CACHE_KEY);
            return null;
        }
    }

    function buildRulesFromData(rulesData) {
        const fwd = [], rev = [], sens = [], blocked = [];
        if (!Array.isArray(rulesData)) {
            logToPanel('构建规则失败:提供的数据不是数组。', 'ERROR');
            return [[], [], [], []];
        }
        rulesData.forEach(rule => {
            if (rule.sensitive_word === true && rule.pattern) {
                sens.push(rule.pattern);
            } else if (rule.block_element === true && rule.url_pattern && rule.selector) { // New rule type for blocking elements
                blocked.push({
                    url_pattern: rule.url_pattern,
                    selector: rule.selector
                });
            } else if (rule.pattern && typeof rule.replacement !== 'undefined' && typeof rule.original_text !== 'undefined') {
                fwd.push({ regex: new RegExp(rule.pattern, rule.flags || 'g'), replacement: rule.replacement });
                rev.push({ regex: new RegExp(escapeRegex(rule.replacement), 'g'), replacement: rule.original_text });
            }
        });
        logToPanel(`成功构建 ${fwd.length} 条替换规则,${sens.length} 条敏感词规则,和 ${blocked.length} 条元素屏蔽规则。`, 'SUCCESS');
        return [fwd, rev, sens, blocked];
    }

    async function fetchAndParseRules(shouldFillPanel) {
        logToPanel('开始从网络获取规则...', 'DEBUG');
        return new Promise((resolve, reject) => {
            if (typeof CryptoJS === 'undefined') return reject(new Error('未找到CryptoJS库。'));
            GM.xmlHttpRequest({
                method: "GET", url: RULES_URL,
                onload: (response) => {
                    if (response.status !== 200) return reject(new Error(`获取规则失败,状态码: ${response.status}`));
                    try {
                        const pageText = new DOMParser().parseFromString(response.responseText, 'text/html').body.innerText;
                        const match = pageText.match(/\[Qe3t\]([\s\S]*?)\[\/Qe3t\]/);
                        if (!match || !match[1]) return reject(new Error('未找到 [Qe3t] 标签或其内容为空。'));
                        const decryptedJson = CryptoJS.AES.decrypt(match[1].trim(), DECRYPTION_KEY).toString(CryptoJS.enc.Utf8);
                        if (!decryptedJson) return reject(new Error('解密失败。'));

                        if (shouldFillPanel) {
                            const inputArea = document.getElementById('encryptor-input');
                            if (inputArea) {
                                try {
                                    inputArea.value = JSON.stringify(JSON.parse(decryptedJson), null, 2);
                                } catch { inputArea.value = decryptedJson; }
                                logToPanel('已将最新规则自动填充到加密器。', 'INFO');
                            }
                        }

                        const rulesData = JSON.parse(decryptedJson);
                        if (!Array.isArray(rulesData)) return reject(new Error('解析的数据不是数组。'));
                        resolve(rulesData);
                    } catch (e) { reject(e); }
                },
                onerror: (err) => reject(new Error('网络错误,无法获取规则。'))
            });
        });
    }

    function applyReplacements(text, rules) {
        if (!rules.length || !text) return text;
        return rules.reduce((acc, rule) => acc.replace(rule.regex, rule.replacement), text);
    }

    function doesMatch(text, rules) {
        if (!text || !rules.length) return false;
        return rules.some(rule => rule.regex.test(text));
    }

    function replaceForwardsInTextarea(inputElement) {
        if (inputElement.id !== 'content' || inputElement.tagName !== 'TEXTAREA' || !doesMatch(inputElement.value, forwardRules)) return;
        const originalValue = inputElement.value, selectionStart = inputElement.selectionStart;
        const textBeforeCursor = originalValue.slice(0, selectionStart);
        const newTextBeforeCursor = applyReplacements(textBeforeCursor, forwardRules);
        if (textBeforeCursor !== newTextBeforeCursor) {
            inputElement.value = newTextBeforeCursor + originalValue.slice(selectionStart);
            inputElement.setSelectionRange(newTextBeforeCursor.length, newTextBeforeCursor.length);
        }
    }

    function revertBackwardsElsewhere(rootElement = document.body) {
        if (!rootElement || rootElement.nodeType !== Node.ELEMENT_NODE || !reverseRules.length) return;
        const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
            acceptNode: (node) => (node.parentElement && (node.parentElement.closest('textarea#content') || /^(SCRIPT|STYLE|TEXTAREA|INPUT)$/i.test(node.parentElement.tagName))) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
        });
        const nodesToModify = [];
        let node;
        while ((node = walker.nextNode())) {
            if (doesMatch(node.nodeValue, reverseRules)) nodesToModify.push(node);
        }
        if (nodesToModify.length > 0) {
            nodesToModify.forEach(n => { n.nodeValue = applyReplacements(n.nodeValue, reverseRules); });
        }
    }

    // New function to check if current path matches pattern
    function pathMatchesPattern(pathname, pattern) {
        if (!pathname || !pattern) return false;
        const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
        const regex = new RegExp(`^${escapedPattern}$`);
        return regex.test(pathname);
    }

    // New function to apply element blocking rules
    const blockedElementsProcessed = new WeakSet(); // Use WeakSet to avoid memory leaks
    function applyBlockedElementsRules(rootElement = document.body) {
        if (!blockedElementsRules.length) return;
        const currentPathname = window.location.pathname;

        blockedElementsRules.forEach(rule => {
            if (pathMatchesPattern(currentPathname, rule.url_pattern)) {
                try {
                    rootElement.querySelectorAll(rule.selector).forEach(element => {
                        if (!blockedElementsProcessed.has(element)) {
                            element.style.display = 'none';
                            blockedElementsProcessed.add(element);
                        }
                    });
                } catch (e) {
                    logToPanel(`应用屏蔽规则时出错: 选择器 "${rule.selector}" 无效或DOM操作失败。`, 'ERROR');
                }
            }
        });
    }

    const monitoredElements = new WeakSet();
    function setupInputMonitoringForElement(element) {
        if (!element || monitoredElements.has(element)) return;
        monitoredElements.add(element);
        if (element.id === 'content' && element.tagName === 'TEXTAREA') {
            element.addEventListener('input', (event) => replaceForwardsInTextarea(event.target));
            if (element.value) replaceForwardsInTextarea(element);
        }
    }

    function setupAllInputMonitoring() {
        const targetTextarea = document.querySelector('textarea#content');
        if (targetTextarea) setupInputMonitoringForElement(targetTextarea);
    }

    function setupMutationObserver() {
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            revertBackwardsElsewhere(node);
                            applyBlockedElementsRules(node); // Apply blocking rules to newly added nodes
                            const targetTextarea = node.matches('textarea#content') ? node : node.querySelector('textarea#content');
                            if (targetTextarea) setupInputMonitoringForElement(targetTextarea);
                        }
                    }
                }
            }
        });
        if (document.body) observer.observe(document.body, { childList: true, subtree: true });
    }

    function setupSensitiveWordChecker(selector, words) {
        const target = document.querySelector(selector);
        if (!target || !words.length) return;

        target.addEventListener('blur', handleCheck);
        target.addEventListener('keyup', handleCheck);
        target.addEventListener('input', handleCheck);

        function handleCheck(event) {
            const currentWords = [...words];
            currentWords.forEach(word => {
                const patt = new RegExp(word, "g");
                const text = event.target.value;
                if (patt.test(text)) {
                    if (confirm("发现敏感词:" + word + ", 是否替换?")) {
                        const replacement = prompt("敏感词:" + word + ", 替换为:");
                        if (replacement !== null) {
                            event.target.value = text.replace(patt, replacement);
                        }
                    } else {
                        // If user cancels replacement, remove this specific word from the active check list for this session
                        const index = words.indexOf(word);
                        if (index > -1) {
                            words.splice(index, 1);
                        }
                    }
                }
            });
        }
        logToPanel(`为 ${selector} 设置了敏感词检查器。`, 'DEBUG');
    }

    function initializeSensitiveWordCheckers(words) {
        if (words.length === 0) return;
        const href = window.location.href;
        const localWords = [...words];

        if (href.match(/new_topic|topic\/\d+\/edit/)) {
            setupSensitiveWordChecker("#title", localWords);
            setupSensitiveWordChecker("#content", localWords);
        }
        if (href.match(/blog\/create|blog\/\d+\/edit/)) {
            setupSensitiveWordChecker("#title", localWords);
            setupSensitiveWordChecker("#tpc_content", localWords);
        }
        if (href.match(/subject\/\d+/)) {
            setupSensitiveWordChecker("#title", localWords);
            setupSensitiveWordChecker("#content", localWords);
            setupSensitiveWordChecker("#comment", localWords);
        }
    }

    async function runReplacementLogic() {
        const applyAllRulesAndSetupObservers = () => {
            if (document.body) {
                revertBackwardsElsewhere(document.body);
                applyBlockedElementsRules(document.body); // Apply blocking rules on initial load
                setupAllInputMonitoring();
                setupMutationObserver();
                initializeSensitiveWordCheckers(sensitiveWords);
                logToPanel('初始化完成,脚本正在运行。', 'SUCCESS');
            }
        };

        let rulesApplied = false;
        const cachedRulesData = await loadRulesFromStorage();
        if (cachedRulesData && cachedRulesData.length > 0) {
            [forwardRules, reverseRules, sensitiveWords, blockedElementsRules] = buildRulesFromData(cachedRulesData);
            applyAllRulesAndSetupObservers();
            rulesApplied = true;
        }

        try {
            const newRulesData = await fetchAndParseRules(false);
            await saveRulesToStorage(newRulesData);
            // Re-build rules even if cached rules were used, to ensure latest rules are active.
            [forwardRules, reverseRules, sensitiveWords, blockedElementsRules] = buildRulesFromData(newRulesData);
            if (!rulesApplied) { // Only call init if it hasn't been called with cached rules
                applyAllRulesAndSetupObservers();
            } else { // If already initialized with cached rules, just re-apply the new rules (e.g. blocking)
                revertBackwardsElsewhere(document.body); // Re-apply replacement as rules might have changed
                applyBlockedElementsRules(document.body); // Re-apply blocking rules
            }
        } catch (error) {
            logToPanel(`获取网络规则失败: ${error.message}`, 'ERROR');
            if (!rulesApplied) logToPanel('由于网络和缓存均失败,脚本无法运行。', 'ERROR');
        }
    }

    async function setupEditPagePanel() {
        const anchorElement = document.querySelector('div.light_odd');
        if (!anchorElement) return;

        const encryptorPanel = createEncryptorPanel();
        const logPanel = createLogPanel();

        anchorElement.after(logPanel);
        anchorElement.after(encryptorPanel);

        document.getElementById('encryptor-button').addEventListener('click', handleEncryption);

        logToPanel('调试面板已加载。');
        try {
            await fetchAndParseRules(true);
        } catch(e) {
            logToPanel(`获取规则以填充面板时出错: ${e.message}`, 'ERROR');
        }
    }

    async function initializeScript() {
        if (window.location.href.startsWith(EDIT_PAGE_URL)) {
            await setupEditPagePanel();
        } else {
            await setupEditPagePanel();
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeScript);
    } else {
        initializeScript();
    }
})();