Keyword-based Tweet Filtering for Threads

If any part of a post—such as the main content, hashtags, or username—matches a keyword, the entire post will be hidden. Supports adding keywords, viewing the list, and deleting them individually. Quick block is also supported, along with blocklist viewing and individual removal.

Tính đến 24-04-2025. Xem phiên bản mới nhất.

// ==UserScript==
// @name         Keyword-based Tweet Filtering for Threads
// @name:zh-TW   Threads 關鍵字過濾推文
// @name:zh-CN   Threads 关键字过滤推文
// @namespace    http://tampermonkey.net/
// @version      3.5
// @description  If any part of a post—such as the main content, hashtags, or username—matches a keyword, the entire post will be hidden. Supports adding keywords, viewing the list, and deleting them individually. Quick block is also supported, along with blocklist viewing and individual removal.
// @description:zh-TW 只要推文主體、標籤、用戶名等任一區塊命中關鍵字,整則推文一起隱藏。支援關鍵字新增、清單、單獨刪除。支援快速封鎖、清單、單獨刪除。
// @description:zh-CN 只要推文主体、标签、用户名等任一区块命中关键字,整则推文一起隐藏。支援关键字新增、清单、单独删除。支援快速封锁、清单、单独删除。
// @author       chatgpt
// @match        https://www.threads.net/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // 關鍵字相關
    function getKeywords() {
        return GM_getValue('keywords', []);
    }
    function setKeywords(keywords) {
        GM_setValue('keywords', keywords);
    }

    // 封鎖用戶相關
    function getBlockedUsers() {
        return GM_getValue('blockedUsers', []);
    }
    function setBlockedUsers(users) {
        GM_setValue('blockedUsers', users);
    }

    // 取得所有推文主容器
    function getAllPostContainers() {
        return document.querySelectorAll('div[data-pressable-container][class*=" "]');
    }

    // 在推文主容器下,找所有可能含有文字的區塊
    function getAllTextBlocks(container) {
        return container.querySelectorAll('span[dir="auto"]:not([translate="no"]), a[role="link"], span, div');
    }

    // 取得用戶名稱(Threads 通常在 a[href^="/@"] 內)
    function getUsername(container) {
        let a = container.querySelector('a[href^="/@"]');
        if (a) {
            let username = a.getAttribute('href').replace('/', '').replace('@', '');
            return username;
        }
        return null;
    }

    // 過濾推文
    function filterPosts() {
        let keywords = getKeywords();
        let blockedUsers = getBlockedUsers();
        let containers = getAllPostContainers();
        containers.forEach(container => {
            let blocks = getAllTextBlocks(container);
            let matched = false;
            // 關鍵字過濾
            blocks.forEach(block => {
                let text = (block.innerText || block.textContent || "").trim();
                if (text && keywords.some(keyword => keyword && text.includes(keyword))) {
                    matched = true;
                }
            });
            // 封鎖用戶過濾
            let username = getUsername(container);
            if (username && blockedUsers.includes(username)) {
                matched = true;
            }
            if (matched) {
                container.style.display = 'none';
            } else {
                container.style.display = '';
            }
        });
    }

    // 插入封鎖用戶按鈕(插在「分享」按鈕右邊)
    function insertBlockButtons() {
        let containers = getAllPostContainers();
        let blockedUsers = getBlockedUsers();
        containers.forEach(container => {
            // 避免重複插入
            if (container.querySelector('.tm-block-user-btn')) return;
            let username = getUsername(container);
            if (!username) return;

            // 找到「分享」按鈕
            let shareBtn = container.querySelector('div[role="button"] svg[aria-label="分享"]');
            if (!shareBtn) return;
            let shareBtnDiv = shareBtn.closest('div[role="button"]');
            if (!shareBtnDiv) return;

            // 建立封鎖按鈕
            let blockBtn = document.createElement('button');
            blockBtn.className = 'tm-block-user-btn';
            blockBtn.title = '封鎖用戶';
            blockBtn.style.marginLeft = '8px';
            blockBtn.style.background = 'none';
            blockBtn.style.border = 'none';
            blockBtn.style.cursor = 'pointer';
            blockBtn.style.fontSize = '18px';
            blockBtn.style.color = '#d00';
            blockBtn.innerHTML = '🚫';

            blockBtn.onclick = function(e) {
                e.stopPropagation();
                if (confirm(`確定要封鎖 @${username} 嗎?\n(此用戶所有推文將被隱藏)`)) {
                    let users = getBlockedUsers();
                    if (!users.includes(username)) {
                        users.push(username);
                        setBlockedUsers(users);
                        alert(`已封鎖 @${username}!`);
                        filterPosts();
                    }
                }
            };

            // 插入在「分享」按鈕之後
            shareBtnDiv.parentNode.insertBefore(blockBtn, shareBtnDiv.nextSibling);
        });
    }

    // observer 只監控新節點
    const observer = new MutationObserver(mutations => {
        let needFilter = false;
        for (const m of mutations) {
            if (m.addedNodes && m.addedNodes.length > 0) {
                needFilter = true;
                break;
            }
        }
        if (needFilter) {
            filterPosts();
            insertBlockButtons();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    // 初始執行一次
    filterPosts();
    insertBlockButtons();

    // 新增關鍵字
    GM_registerMenuCommand('新增關鍵字', () => {
        let input = prompt('請輸入要新增的關鍵字(可用半形或全形逗號分隔,一次可多個):');
        if (input !== null) {
            let arr = input.split(/,|,/).map(s => s.trim()).filter(Boolean);
            let keywords = getKeywords();
            let newKeywords = [...keywords];
            arr.forEach(k => {
                if (!newKeywords.includes(k)) newKeywords.push(k);
            });
            setKeywords(newKeywords);
            alert('已新增關鍵字!');
            location.reload();
        }
    });

    // 關鍵字清單與單獨刪除
    GM_registerMenuCommand('關鍵字清單/刪除', () => {
        let keywords = getKeywords();
        if (keywords.length === 0) {
            alert('目前沒有設定任何關鍵字。');
            return;
        }
        let msg = '目前關鍵字如下:\n';
        keywords.forEach((k, i) => {
            msg += `${i+1}. ${k}\n`;
        });
        msg += '\n請輸入要刪除的關鍵字編號(可多個,用逗號分隔),或留空取消:';
        let input = prompt(msg, '');
        if (input !== null && input.trim() !== '') {
            let idxArr = input.split(/,|,/).map(s => parseInt(s.trim(), 10) - 1).filter(i => !isNaN(i) && i >= 0 && i < keywords.length);
            if (idxArr.length > 0) {
                let newKeywords = keywords.filter((k, i) => !idxArr.includes(i));
                setKeywords(newKeywords);
                alert('已刪除指定關鍵字!');
                location.reload();
            }
        }
    });

    // 清除所有關鍵字
    GM_registerMenuCommand('清除所有關鍵字', () => {
        setKeywords([]);
        alert('已清除所有關鍵字!');
        location.reload();
    });

    // 封鎖名單管理
    GM_registerMenuCommand('封鎖名單管理', () => {
        let users = getBlockedUsers();
        if (users.length === 0) {
            alert('目前沒有封鎖任何用戶。');
            return;
        }
        let msg = '目前封鎖用戶如下:\n';
        users.forEach((u, i) => {
            msg += `${i+1}. @${u}\n`;
        });
        msg += '\n請輸入要解除封鎖的用戶編號(可多個,用逗號分隔),或留空取消:';
        let input = prompt(msg, '');
        if (input !== null && input.trim() !== '') {
            let idxArr = input.split(/,|,/).map(s => parseInt(s.trim(), 10) - 1).filter(i => !isNaN(i) && i >= 0 && i < users.length);
            if (idxArr.length > 0) {
                let newUsers = users.filter((u, i) => !idxArr.includes(i));
                setBlockedUsers(newUsers);
                alert('已解除指定用戶封鎖!');
                location.reload();
            }
        }
    });

    // 清除所有封鎖用戶
    GM_registerMenuCommand('清除所有封鎖用戶', () => {
        setBlockedUsers([]);
        alert('已清除所有封鎖用戶!');
        location.reload();
    });

})();