Twitch聊天室净化

聊天室信息增删改,替换词留空为删除。特性:大小写不敏感、简繁体和中英文符号替换,优先匹配长词,自动去重,导入导出数据,多页面同步,支持列表拖动记忆位置。

// ==UserScript==
// @name         Twitch聊天室净化
// @version      1.0
// @description  聊天室信息增删改,替换词留空为删除。特性:大小写不敏感、简繁体和中英文符号替换,优先匹配长词,自动去重,导入导出数据,多页面同步,支持列表拖动记忆位置。
// @author       yzcjd
// @author1       ChatGPT4辅助
// @match        https://www.twitch.tv/*
// @namespace    https://greasyfork.org/users/1171320
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addValueChangeListener
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const PER_PAGE = 24;
    let replacements = {};
    let currentPage = 1;
    let container = null;
    let allEntries = [];
    let dragOffset = { x: 0, y: 0 };
    let isDragging = false;

    let justImportedOrSaved = false;

    const simpToTradMap = { '台湾': '台灣', '後': '后', '马': '馬' };
    const tradToSimpMap = Object.fromEntries(Object.entries(simpToTradMap).map(([k, v]) => [v, k]));

    function normalizeSymbols(str) {
        return str.replace(/[,。!?【】()%#@&1234567890]/g, c => ({
            ',': ',', '。': '.', '!': '!', '?': '?', '【': '[', '】': ']', '(': '(', ')': ')',
            '%': '%', '#': '#', '@': '@', '&': '&',
            '1': '1', '2': '2', '3': '3', '4': '4', '5': '5',
            '6': '6', '7': '7', '8': '8', '9': '9', '0': '0'
        })[c] || c);
    }

    function convertSimpToTrad(str) {
        for (const [simp, trad] of Object.entries(simpToTradMap)) {
            str = str.split(simp).join(trad);
        }
        return str;
    }

    function convertTradToSimp(str) {
        for (const [trad, simp] of Object.entries(tradToSimpMap)) {
            str = str.split(trad).join(simp);
        }
        return str;
    }

    function generateRegexKeys(from) {
        const keys = new Set();
        const normalized = normalizeSymbols(from);
        const simp = convertTradToSimp(normalized);
        const trad = convertSimpToTrad(normalized);
        keys.add(normalized);
        keys.add(simp);
        keys.add(trad);
        return [...keys].filter(Boolean);
    }

    function parseReplacements(obj) {
        const map = {};
        Object.entries(obj).forEach(([k, v]) => {
            if (k.trim()) map[k.trim()] = v.trim();
        });
        return map;
    }

    function getSortedReplacements() {
        return Object.entries(replacements).sort((a, b) => a[0].localeCompare(b[0]));
    }

    function applyReplacementToText(text) {
        getSortedReplacements().forEach(([from, to]) => {
            generateRegexKeys(from).forEach(key => {
                const regex = new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
                text = text.replace(regex, to);
            });
        });
        return text;
    }

    function scanMessages() {
        document.querySelectorAll('span.text-fragment[data-a-target="chat-message-text"]').forEach(el => {
            el.textContent = applyReplacementToText(el.textContent);
        });
    }

    setInterval(scanMessages, 3000);
    window.addEventListener('load', () => setTimeout(scanMessages, 3000));

    if (typeof GM_addValueChangeListener === 'function') {
        GM_addValueChangeListener('replacements_obj', (_, __, newVal) => {
            replacements = parseReplacements(newVal);
            allEntries = Object.entries(replacements);
            scanMessages();
        });
        GM_addValueChangeListener('editor_pos', (_, __, newVal) => {
            if (container && newVal) {
                container.style.left = `${newVal.x}px`;
                container.style.top = `${newVal.y}px`;
            }
        });
    }

    function createButton(text, onClick) {
        const btn = document.createElement('button');
        btn.textContent = text;
        btn.style.cssText = 'margin:2px;padding:2px 6px;font-size:12px;cursor:pointer;';
        btn.onclick = onClick;
        return btn;
    }

    function makeRow(from = '', to = '', highlight = false) {
        const row = document.createElement('tr');
        row.innerHTML = `
            <td><input type="text" value="${from}" style="width:120px;border:1px solid ${highlight ? '#aaa' : '#ccc'};"/></td>
            <td><input type="text" value="${to}" style="width:120px;border:1px solid ${highlight ? '#aaa' : '#ccc'};"/></td>`;
        return row;
    }

    function showToast(msg) {
        const toast = document.createElement('div');
        toast.textContent = msg;
        toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:5px 10px;border-radius:4px;font-size:12px;z-index:100000;opacity:1;transition:opacity 0.5s ease';
        document.body.appendChild(toast);
        setTimeout(() => toast.style.opacity = '0', 3000);
        setTimeout(() => toast.remove(), 3500);
    }

    function renderPage(table, data, page) {
        table.querySelectorAll('tr').forEach((tr, i) => i > 0 && tr.remove());
        const start = (page - 1) * PER_PAGE;
        const end = start + PER_PAGE;
        data.slice(start, end).forEach(([from, to]) => table.appendChild(makeRow(from, to)));
        const blankRow = makeRow('', '', true);
        table.appendChild(blankRow);
    }

    function getTableEntries(table) {
        const rows = table.querySelectorAll('tr');
        const entries = [];
        rows.forEach((tr, i) => {
            if (i === 0) return;
            const inputs = tr.querySelectorAll('input');
            const key = inputs[0].value.trim();
            const val = inputs[1].value.trim();
            if (key) entries.push([key, val]);
        });
        return entries;
    }

    async function saveAndClose(table) {
        const currentPageEntries = getTableEntries(table);
        const startIdx = (currentPage - 1) * PER_PAGE;
        const newAllEntries = [...allEntries];
        newAllEntries.splice(startIdx, PER_PAGE, ...currentPageEntries);

        const filteredEntries = newAllEntries.filter(([k]) => k.trim() !== '');
        filteredEntries.sort((a, b) => a[0].localeCompare(b[0]));

        replacements = Object.fromEntries(filteredEntries);
        allEntries = filteredEntries;
        await GM_setValue('replacements_obj', replacements);

        currentPage = 1;
        await GM_setValue('lastPage', currentPage);

        closeContainer(true);
        scanMessages();
        showToast('已保存,每0.3秒执行一次替换');
    }

    function closeContainer(isSaved = false) {
        if (container) {
            container.remove();
            container = null;
            justImportedOrSaved = isSaved;
        }
        // 移除拖拽事件监听,防止内存泄漏和误触发
        document.removeEventListener('mousemove', onDragMove);
        document.removeEventListener('mouseup', onDragEnd);
    }

    function focusLastRowInput(table) {
        const rows = table.querySelectorAll('tr');
        if (rows.length > 1) {
            const lastRowInputs = rows[rows.length - 1].querySelectorAll('input');
            if (lastRowInputs.length > 0) {
                lastRowInputs[0].focus();
            }
        }
    }

    // 拖拽相关函数声明,方便绑定和解绑
    function onDragStart(e) {
        if (e.target.tagName === 'INPUT') return; // 输入框内不拖动
        isDragging = true;
        dragOffset.x = e.clientX - container.offsetLeft;
        dragOffset.y = e.clientY - container.offsetTop;
        e.preventDefault();
    }
    function onDragMove(e) {
        if (!isDragging) return;
        let x = e.clientX - dragOffset.x;
        let y = e.clientY - dragOffset.y;
        x = Math.max(0, Math.min(window.innerWidth - container.offsetWidth, x));
        y = Math.max(0, Math.min(window.innerHeight - container.offsetHeight, y));
        container.style.left = x + 'px';
        container.style.top = y + 'px';
    }
    function onDragEnd() {
        if (isDragging) {
            isDragging = false;
            GM_setValue('editor_pos', { x: container.offsetLeft, y: container.offsetTop });
        }
    }

    function openEditor() {
        if (container) return;
        justImportedOrSaved = false;
        replacements = parseReplacements(GM_getValue('replacements_obj', {}));
        allEntries = Object.entries(replacements);
        currentPage = GM_getValue('lastPage', 1) || 1;

        container = document.createElement('div');
        container.style.cssText = 'position:fixed;z-index:99999;background:#fff;border:1px solid #ccc;padding:10px;top:100px;left:100px;max-height:80%;overflow-y:auto;font-size:13px;box-shadow:0 0 10px rgba(0,0,0,0.2);min-width:300px;';
        container.style.userSelect = 'none';

        // 绑定拖拽事件
        container.addEventListener('mousedown', onDragStart);
        document.addEventListener('mousemove', onDragMove);
        document.addEventListener('mouseup', onDragEnd);

        // 读取存储位置并应用
        const pos = GM_getValue('editor_pos', null);
        if (pos) {
            container.style.left = `${pos.x}px`;
            container.style.top = `${pos.y}px`;
        } else {
            container.style.left = '100px';
            container.style.top = '100px';
        }

        const table = document.createElement('table');
        table.style.borderCollapse = 'collapse';
        table.style.width = '100%';
        table.innerHTML = '<tr><th>原词</th><th>替换词</th></tr>';

        renderPage(table, allEntries, currentPage);

        const saveBtn = createButton('💾 保存', () => saveAndClose(table));
        const closeBtn = createButton('❌ 关闭', () => {
            closeContainer(false);
        });
        const addBtn = createButton('+ 添加', () => {
            table.appendChild(makeRow('', '', true));
            focusLastRowInput(table);
        });
        const prevBtn = createButton('⬅', () => {
            if (currentPage > 1) {
                currentPage--;
                GM_setValue('lastPage', currentPage);
                renderPage(table, allEntries, currentPage);
                focusLastRowInput(table);
            }
        });
        const nextBtn = createButton('➡', () => {
            if (currentPage < Math.ceil(allEntries.length / PER_PAGE)) {
                currentPage++;
                GM_setValue('lastPage', currentPage);
                renderPage(table, allEntries, currentPage);
                focusLastRowInput(table);
            }
        });

        const importBtn = createButton('📥 导入', () => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = '.ini,.txt';
            input.onchange = () => {
                const file = input.files[0];
                if (!file) return;
                const reader = new FileReader();
                reader.onload = () => {
                    const result = {};
                    reader.result.split(/\r?\n/).forEach(line => {
                        const [key, val = ''] = line.split('=>');
                        if (key && key.trim()) result[key.trim()] = val.trim();
                    });
                    replacements = parseReplacements(result);
                    allEntries = Object.entries(replacements);

                    currentPage = 1;
                    GM_setValue('lastPage', currentPage);

                    renderPage(table, allEntries, currentPage);

                    GM_setValue('replacements_obj', replacements).then(() => {
                        justImportedOrSaved = true;
                        showToast('导入成功,已自动保存');
                        scanMessages();
                        focusLastRowInput(table);
                    });
                };
                reader.readAsText(file);
            };
            input.click();
        });

        const exportBtn = createButton('📤 导出', () => {
            const now = new Date();
            const pad = n => n.toString().padStart(2, '0');
            const filename = `文字替换${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}.ini`;
            const blob = new Blob([Object.entries(replacements).map(([k, v]) => `${k} => ${v}`).join('\n')], { type: 'text/plain' });
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = filename;
            a.click();
            showToast('导出成功');
        });

        container.appendChild(table);
        [addBtn, saveBtn, importBtn, exportBtn, prevBtn, nextBtn, closeBtn].forEach(btn => container.appendChild(btn));

        document.body.appendChild(container);

        focusLastRowInput(table);
    }

    GM_registerMenuCommand('📝 列表', openEditor);

})();