TikTok 直播封锁过滤

支援直播间封锁、推荐区过滤、主页悬浮封锁按钮、封锁清单管理功能

// ==UserScript==
// @name         TikTok Live Blocking & Filtering
// @name:zh-TW   TikTok 直播封鎖過濾
// @name:zh-CN   TikTok 直播封锁过滤
// @namespace    https://www.tampermonkey.net/
// @version      2.4
// @description  Supports live room blocking, recommendation feed filtering, floating block button on homepage, and block list management features.
// @description:zh-TW 支援直播間封鎖、推薦區過濾、主頁懸浮封鎖按鈕、封鎖清單管理功能
// @description:zh-CN 支援直播间封锁、推荐区过滤、主页悬浮封锁按钮、封锁清单管理功能
// @author       ChatGPT
// @match        https://www.tiktok.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // ======= ✅ Toast 提示功能 =======
    function toast(msg) {
        const div = document.createElement('div');
        div.textContent = msg;
        div.style.cssText = `
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(0,0,0,0.75);
            color: white;
            padding: 10px 14px;
            border-radius: 6px;
            z-index: 10000;
            font-size: 14px;
            user-select: none;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.3s ease-in-out;
        `;
        document.body.appendChild(div);
        requestAnimationFrame(() => div.style.opacity = '1');
        setTimeout(() => {
            div.style.opacity = '0';
            div.addEventListener('transitionend', () => div.remove());
        }, 2500);
    }

    // ======= 🟢 開關按鈕功能( ON/OFF ) =======
    const SCRIPT_ENABLED_KEY = 'script_enabled';
    let scriptEnabled = GM_getValue(SCRIPT_ENABLED_KEY, true);

    function insertToggleButton() {
        const targetAnchor = document.querySelector('a.tiktok-104tlrh.link-a11y-focus');
        if (!targetAnchor) return;
        if (document.getElementById('tiktok-script-toggle-btn')) return;

        const btn = document.createElement('button');
        btn.id = 'tiktok-script-toggle-btn';
        btn.textContent = scriptEnabled ? 'ON' : 'OFF';
        btn.style.cssText = `
            margin-left: 8px;
            padding: 4px 10px;
            font-size: 16px;
            border-radius: 5px;
            border: none;
            cursor: pointer;
            background-color: ${scriptEnabled ? '#52c41a' : '#ff4d4f'};
            color: white;
            box-shadow: 0 2px 6px rgba(0,0,0,0.15);
            user-select: none;
            transition: background-color 0.3s ease;
        `;
        btn.addEventListener('click', () => {
            scriptEnabled = !scriptEnabled;
            GM_setValue(SCRIPT_ENABLED_KEY, scriptEnabled);
            location.reload();
        });

        targetAnchor.parentElement.style.position = 'relative';
        targetAnchor.insertAdjacentElement('afterend', btn);
    }

    function tryInsertToggleButton() {
        insertToggleButton();
        if (!document.getElementById('tiktok-script-toggle-btn')) {
            setTimeout(tryInsertToggleButton, 1000);
        }
    }

    tryInsertToggleButton();

    if (!scriptEnabled) {
        console.log('⚠️ TikTok 直播封鎖過濾腳本已被用戶關閉,停止執行');
        return;
    }

    // ======= 🔒 封鎖邏輯處理與封鎖名單 =======
    const BLOCK_BTN_CLASS = 'tiktok-block-btn';
    let blockedList = GM_getValue('blocked_list', []);

    function getBlockedList() {
        return blockedList;
    }

    function setBlockedList(list) {
        blockedList = list;
        GM_setValue('blocked_list', list);
    }

    function getStreamerIDFromPath(path) {
        const match = path.match(/^\/@([^/]+)\/live/);
        return match ? match[1] : null;
    }

    function getStreamerID() {
        return getStreamerIDFromPath(window.location.pathname);
    }

    function addBlock() {
        const streamerID = getStreamerID();
        if (!streamerID) return toast('❌ 無法取得直播主ID');
        if (blockedList.includes(streamerID)) return toast(`⚠️ 直播主 ${streamerID} 已在封鎖名單中`);

        blockedList.push(streamerID);
        setBlockedList(blockedList);
        toast(`✅ 已將直播主 ${streamerID} 加入封鎖名單`);
    }

    // ======= 📌 插入直播間封鎖按鈕 =======
    function insertLiveBlockButton() {
        if (document.querySelector(`button.${BLOCK_BTN_CLASS}`)) return;
        const wrapper = document.querySelector('div.tiktok-tk1gy.e1f21nov0');
        if (!wrapper) return;
        const idBlock = wrapper.querySelector('div.tiktok-79elbk.e1w7pjwm0');
        if (!idBlock) return;

        idBlock.style.position = 'relative';

        const btn = document.createElement('button');
        btn.className = BLOCK_BTN_CLASS;
        btn.textContent = '🚫 封鎖直播主';
        btn.style.cssText = `
            position: absolute;
            top: 50%;
            left: 100%;
            transform: translate(10px, -50%);
            background-color: #ff4d4f;
            color: white;
            border: none;
            padding: 6px 10px;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
            z-index: 9999;
            white-space: nowrap;
            box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
        `;
        btn.addEventListener('click', addBlock);
        idBlock.appendChild(btn);
    }

    // ======= 👀 判斷是否已封鎖 =======
    function isStreamerBlocked(streamerID) {
        return blockedList.includes(streamerID);
    }

    // ======= 🧹 隱藏推薦直播卡片(側欄與主頁) =======
    function hideBlockedRecommendations() {
        if (blockedList.length === 0) return;

        const sideAnchors = document.querySelectorAll('a.tiktok-1usxus4.e1kktrof6.link-a11y-focus[href*="/@"]');
        sideAnchors.forEach(anchor => {
            const href = decodeURIComponent(anchor.getAttribute('href') || '');
            const streamerID = getStreamerIDFromPath(href);
            if (streamerID && isStreamerBlocked(streamerID)) {
                const card = anchor.closest('div[data-e2e="live-side-nav-item"]');
                if (card) card.remove();
            }
        });

        const mainArea = document.querySelector('div.tiktok-i9gxme.e1q7cfv81');
        if (!mainArea) return;
        const mainAnchors = mainArea.querySelectorAll('a[href*="/@"][href*="/live"]');

        mainAnchors.forEach(anchor => {
            const href = decodeURIComponent(anchor.getAttribute('href') || '');
            const streamerID = getStreamerIDFromPath(href);
            if (streamerID && isStreamerBlocked(streamerID)) {
                const card = anchor.closest('div.tiktok-17fk2p9.eomcb1m0');
                if (card) card.remove();
            }
        });
    }

    // ======= 🧱 主頁推薦區封鎖按鈕注入 =======
    function injectBlockButtonsToMainCards() {
        if (blockedList.length === 0) return;

        const mainArea = document.querySelector('div.tiktok-i9gxme.e1q7cfv81');
        if (!mainArea) return;

        const cards = mainArea.querySelectorAll('div.tiktok-17fk2p9.eomcb1m0');

        cards.forEach(card => {
            if (card.querySelector(`button.${BLOCK_BTN_CLASS}`)) return;

            const anchor = card.querySelector('a[href*="/@"][href*="/live"]');
            if (!anchor) return;

            const href = decodeURIComponent(anchor.getAttribute('href') || '');
            const streamerID = getStreamerIDFromPath(href);
            if (!streamerID) return;

            if (isStreamerBlocked(streamerID)) {
                card.remove();
                return;
            }

            const btn = document.createElement('button');
            btn.textContent = '🚫 封鎖';
            btn.className = BLOCK_BTN_CLASS;
            btn.style.cssText = `
                position: absolute;
                top: 8px;
                right: 8px;
                z-index: 9999;
                background-color: #ff4d4f;
                color: white;
                border: none;
                padding: 4px 8px;
                border-radius: 6px;
                cursor: pointer;
                font-size: 12px;
                box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
            `;
            btn.onclick = () => {
                if (!blockedList.includes(streamerID)) {
                    blockedList.push(streamerID);
                    setBlockedList(blockedList);
                    toast(`✅ 已封鎖直播主 @${streamerID}`);
                }
                card.remove();
            };

            card.style.position = 'relative';
            card.appendChild(btn);
        });
    }

    // ======= 🧾 封鎖清單管理與清空 =======
    function showBlockedListAndEdit() {
    const PAGE_SIZE = 500; // 每頁最多顯示筆數
    let currentPage = 0;   // 預設從第 0 頁開始

    function renderPage() {
        const list = getBlockedList(); // 取得目前封鎖清單
        const totalPages = Math.ceil(list.length / PAGE_SIZE); // 計算總頁數

        if (list.length === 0) return toast('封鎖清單目前為空');

        const start = currentPage * PAGE_SIZE; // 當前頁起始索引
        const end = Math.min(start + PAGE_SIZE, list.length); // 當前頁結束索引
        const listStr = list.slice(start, end).map((id, i) => `${start + i + 1}. ${id}`).join('\n'); // 列出當前頁的項目

        // 顯示 prompt 提示用戶輸入
        const input = prompt(
            `📄 封鎖清單(第 ${currentPage + 1} 頁 / 共 ${totalPages} 頁)\n\n${listStr}\n\n` +
            `輸入欲剃除的「編號」(可用空格或逗號分隔)\n` +
            `輸入 > / < 可翻頁(下一頁 / 上一頁):`
        );

        // 若使用者關閉對話框,則不做任何處理
        if (input === null) return;

        const trimmed = input.trim();

        // 處理翻頁邏輯
        if (trimmed === '>') {
            if (currentPage + 1 < totalPages) currentPage++;
            return renderPage();
        } else if (trimmed === '<') {
            if (currentPage > 0) currentPage--;
            return renderPage();
        }

        // 使用者輸入欲剃除的編號
        let indexes = trimmed.split(/[\s,]+/).map(s => parseInt(s.trim()))
            .filter(n => !isNaN(n) && n >= 1 && n <= list.length);

        if (indexes.length === 0) {
            toast('⚠️ 無有效編號,未變更');
            return;
        }

        // 移除重複並由大至小排序,避免刪除時索引錯位
        indexes = [...new Set(indexes)].sort((a, b) => b - a);

        const newList = [...list];
        for (const idx of indexes) {
            newList.splice(idx - 1, 1);
        }

        // 儲存新的封鎖清單
        setBlockedList(newList);
        toast(`✅ 已剃除 ${indexes.length} 位直播主`);
    }

    renderPage();
}

    // 清空封鎖清單
    function clearBlockedList() {
    setBlockedList([]);
    toast('✅ 封鎖清單已清空');
}

    // 註冊功能至油猴選單
    GM_registerMenuCommand('編輯封鎖清單', showBlockedListAndEdit);
    GM_registerMenuCommand('清除所有封鎖用戶', clearBlockedList);

    // ======= 🔁 自動重試載入錯誤頁面 =======
    function autoRetryIfCrashed() {
        const errorContainer = document.querySelector('div.tiktok-17btlil');
        const errorIcon = errorContainer?.querySelector('svg');
        const retryButton = errorContainer?.querySelector('button.tiktok-1xrybjt.ebef5j00');
        if (errorContainer && errorIcon && retryButton) {
            console.log('⚠️ 偵測到頁面掛掉,嘗試點擊「重試」按鈕...');
            retryButton.click();
        }
    }

    // ======= 🧠 MutationObserver 觀察頁面變化 =======
    const observer = new MutationObserver(() => {
        const isLive = /^\/@[^/]+\/live/.test(window.location.pathname);
        if (isLive) insertLiveBlockButton();
        injectBlockButtonsToMainCards();
        hideBlockedRecommendations();
        autoRetryIfCrashed();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });
})();