YouTube 聊天室管理

8種預設可選色彩用以自動著色指定用戶訊息,其它功能包含封鎖用戶、簡化編輯儲存在瀏覽器的用戶清單、移除聊天室置頂消息,清理重複消息。

目前为 2025-03-30 提交的版本。查看 最新版本

// ==UserScript==
// @name         YouTube 聊天室管理
// @namespace    http://tampermonkey.net/
// @version      9.1.3
// @description  8種預設可選色彩用以自動著色指定用戶訊息,其它功能包含封鎖用戶、簡化編輯儲存在瀏覽器的用戶清單、移除聊天室置頂消息,清理重複消息。
// @match        *://www.youtube.com/live_chat*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // 常數定義
    const COLOR_OPTIONS = {
        "淺藍": "lightblue",
        "深藍": "blue",
        "淺綠": "palegreen",
        "綠色": "green",
        "淺紅": "lightcoral",
        "紅色": "red",
        "紫色": "purple",
        "金色": "gold"
    };

    const MENU_AUTO_CLOSE_DELAY = 8000;
    const DUPLICATE_HIGHLIGHT_INTERVAL = 10000;
    const THROTTLE_DELAY = 150;

    // 初始化設定
    let userColorSettings = loadSettings('userColorSettings', {});
    let keywordColorSettings = loadSettings('keywordColorSettings', {});
    let blockedUsers = loadSettings('blockedUsers', []);
    let currentMenu = null;
    let menuTimeoutId = null;
    let lastDuplicateHighlightTime = 0;

    // 新增快取結構
    const userColorCache = new Map(Object.entries(userColorSettings));
    const keywordColorCache = new Map(Object.entries(keywordColorSettings));
    const blockedUsersSet = new Set(blockedUsers);

    // 預先注入 CSS 樣式
    const style = document.createElement('style');
    style.textContent = `
        .ytcm-menu {
            position: fixed;
            background-color: white;
            border: 1px solid black;
            padding: 5px;
            z-index: 9999;
            box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
            border-radius: 5px;
        }
        .ytcm-color-item {
            cursor: pointer;
            padding: 5px;
            text-align: center;
            border-radius: 3px;
        }
        .ytcm-list-item {
            cursor: pointer;
            padding: 5px;
            background-color: #f0f0f0;
            border-radius: 3px;
            margin: 2px;
        }
        .ytcm-button {
            cursor: pointer;
            padding: 5px;
            margin-top: 5px;
        }
        .ytcm-grid {
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 5px;
        }
        .ytcm-flex-wrap {
            display: flex;
            flex-wrap: wrap;
            gap: 5px;
            margin-bottom: 10px;
        }
    `;
    document.head.appendChild(style);

    // DOM 元素快取
    const chatContainer = document.querySelector('#chat');
    let lastProcessedMessageId = '';

    // 節流函數
    function throttle(func, limit) {
        let lastFunc;
        let lastRan;
        return function() {
            const context = this;
            const args = arguments;
            if (!lastRan) {
                func.apply(context, args);
                lastRan = Date.now();
            } else {
                clearTimeout(lastFunc);
                lastFunc = setTimeout(function() {
                    if ((Date.now() - lastRan) >= limit) {
                        func.apply(context, args);
                        lastRan = Date.now();
                    }
                }, limit - (Date.now() - lastRan));
            }
        };
    }

    // 加載設定
    function loadSettings(key, defaultValue) {
        try {
            return JSON.parse(localStorage.getItem(key)) || defaultValue;
        } catch (error) {
            console.error(`Failed to load ${key}:`, error);
            return defaultValue;
        }
    }

    // 保存設定 (批次處理)
    let settingsSaveQueue = {};
    function saveSettings(key, value) {
        settingsSaveQueue[key] = value;
        if (!window.settingsSaveTimeout) {
            window.settingsSaveTimeout = setTimeout(() => {
                try {
                    Object.keys(settingsSaveQueue).forEach(k => {
                        localStorage.setItem(k, JSON.stringify(settingsSaveQueue[k]));
                    });
                    settingsSaveQueue = {};
                } catch (error) {
                    console.error('Batch save failed:', error);
                }
                window.settingsSaveTimeout = null;
            }, 1000);
        }
    }

    // 高亮訊息
    function highlightMessages(mutations) {
        const messages = [];

        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === 1 && node.matches('yt-live-chat-text-message-renderer')) {
                    messages.push(node);
                }
            });
        });

        if (messages.length === 0) {
            messages.push(...Array.from(chatContainer.querySelectorAll('yt-live-chat-text-message-renderer')).slice(-50));
        }

        messages.forEach(msg => {
            const messageId = msg.id;
            if (!messageId || messageId === lastProcessedMessageId) return;
            lastProcessedMessageId = messageId;

            const authorName = msg.querySelector('#author-name');
            const messageElement = msg.querySelector('#message');
            if (!authorName || !messageElement) return;

            const userName = authorName.textContent.trim();
            const messageText = messageElement.textContent.trim();

            messageElement.style.color = '';

            if (userColorCache.has(userName)) {
                messageElement.style.color = userColorCache.get(userName);
                return;
            }

            for (const [keyword, color] of keywordColorCache) {
                if (messageText.includes(keyword)) {
                    messageElement.style.color = color;
                    break;
                }
            }
        });
    }

    // 標記重複訊息
    function markDuplicateMessages() {
        const currentTime = Date.now();
        if (currentTime - lastDuplicateHighlightTime < DUPLICATE_HIGHLIGHT_INTERVAL) return;
        lastDuplicateHighlightTime = currentTime;

        const messages = Array.from(chatContainer.querySelectorAll('yt-live-chat-text-message-renderer')).slice(-50);
        const messageMap = new Map();

        messages.forEach(msg => {
            const authorName = msg.querySelector('#author-name');
            const messageElement = msg.querySelector('#message');
            if (!authorName || !messageElement) return;

            const userName = authorName.textContent.trim();
            const messageText = messageElement.textContent.trim();
            const key = `${userName}:${messageText}`;

            if (messageMap.has(key)) {
                messageElement.textContent = '';
            } else {
                messageMap.set(key, msg);
            }
        });
    }

    // 處理封鎖用戶
    function handleBlockedUsers(mutations) {
        const messages = [];

        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === 1 && node.matches('yt-live-chat-text-message-renderer')) {
                    messages.push(node);
                }
            });
        });

        if (messages.length === 0) {
            messages.push(...Array.from(chatContainer.querySelectorAll('yt-live-chat-text-message-renderer')).slice(-50));
        }

        messages.forEach(msg => {
            const authorName = msg.querySelector('#author-name');
            if (!authorName) return;

            const userName = authorName.textContent.trim();
            if (blockedUsersSet.has(userName)) {
                const messageElement = msg.querySelector('#message');
                if (messageElement) {
                    messageElement.textContent = '';
                }
            }
        });
    }

    // 移除置頂訊息
    function removePinnedMessage() {
        const pinnedMessage = document.querySelector('yt-live-chat-banner-renderer');
        if (pinnedMessage) {
            pinnedMessage.style.display = 'none';
        }
    }

    // 關閉選單函數
    function closeMenu() {
        if (currentMenu) {
            document.body.removeChild(currentMenu);
            currentMenu = null;
            clearTimeout(menuTimeoutId);
        }
    }

    // 創建顏色選單 (修正編輯按鈕問題)
    function createColorMenu(targetElement, event) {
        closeMenu();

        const menu = document.createElement('div');
        menu.className = 'ytcm-menu';
        menu.style.top = `${event.clientY}px`;
        menu.style.left = `${event.clientX}px`;
        menu.style.width = '200px';

        const colorColumn = document.createElement('div');
        colorColumn.className = 'ytcm-grid';

        Object.entries(COLOR_OPTIONS).forEach(([colorName, colorValue]) => {
            const colorItem = document.createElement('div');
            colorItem.className = 'ytcm-color-item';
            colorItem.textContent = colorName;
            colorItem.style.backgroundColor = colorValue;
            colorItem.addEventListener('click', () => {
                if (targetElement.type === 'user') {
                    userColorSettings[targetElement.name] = colorValue;
                    userColorCache.set(targetElement.name, colorValue);
                } else if (targetElement.type === 'keyword') {
                    keywordColorSettings[targetElement.keyword] = colorValue;
                    keywordColorCache.set(targetElement.keyword, colorValue);
                }
                saveSettings('userColorSettings', userColorSettings);
                saveSettings('keywordColorSettings', keywordColorSettings);
                closeMenu();
            });
            colorColumn.appendChild(colorItem);
        });

        const blockButton = document.createElement('button');
        blockButton.className = 'ytcm-button';
        blockButton.textContent = '封鎖';
        blockButton.addEventListener('click', () => {
            if (targetElement.type === 'user') {
                blockedUsers.push(targetElement.name);
                blockedUsersSet.add(targetElement.name);
                saveSettings('blockedUsers', blockedUsers);
            }
            closeMenu();
        });

        const editButton = document.createElement('button');
        editButton.className = 'ytcm-button';
        editButton.textContent = '編輯';
        editButton.addEventListener('click', (e) => {
            e.stopPropagation(); // 阻止事件冒泡
            createEditMenu(event); // 使用原始事件對象
        });

        menu.appendChild(colorColumn);
        menu.appendChild(blockButton);
        menu.appendChild(editButton);
        document.body.appendChild(menu);
        currentMenu = menu;

        menuTimeoutId = setTimeout(closeMenu, MENU_AUTO_CLOSE_DELAY);
    }

    // 創建編輯選單
    function createEditMenu(event) {
        closeMenu();

        const menu = document.createElement('div');
        menu.className = 'ytcm-menu';
        menu.style.top = `${event.clientY}px`;
        menu.style.left = `${event.clientX}px`;
        menu.style.maxWidth = '600px';

        const closeButton = document.createElement('button');
        closeButton.className = 'ytcm-button';
        closeButton.textContent = '關閉';
        closeButton.style.width = '100%';
        closeButton.style.marginBottom = '10px';
        closeButton.addEventListener('click', closeMenu);
        menu.appendChild(closeButton);

        // 封鎖用戶名單
        const blockedUserList = document.createElement('div');
        blockedUserList.textContent = '封鎖用戶名單:';
        blockedUserList.className = 'ytcm-flex-wrap';

        blockedUsers.forEach(user => {
            const userItem = document.createElement('div');
            userItem.className = 'ytcm-list-item';
            userItem.textContent = user;
            userItem.addEventListener('click', () => {
                blockedUsers = blockedUsers.filter(u => u !== user);
                blockedUsersSet.delete(user);
                saveSettings('blockedUsers', blockedUsers);
                userItem.remove();
            });
            blockedUserList.appendChild(userItem);
        });
        menu.appendChild(blockedUserList);

        // 關鍵字名單
        const keywordList = document.createElement('div');
        keywordList.textContent = '關鍵字名單:';
        keywordList.className = 'ytcm-flex-wrap';

        Object.keys(keywordColorSettings).forEach(keyword => {
            const keywordItem = document.createElement('div');
            keywordItem.className = 'ytcm-list-item';
            keywordItem.textContent = keyword;
            keywordItem.addEventListener('click', () => {
                delete keywordColorSettings[keyword];
                keywordColorCache.delete(keyword);
                saveSettings('keywordColorSettings', keywordColorSettings);
                keywordItem.remove();
            });
            keywordList.appendChild(keywordItem);
        });
        menu.appendChild(keywordList);

        // 被上色用戶名單
        const coloredUserList = document.createElement('div');
        coloredUserList.textContent = '被上色用戶名單:';
        coloredUserList.className = 'ytcm-flex-wrap';

        Object.keys(userColorSettings).forEach(user => {
            const userItem = document.createElement('div');
            userItem.className = 'ytcm-list-item';
            userItem.textContent = user;
            userItem.addEventListener('click', () => {
                delete userColorSettings[user];
                userColorCache.delete(user);
                saveSettings('userColorSettings', userColorSettings);
                userItem.remove();
            });
            coloredUserList.appendChild(userItem);
        });
        menu.appendChild(coloredUserList);

        document.body.appendChild(menu);
        currentMenu = menu;

        menuTimeoutId = setTimeout(closeMenu, MENU_AUTO_CLOSE_DELAY);
    }

    // 點擊事件處理
    document.addEventListener('click', (event) => {
        if (currentMenu && !currentMenu.contains(event.target)) {
            closeMenu();
        }

        if (event.target.id === 'author-name') {
            const userName = event.target.textContent.trim();
            createColorMenu({ type: 'user', name: userName }, event);
        } else {
            const selectedText = window.getSelection().toString();
            if (selectedText) {
                createColorMenu({ type: 'keyword', keyword: selectedText }, event);
            }
        }
    });

    // MutationObserver
    const observer = new MutationObserver(throttle((mutations) => {
        highlightMessages(mutations);
        markDuplicateMessages();
        handleBlockedUsers(mutations);
        removePinnedMessage();
    }, THROTTLE_DELAY));

    if (chatContainer) {
        observer.observe(chatContainer, { childList: true, subtree: true });
    }
})();