Twitter MTF killer

荷包蛋自用净化推特贴文的脚本,全自动隐藏MTF相关贴文,检测内容包括贴文正文,贴文标签,用户名,用户简介,支持对emoji的检测

// ==UserScript==
// @name         Twitter MTF killer
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  荷包蛋自用净化推特贴文的脚本,全自动隐藏MTF相关贴文,检测内容包括贴文正文,贴文标签,用户名,用户简介,支持对emoji的检测
// @author       Ayase
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      x.com
// @connect      twitter.com
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 关键词配置 ---
    const BLOCKED_KEYWORDS_RAW = [
        '男娘',
        '伪娘',
        '药娘',
        '男同',
        'mtf',
        '🏳️‍⚧️',
        '🏳️‍🌈',
        '跨性别',
        '扶她',
        'futa',
        '性转',
        'LGBT',
        '🍥',
        'furry',
        '男童',
        '福瑞'
    ];


    const keywords = BLOCKED_KEYWORDS_RAW.map(k => k.trim().toLowerCase()).filter(k => k.length > 0);
    const userBioCache = new Map();
    let isCurrentProfileBlocked = false;
    let lastCheckedUrl = '';


    GM_addStyle(`
        #blocker-toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; }
        .blocker-toast-message { background-color: rgba(29, 155, 240, 0.9); color: white; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); opacity: 0; transform: translateX(100%); transition: all 0.4s cubic-bezier(0.21, 1.02, 0.73, 1); font-size: 14px; line-height: 1.4; }
        .blocker-toast-message.show { opacity: 1; transform: translateX(0); }
        .blocker-toast-message b { font-weight: bold; }
    `);
    const initToastContainer = () => {
        if (!document.getElementById('blocker-toast-container')) {
            const container = document.createElement('div');
            container.id = 'blocker-toast-container';
            document.body.appendChild(container);
        }
    };
    const showNotification = (message) => {
        const container = document.getElementById('blocker-toast-container');
        if (!container) return;
        const toast = document.createElement('div');
        toast.className = 'blocker-toast-message';
        toast.innerHTML = message;
        container.appendChild(toast);
        setTimeout(() => toast.classList.add('show'), 10);
        setTimeout(() => {
            toast.classList.remove('show');
            toast.addEventListener('transitionend', () => toast.remove());
        }, 2500);
    };


    const getElementTextWithEmojiAlt = (element) => {
        if (!element) return '';
        let fullText = element.textContent || '';
        const emojiImages = element.querySelectorAll('img[alt]');
        emojiImages.forEach(img => {
            fullText += ` ${img.alt}`;
        });
        return fullText;
    };


    const findMatchingKeyword = (text) => {
        if (!text || keywords.length === 0) return null;
        const lowerText = text.toLowerCase();
        for (const keyword of keywords) {
            if (lowerText.includes(keyword)) return keyword;
        }
        return null;
    };


    const checkUserBioInBackground = (username, tweetElement) => {
        if (userBioCache.get(username)) return;

        userBioCache.set(username, 'checking');

        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://x.com/${username}`,
            onload: function(response) {
                const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
                const bioElement = doc.querySelector('[data-testid="UserDescription"]');
                const bioText = getElementTextWithEmojiAlt(bioElement);
                const matchedKeyword = findMatchingKeyword(bioText);

                if (matchedKeyword) {
                    userBioCache.set(username, 'blocked');
                    hideTweet(tweetElement, `简介含 "<b>${matchedKeyword}</b>"`, `@${username}`);
                } else {
                    userBioCache.set(username, 'safe');
                }
            },
            onerror: function(response) {
                console.error(`[Twitter Blocker] 获取 @${username} 的主页失败:`, response);
                userBioCache.set(username, 'safe');
            }
        });
    };

    const hideTweet = (tweetElement, reason, source) => {
        const message = `已屏蔽 (<b>${source}</b>)<br>原因: ${reason}`;
        showNotification(message);
        console.log(`[Twitter Blocker] ${message.replace(/<br>|<b>|<\/b>/g, ' ')}`);

        const parentCell = tweetElement.closest('div[data-testid="cellInnerDiv"]');
        if (parentCell) {
            parentCell.style.display = 'none';
        } else {
            tweetElement.style.display = 'none';
        }
    };

    const processTweet = (tweetElement) => {
        if (tweetElement.dataset.blockerChecked) return;
        tweetElement.dataset.blockerChecked = 'true';

        if (isCurrentProfileBlocked) {
            hideTweet(tweetElement, "当前主页已被屏蔽", "主页状态");
            return;
        }

        let matchedKeyword, reason, source;

        const tweetTextElement = tweetElement.querySelector('[data-testid="tweetText"]');
        const tweetText = getElementTextWithEmojiAlt(tweetTextElement);
        matchedKeyword = findMatchingKeyword(tweetText);
        if (matchedKeyword) {
            hideTweet(tweetElement, `内容含 "<b>${matchedKeyword}</b>"`, "推文内容");
            return;
        }

        const userLinkElement = tweetElement.querySelector('[data-testid="User-Name"] a[href^="/"]');
        if (!userLinkElement) return;

        const username = userLinkElement.getAttribute('href').substring(1);
        const userDisplayName = getElementTextWithEmojiAlt(userLinkElement);
        source = `<b>${userDisplayName}</b> (@${username})`;

        matchedKeyword = findMatchingKeyword(username) || findMatchingKeyword(userDisplayName);
        if (matchedKeyword) {
            hideTweet(tweetElement, `用户名含 "<b>${matchedKeyword}</b>"`, source);
            return;
        }

        const cacheResult = userBioCache.get(username);
        if (cacheResult === 'blocked') {
            hideTweet(tweetElement, "简介(来自缓存)", source);
            return;
        }

        if (!cacheResult) {
            checkUserBioInBackground(username, tweetElement);
        }
    };

    const processProfile = () => {
        const bioElement = document.querySelector('[data-testid="UserDescription"]');
        if (bioElement && !bioElement.dataset.blockerChecked) {
            bioElement.dataset.blockerChecked = 'true';
            const bioText = getElementTextWithEmojiAlt(bioElement);
            const matchedKeyword = findMatchingKeyword(bioText);

            if (matchedKeyword) {
                isCurrentProfileBlocked = true;
                const message = `用户主页已屏蔽<br>原因: 简介含 "<b>${matchedKeyword}</b>"`;
                showNotification(message);
                console.log(`[Twitter Blocker] ${message.replace(/<br>|<b>|<\/b>/g, ' ')}`);
                scanAndBlock(true);
            }
        }
    };

    const scanAndBlock = (forceScan = false) => {
        if (window.location.href !== lastCheckedUrl) {
            lastCheckedUrl = window.location.href;
            isCurrentProfileBlocked = false;
        }

        const path = window.location.pathname;
        const isProfilePage = path.split('/').length === 2 && path.length > 1 && !path.includes('/i/') && !/^\/(home|explore|notifications|messages|search|settings)/.test(path);


        if (isProfilePage) {
            processProfile();
        }

        const tweets = document.querySelectorAll('article[data-testid="tweet"]:not([data-blocker-checked])');
        tweets.forEach(processTweet);
    };

    const observer = new MutationObserver(() => scanAndBlock());

    const start = () => {
        if (keywords.length === 0) return console.log('[Twitter Blocker] 关键词列表为空,脚本未启动。');
        console.log('[Twitter Blocker] 智能屏蔽脚本已启动,当前关键词:', keywords);

        initToastContainer();
        
        window.requestIdleCallback ? requestIdleCallback(scanAndBlock) : setTimeout(scanAndBlock, 500);

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

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

})();