X.com Chain Blocker

Block author, retweeters, repliers, and auto-block users based on rules (length, content, keywords). Manage block log, whitelist, and settings in a panel.

// ==UserScript==
// @name         X.com Chain Blocker
// @name:zh-CN   X.com 九族拉黑
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  Block author, retweeters, repliers, and auto-block users based on rules (length, content, keywords). Manage block log, whitelist, and settings in a panel.
// @description:zh-CN 当拉黑作者时,自动拉黑所有转推者和回复者。支持根据长度、内容、关键词等规则自动拉黑,并提供黑/白名单管理面板。
// @author       Gemini 2.5 Pro
// @license      MIT
// @match        *://x.com/*
// @match        *://twitter.com/*
// @exclude      *://x.com/settings*
// @exclude      *://twitter.com/settings*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      api.x.com
// @connect      x.com
// ==/UserScript==
(function () {
'use strict';
// --- CONFIG & CONSTANTS ---
const MENU_ITEM_TEXT = "九族拉黑";
const STORAGE_KEY = 'CHAIN_BLOCKER_DATA';
const CONFIG_STORAGE_KEY = 'CHAIN_BLOCKER_CONFIG';
const BLOCK_INTERVAL_MS = 10 * 1000;
const PROCESS_CHECK_INTERVAL_MS = 5 * 1000;
const USERNAME_LENGTH_THRESHOLD = 25;
const AUTO_SCAN_INTERVAL_MS = 2000;
const API_RETRY_DELAY_MS = 5 * 60 * 1000;
let currentUserId = null, currentUserScreenName = null, activeTweetArticle = null;
let isProcessingQueue = false, processIntervalId = null, apiLimitCountdownInterval = null;
let scriptConfig = {}, isConfigPanelBusy = false;

// --- STYLES ---
GM_addStyle(`.nuke-toast{position:fixed;top:20px;right:20px;z-index:100000;background-color:#15202b;color:white;padding:10px 15px;border-radius:12px;border:1px solid #38444d;box-shadow:0 4px 12px rgba(0,0,0,0.4);width:auto;max-width:350px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;transition:all .5s ease-out;opacity:1;transform:translateX(0)}.nuke-toast.fading-out{opacity:0;transform:translateX(20px)}.nuke-toast-title{font-weight:bold;margin-bottom:8px;font-size:16px}.nuke-toast-status{font-size:14px;margin-bottom:0;line-height:1.5}#nuke-status-toast{background-color:#253341}#nuke-api-limit-toast{background-color:#d9a100;color:#15202b;border-color:#ffc107}.nuke-config-panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:100001;background-color:#15202b;color:white;border-radius:16px;border:1px solid #38444d;box-shadow:0 8px 24px rgba(0,0,0,0.5);width:550px;max-width:90vw;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}.nuke-panel-header{display:flex;align-items:center;justify-content:space-between;height:53px;padding:0 16px;border-bottom:1px solid #38444d}.nuke-header-item{flex-basis:56px;display:flex;align-items:center}.nuke-header-item.left{justify-content:flex-start}.nuke-header-item.right{justify-content:flex-end}.nuke-config-title{font-weight:bold;font-size:20px;flex-grow:1;text-align:center}.nuke-close-button{background:0 0;border:0;padding:0;cursor:pointer;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:9999px;transition:background-color .2s ease-in-out}.nuke-close-button:hover{background-color:rgba(239,243,244,0.1)}.nuke-close-button svg{fill:white;width:20px;height:20px}.nuke-panel-content{padding:16px}.nuke-config-textarea{width:100%;background-color:#253341;border:1px solid #38444d;border-radius:8px;color:white;padding:10px;font-size:14px;resize:vertical;box-sizing:border-box;margin-bottom:15px}.nuke-url-textarea{height:100px}.nuke-keywords-textarea{height:80px}.nuke-config-button-container{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}.nuke-config-button.save{background-color:#eff3f4;color:#0f1419;padding:8px 16px;border-radius:20px;border:none;font-weight:bold;cursor:pointer;transition:background-color .2s}.nuke-config-button.save:hover{background-color:#d7dbdc}.nuke-config-tabs{display:flex;border-bottom:1px solid #38444d;margin-bottom:15px}.nuke-config-tab{background:0 0;border:none;color:#8899a6;padding:10px 15px;cursor:pointer;font-size:15px;font-weight:700;flex-grow:1;transition:background-color .2s}.nuke-config-tab:hover{background-color:rgba(239,243,244,0.1)}.nuke-config-tab.active{color:#1d9bf0;border-bottom:2px solid #1d9bf0;margin-bottom:-1px}.nuke-config-tab-content{animation:fadeIn .3s ease-in-out;padding-top:10px}.nuke-config-tab-content.hidden{display:none}@keyframes fadeIn{from{opacity:0}to{opacity:1}}.nuke-list{max-height:280px;overflow-y:auto;padding-right:10px}.nuke-list-search{width:100%;background-color:#253341;border:1px solid #38444d;border-radius:8px;color:white;padding:8px 12px;font-size:14px;box-sizing:border-box;margin-bottom:10px}.nuke-list-entry{display:flex;justify-content:space-between;align-items:center;padding:8px 5px;border-bottom:1px solid #253341}.nuke-list-user-info{display:flex;flex-direction:column;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:10px}.nuke-list-user-name{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nuke-list-user-handle{color:#8899a6;font-size:14px;cursor:pointer}.nuke-list-user-handle:hover{text-decoration:underline}.nuke-list-actions{font-size:12px;color:#8899a6;white-space:nowrap;cursor:pointer}.nuke-list-actions:hover{color:#1d9bf0}.nuke-list-user-info a{color:inherit;text-decoration:none}.nuke-list-user-info a:hover .nuke-list-user-name{text-decoration:underline}.nuke-setting-item{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px}.nuke-setting-item label{font-size:14px;margin-right:10px}.nuke-setting-item input[type=number]{width:80px;background-color:#253341;border:1px solid #38444d;border-radius:8px;color:white;padding:5px 8px;font-size:14px}.nuke-setting-item input[type=checkbox]{height:20px;width:20px;accent-color:#1d9bf0}.nuke-settings-label{display:block;font-size:14px;color:#8899a6;margin-top:10px;margin-bottom:10px}`);

// --- CONFIGURATION MANAGEMENT ---
async function loadConfig() {
    const defaultConfig = { autoBlockEnabled: true, autoBlockUrls: ['https://x.com/*/status/*', 'https://x.com/search*'], blockLogLimit: 500, blockKeywords: [] };
    scriptConfig = await GM_getValue(CONFIG_STORAGE_KEY, defaultConfig);
    return scriptConfig;
}
async function saveConfig(config) { await GM_setValue(CONFIG_STORAGE_KEY, config); scriptConfig = config; }
function updateMenuCommands() { GM_registerMenuCommand('配置与记录', showConfigPanel); }
async function showConfigPanel() {
    if (isConfigPanelBusy) return;
    isConfigPanelBusy = true;
    try {
        if (document.getElementById('nuke-url-config-panel')?.remove()) return;
        let config = await loadConfig();
        const panel = document.createElement('div');
        panel.id = 'nuke-url-config-panel';
        panel.className = 'nuke-config-panel';
        panel.innerHTML = `
            <div class="nuke-panel-header">
                <div class="nuke-header-item left">
                    <button class="nuke-close-button" aria-label="关闭"><svg viewBox="0 0 24 24"><g><path d="M10.59 12L4.54 5.96l1.42-1.42L12 10.59l6.04-6.05 1.42 1.42L13.41 12l6.05 6.04-1.42 1.42L12 13.41l-6.04 6.05-1.42-1.42L10.59 12z"></path></g></svg></button>
                </div>
                <h2 class="nuke-config-title">配置与记录</h2>
                <div class="nuke-header-item right"></div>
            </div>
            <div class="nuke-panel-content">
                <div class="nuke-config-tabs">
                    <button class="nuke-config-tab active" data-tab="settings">⚙️ 设置</button>
                    <button class="nuke-config-tab" data-tab="log">📓 拉黑记录</button>
                    <button class="nuke-config-tab" data-tab="whitelist">🛡️ 白名单</button>
                </div>
                <div id="nuke-settings-content" class="nuke-config-tab-content">
                    <div class="nuke-setting-item">
                        <label for="nuke-auto-block-toggle">自动拉黑可疑用户名</label>
                        <input type="checkbox" id="nuke-auto-block-toggle">
                    </div>
                    <div class="nuke-setting-item">
                        <label for="nuke-log-limit-input">拉黑记录最大条数 (0为不限制)</label>
                        <input type="number" id="nuke-log-limit-input" min="0" step="100">
                    </div>
                     <label class="nuke-settings-label" for="nuke-keywords-textarea">可疑用户名关键词 (每行一个):</label>
                    <textarea id="nuke-keywords-textarea" class="nuke-config-textarea nuke-keywords-textarea"></textarea>
                    <label class="nuke-settings-label" for="nuke-urls-textarea">自动拉黑生效的页面 URL (每行一条, 支持*通配符):</p>
                    <textarea id="nuke-urls-textarea" class="nuke-config-textarea nuke-url-textarea"></textarea>
                    <div class="nuke-config-button-container">
                        <button class="nuke-config-button save">保存设置</button>
                    </div>
                </div>
                <div id="nuke-log-content" class="nuke-config-tab-content hidden">
                    <input type="search" class="nuke-list-search" id="nuke-log-search" placeholder="搜索记录 (用户名, @handle, ID)...">
                    <div class="nuke-list"></div>
                </div>
                <div id="nuke-whitelist-content" class="nuke-config-tab-content hidden">
                    <input type="search" class="nuke-list-search" id="nuke-whitelist-search" placeholder="搜索白名单 (用户名, @handle, ID)...">
                    <div class="nuke-list"></div>
                </div>
            </div>`;
        document.body.appendChild(panel);
        panel.querySelector('#nuke-auto-block-toggle').checked = config.autoBlockEnabled;
        panel.querySelector('#nuke-log-limit-input').value = config.blockLogLimit;
        panel.querySelector('#nuke-urls-textarea').value = (config.autoBlockUrls || []).join('\n');
        panel.querySelector('#nuke-keywords-textarea').value = (config.blockKeywords || []).join('\n');
        const setActiveTab = (tabName) => {
            panel.querySelectorAll('.nuke-config-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
            panel.querySelectorAll('.nuke-config-tab-content').forEach(c => c.classList.toggle('hidden', c.id !== `nuke-${tabName}-content`));
        };
        panel.querySelectorAll('.nuke-config-tab').forEach(tab => tab.addEventListener('click', () => setActiveTab(tab.dataset.tab)));
        panel.querySelector('.nuke-close-button').addEventListener('click', () => panel.remove());
        panel.querySelector('.nuke-config-button.save').addEventListener('click', async () => {
            config.autoBlockEnabled = panel.querySelector('#nuke-auto-block-toggle').checked;
            config.blockLogLimit = parseInt(panel.querySelector('#nuke-log-limit-input').value, 10) || 500;
            config.autoBlockUrls = panel.querySelector('#nuke-urls-textarea').value.split('\n').map(url => url.trim()).filter(Boolean);
            config.blockKeywords = panel.querySelector('#nuke-keywords-textarea').value.split('\n').map(kw => kw.trim()).filter(Boolean);
            await saveConfig(config);
            showToast('nuke-config-toast', '设置已更新', '配置已成功保存', 3000);
        });
        panel.querySelector('#nuke-log-search').addEventListener('input', renderListsInPanel);
        panel.querySelector('#nuke-whitelist-search').addEventListener('input', renderListsInPanel);
        await renderListsInPanel();
    } finally { setTimeout(() => { isConfigPanelBusy = false; }, 200); }
}
async function renderListsInPanel() {
    const userData = await loadUserData();
    if (!userData) return;
    const logSearchTerm = document.getElementById('nuke-log-search')?.value.toLowerCase() || '';
    const whitelistSearchTerm = document.getElementById('nuke-whitelist-search')?.value.toLowerCase() || '';
    const filterUsers = (user, term) => {
        if (!term) return true;
        const userId = String(user.userId || '');
        const screenName = user.screenName?.toLowerCase() || '';
        const userNameText = user.userNameText?.toLowerCase() || '';
        return userId.includes(term) || screenName.includes(term) || userNameText.includes(term);
    };
    const renderList = (containerSelector, list, type) => {
        const container = document.querySelector(containerSelector);
        if (!container) return;
        const searchTerm = type === 'log' ? logSearchTerm : whitelistSearchTerm;
        const filteredList = list.filter(user => filterUsers(user, searchTerm));
        container.innerHTML = '';
        if (filteredList.length === 0) {
            const message = searchTerm ? '没有找到匹配的用户' : (type === 'log' ? '暂无拉黑记录' : '白名单为空');
            container.innerHTML = `<p style="color:#8899a6;text-align:center;padding:20px 0;">${message}</p>`;
            return;
        }
        filteredList.slice().reverse().forEach(entry => {
            const el = document.createElement('div');
            el.className = 'nuke-list-entry';
            const userName = entry.userNameText || entry.screenName || String(entry.userId);
            const screenNameHandle = entry.screenName ? `@${entry.screenName}` : '';
            const userLinkHTML = entry.screenName ? `<a href="https://x.com/${entry.screenName}" target="_blank" rel="noopener noreferrer" title="在新标签页中打开"><span class="nuke-list-user-name">${userName}</span></a>` : `<span class="nuke-list-user-name">${userName}</span>`;
            if (type === 'log') {
                const timestamp = entry.blockTimestamp ? new Date(entry.blockTimestamp).toLocaleString() : '未知时间';
                el.innerHTML = `<div class="nuke-list-user-info">${userLinkHTML}<span class="nuke-list-user-handle" title="移至白名单并取消拉黑">${screenNameHandle}</span></div><span class="nuke-list-actions" title="从记录中移除">${timestamp}</span>`;
                if (entry.screenName) {
                    el.querySelector('.nuke-list-user-handle')?.addEventListener('click', () => moveUser(entry, 'logToWhitelist'));
                } else {
                    const userNameEl = el.querySelector('.nuke-list-user-name');
                    if (userNameEl) {
                        userNameEl.style.cursor = 'pointer';
                        userNameEl.title = '移至白名单并取消拉黑';
                        userNameEl.addEventListener('click', () => moveUser(entry, 'logToWhitelist'));
                    }
                }
                el.querySelector('.nuke-list-actions')?.addEventListener('click', () => moveUser(entry, 'removeFromLog'));
            } else {
                el.innerHTML = `<div class="nuke-list-user-info">${userLinkHTML}<span class="nuke-list-user-handle">${screenNameHandle}</span></div><span class="nuke-list-actions" title="从白名单中移除">移除</span>`;
                el.querySelector('.nuke-list-actions')?.addEventListener('click', () => moveUser(entry, 'removeFromWhitelist'));
            }
            container.appendChild(el);
        });
    };
    renderList('#nuke-log-content .nuke-list', userData.blockedLog, 'log');
    renderList('#nuke-whitelist-content .nuke-list', userData.whitelist, 'whitelist');
}
async function moveUser(user, action) {
    const userData = await loadUserData();
    if (!userData) return;
    const logIndex = userData.blockedLog.findIndex(u => u.userId === user.userId);
    const whitelistIndex = userData.whitelist.findIndex(u => u.userId === user.userId);
    let success = false;
    try {
        if (action === 'logToWhitelist') {
            if (logIndex > -1) {
                await unblockUserById(user.userId);
                const [movedUser] = userData.blockedLog.splice(logIndex, 1);
                if (whitelistIndex === -1) userData.whitelist.push(movedUser);
                success = true;
            }
        } else if (action === 'removeFromLog') {
            if (logIndex > -1) { userData.blockedLog.splice(logIndex, 1); success = true; }
        } else if (action === 'removeFromWhitelist') {
            if (whitelistIndex > -1) { userData.whitelist.splice(whitelistIndex, 1); success = true; }
        }
        if(success) {
            await saveUserData(userData);
            await renderListsInPanel();
        }
    } catch(err) {
        console.error(`[CB] ${action} failed for ${user.screenName || user.userId}:`, err);
        showToast('nuke-feedback-toast', '❌ 操作失败', `无法为 @${user.screenName || user.userId} 执行操作`, 4000);
    }
}

// --- API & HELPERS ---
const API_ENDPOINTS = {
    UserByScreenName: { hash: 'jUKA--0QkqGIFhmfRZdWrQ', features: {"responsive_web_grok_bio_auto_translation_is_enabled":false,"hidden_profile_subscriptions_enabled":true,"payments_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"subscriptions_verification_info_is_identity_verified_enabled":true,"subscriptions_verification_info_verified_since_enabled":true,"highlights_tweets_tab_ui_enabled":true,"responsive_web_twitter_article_notes_tab_enabled":true,"subscriptions_feature_can_gift_premium":true,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true} },
    UserByRestId: { hash: 'tD4_0f_p354q1Yin156s2Q', features: {"responsive_web_grok_bio_auto_translation_is_enabled":false,"hidden_profile_subscriptions_enabled":true,"payments_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"subscriptions_verification_info_is_identity_verified_enabled":true,"subscriptions_verification_info_verified_since_enabled":true,"highlights_tweets_tab_ui_enabled":true,"responsive_web_twitter_article_notes_tab_enabled":true,"subscriptions_feature_can_gift_premium":true,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true} },
    Retweeters: { hash: 'DmC_H6eV_XMiL0g4ltJvpg', features: {"rweb_video_screen_enabled":false,"payments_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_enhance_cards_enabled":false} },
    TweetDetail: { hash: '-0WTL1e9Pij-JWAF5ztCCA', features: {"rweb_video_screen_enabled":false,"payments_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_enhance_cards_enabled":false} }
};
function makeApiRequest(url, method = "GET", data = null) { return new Promise((resolve, reject) => GM_xmlhttpRequest({ method, url, data, headers: { Authorization: `Bearer ${getAuthToken()}`, "Content-Type": "application/x-www-form-urlencoded", "x-csrf-token": getCsrfToken() }, onload: r => r.status >= 200 && r.status < 300 ? resolve(r.responseText ? JSON.parse(r.responseText) : null) : reject({ message: `API请求失败: ${r.status}`, status: r.status }), onerror: e => reject({ message: "Network or script error", error: e }) })); }
function getCsrfToken() { const e = document.cookie.split("; ").find(e => e.startsWith("ct0=")); return e ? e.split("=")[1] : null; }
function getAuthToken() { return "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; }
async function getUserDataByScreenName(screenName) {
    const endpoint = API_ENDPOINTS.UserByScreenName;
    const url = `https://x.com/i/api/graphql/${endpoint.hash}/UserByScreenName?variables=${encodeURIComponent(JSON.stringify({screen_name:screenName,withSafetyModeUserFields:true}))}&features=${encodeURIComponent(JSON.stringify(endpoint.features))}`;
    const data = await makeApiRequest(url);
    if (data?.data?.user?.result) return data.data.user.result;
    throw new Error(`无法找到用户 @${screenName} 的数据`);
}
async function getUserDataById(userId) {
    const endpoint = API_ENDPOINTS.UserByRestId;
    const url = `https://x.com/i/api/graphql/${endpoint.hash}/UserByRestId?variables=${encodeURIComponent(JSON.stringify({userId,withSafetyModeUserFields:true}))}&features=${encodeURIComponent(JSON.stringify(endpoint.features))}`;
    const data = await makeApiRequest(url);
    if (data?.data?.user?.result) return data.data.user.result;
    throw new Error(`无法找到用户 ID: ${userId} 的数据`);
}
async function getRetweetersData(tweetId, onProgress) {
    let users = new Map(), cursor = null, endpoint = API_ENDPOINTS.Retweeters;
    do {
        onProgress(`正在获取转推列表...(已找到: ${users.size})`);
        const url = `https://x.com/i/api/graphql/${endpoint.hash}/Retweeters?variables=${encodeURIComponent(JSON.stringify({tweetId,count:100,cursor,includePromotedContent:true}))}&features=${encodeURIComponent(JSON.stringify(endpoint.features))}`;
        const data = await makeApiRequest(url);
        const entries = data?.data?.retweeters_timeline?.timeline?.instructions?.find(i=>i.type==='TimelineAddEntries')?.entries;
        if (!entries) break;
        let foundNewUsers = false;
        for (const entry of entries) {
            if (entry.entryId.startsWith('user-')) {
                const userResult = entry.content?.itemContent?.user_results?.result;
                if (userResult?.rest_id && !users.has(userResult.rest_id)) { users.set(userResult.rest_id, userResult); foundNewUsers = true; }
            } else if (entry.entryId.startsWith('cursor-bottom-')) { cursor = entry.content.value; }
        }
        if (!foundNewUsers || !cursor) break;
    } while (cursor);
    return Array.from(users.values());
}
async function getRepliersData(tweetId, onProgress) {
    let users = new Map(), cursor = null, endpoint = API_ENDPOINTS.TweetDetail;
    const baseVariables = {"with_rux_injections":false,"includePromotedContent":true,"withCommunity":true,"withQuickPromoteEligibilityTweetFields":true,"withBirdwatchNotes":true,"withVoice":true,"withV2Timeline":true};
    do {
        onProgress(`正在获取回复列表...(已找到: ${users.size})`);
        const variables = {...baseVariables, focalTweetId: tweetId, cursor, count: 40, rankingMode:"Relevance"};
        const url = `https://x.com/i/api/graphql/${endpoint.hash}/TweetDetail?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(endpoint.features))}`;
        const data = await makeApiRequest(url);
        const instructions = data?.data?.threaded_conversation_with_injections_v2?.instructions || [];
        const entriesInstruction = instructions.find(i => i.type === 'TimelineAddEntries');
        const entries = entriesInstruction?.entries;
        if (!entries) break;
        let nextCursor = null;
        for (const entry of entries) {
            if (entry.entryId.startsWith('conversationthread-')) {
                const threadItems = entry.content?.items;
                if(threadItems && Array.isArray(threadItems)){
                    for(const item of threadItems){
                        const userResult = item.item?.itemContent?.tweet_results?.result?.core?.user_results?.result;
                        if (userResult?.rest_id && !users.has(userResult.rest_id)) {
                            users.set(userResult.rest_id, userResult);
                        }
                    }
                }
            } else if (entry.entryId.startsWith('tweet-')) {
                const userResult = entry.content?.itemContent?.tweet_results?.result?.core?.user_results?.result;
                if (userResult?.rest_id && !users.has(userResult.rest_id)) {
                   users.set(userResult.rest_id, userResult);
                }
            } else if (entry.entryId.startsWith('cursor-bottom-')) {
                nextCursor = entry.content.value;
            }
        }
        if (cursor === nextCursor) break;
        cursor = nextCursor;
    } while (cursor);
    return Array.from(users.values());
}
async function blockUserById(userId) { return makeApiRequest("https://x.com/i/api/1.1/blocks/create.json", "POST", `user_id=${userId}`); }
async function unblockUserById(userId) { return makeApiRequest("https://x.com/i/api/1.1/blocks/destroy.json", "POST", `user_id=${userId}`); }

// --- DATA & QUEUE MANAGEMENT ---
async function loadUserData() {
    if (!currentUserId) return null;
    const allData = await GM_getValue(STORAGE_KEY, {});
    let userData = allData[currentUserId];
    if (!userData || typeof userData !== 'object') userData = { queue: [], blockedLog: [], whitelist: [] };
    if (!Array.isArray(userData.queue)) userData.queue = [];
    if (!Array.isArray(userData.blockedLog)) userData.blockedLog = [];
    if (!Array.isArray(userData.whitelist)) userData.whitelist = [];
    return { ...userData, lastBlockTimestamp: 0 };
}
async function saveUserData(data) {
    if (!currentUserId) return;
    const allData = await GM_getValue(STORAGE_KEY, {});
    allData[currentUserId] = data;
    await GM_setValue(STORAGE_KEY, allData);
}

// --- UI & FEEDBACK ---
function showToast(id, title, status, duration = null) {
    let toast = document.getElementById(id);
    if (!toast) {
        toast = document.createElement('div');
        toast.id = id;
        toast.className = 'nuke-toast';
        document.body.appendChild(toast);
    }
    const existingToasts = document.querySelectorAll('.nuke-toast:not([style*="display: none"])');
    toast.style.top = `${20 + (existingToasts.length - 1) * 70}px`;
    toast.classList.remove('fading-out');
    toast.innerHTML = `<div class="nuke-toast-title">${title}</div><div class="nuke-toast-status">${status}</div>`;
    const reorderToasts = () => {
        const remainingToasts = Array.from(document.querySelectorAll('.nuke-toast')).filter(t => t.id !== id);
        remainingToasts.forEach((t, index) => {
            t.style.top = `${20 + index * 70}px`;
        });
    };
    if (duration) {
        setTimeout(() => {
            toast.classList.add('fading-out');
            setTimeout(() => {
                toast.remove();
                reorderToasts();
            }, 500);
        }, duration);
    }
}
async function updateStatusToast() {
    const userData = await loadUserData();
    if (!userData || userData.queue.length === 0) {
        let toast = document.getElementById('nuke-status-toast');
        if (toast) { toast.classList.add('fading-out'); setTimeout(() => toast.remove(), 500); }
        return;
    }
    showToast('nuke-status-toast', `🚀 九族拉黑队列(@${currentUserScreenName||'...'})`, `<b>待处理:</b> ${userData.queue.length}<br><b>已拉黑:</b> ${userData.blockedLog.length || 0}`);
}
function hideElement(element) {
    if (!element) return;
    element.style.cssText += 'transition:all .4s ease-out;max-height:0;opacity:0;padding:0;margin:0;border-width:0;';
    setTimeout(() => element.remove(), 400);
}

// --- CORE LOGIC ---
async function processQueue() {
    if (isProcessingQueue || !currentUserId) return;
    const userData = await loadUserData();
    if (!userData || userData.queue.length === 0 || (Date.now() - userData.lastBlockTimestamp < BLOCK_INTERVAL_MS)) return;
    isProcessingQueue = true;
    let userToBlock = userData.queue[0];
    try {
        if (!userToBlock.screenName || !userToBlock.userNameText) {
            try {
                const fullUserData = await getUserDataById(userToBlock.userId);
                userToBlock.screenName = fullUserData.core?.screen_name || fullUserData.legacy?.screen_name;
                userToBlock.userNameText = fullUserData.core?.name || fullUserData.legacy?.name;
            } catch (fetchError) {
                console.warn(`[CB] 获取用户 ${userToBlock.userId} 的详细信息失败,将使用现有数据继续。`, fetchError);
            }
        }
        await blockUserById(userToBlock.userId);
        userData.queue.shift();
        userData.blockedLog.push({ ...userToBlock, blockTimestamp: Date.now() });
        const limit = scriptConfig.blockLogLimit || 500;
        if (limit > 0) { while (userData.blockedLog.length > limit) userData.blockedLog.shift(); }
        userData.lastBlockTimestamp = Date.now();
    } catch (error) {
        console.error(`[Chain Blocker] 拉黑 @${userToBlock.screenName || userToBlock.userId} 失败,移除.`, error);
        userData.queue.shift();
    } finally {
        await saveUserData(userData);
        await updateStatusToast();
        isProcessingQueue = false;
    }
}
function getExemptHandles() {
    const exemptHandles = [];
    const pathParts = window.location.pathname.split('/');
    if (pathParts[2] === 'status') {
        exemptHandles.push(pathParts[1]);
    }
    return exemptHandles;
}
async function initiateNukeProcess(targetArticle) {
    const exemptHandles = getExemptHandles();
    showToast(`nuke-feedback-toast-${Date.now()}`, '🚀 九族拉黑已启动', '正在处理...', 2000);
    hideElement(targetArticle);
    try {
        const userLink = targetArticle.querySelector('div[data-testid="User-Name"] a[role="link"]');
        const authorHandle = userLink?.href.split('/').pop();
        const authorUserNameText = targetArticle.querySelector('div[data-testid="User-Name"] a[role="link"] span')?.textContent?.trim() || authorHandle;
        if (!authorHandle) throw new Error("无法确定作者 handle");
        const userData = await loadUserData();
        if (!userData) throw new Error("无法加载用户数据");
        const whitelistIds = new Set(userData.whitelist.map(u => u.userId));
        let authorId = null;
        try {
            const authorData = await getUserDataByScreenName(authorHandle);
            authorId = authorData?.rest_id;
            if (!authorId) throw new Error(`无法获取 @${authorHandle} 的用户ID`);
            if (whitelistIds.has(authorId) || exemptHandles.includes(authorHandle)) {
                showToast(`nuke-feedback-toast-${Date.now()}`, '🛡️ 用户在白名单或豁免列表', `已跳过拉黑 @${authorHandle}`, 4000);
            } else {
                await blockUserById(authorId);
                userData.blockedLog.push({ userId: authorId, screenName: authorHandle, userNameText: authorUserNameText, blockTimestamp: Date.now() });
                const limit = scriptConfig.blockLogLimit || 500;
                if (limit > 0) { while (userData.blockedLog.length > limit) userData.blockedLog.shift(); }
                await saveUserData(userData);
                showToast(`nuke-feedback-toast-${Date.now()}`, '✅ 作者已拉黑并记录', `已立刻拉黑 @${authorHandle}`, 4000);
            }
        } catch (authorError) { console.error(`[CB] 拉黑作者 @${authorHandle} 失败:`, authorError); }
        const tweetId = Array.from(targetArticle.querySelectorAll('a')).find(a=>a.href.includes('/status/'))?.href.match(/\/status\/(\d+)/)?.[1];
        if (!tweetId) return;
        const [retweeters, repliers] = await Promise.all([
            getRetweetersData(tweetId, status => showToast(`nuke-fetch-toast-${Date.now()}`, '收集中...', status, 4000)),
            getRepliersData(tweetId, status => showToast(`nuke-fetch-toast-${Date.now()}`, '收集中...', status, 4000))
        ]);
        const combinedUsers = new Map();
        [...retweeters, ...repliers].forEach(u => u.rest_id && combinedUsers.set(u.rest_id, u));
        if (authorId) combinedUsers.delete(authorId);
        const existingUserIds = new Set([...userData.queue.map(u=>u.userId), ...userData.blockedLog.map(u=>u.userId), ...whitelistIds]);
        const newUsersToQueue = Array.from(combinedUsers.values()).map(u => ({
            userId: u.rest_id,
            screenName: u.core?.screen_name || u.legacy?.screen_name,
            userNameText: u.core?.name || u.legacy?.name
        })).filter(u => u.userId && u.userId !== currentUserId && !existingUserIds.has(u.userId) && !exemptHandles.includes(u.screenName));
        if (newUsersToQueue.length > 0) {
            userData.queue.push(...newUsersToQueue);
            await saveUserData(userData);
            showToast(`nuke-feedback-toast-${Date.now()}`, '✅ 操作成功', `已将 ${newUsersToQueue.length} 个相关用户加入拉黑队列。`, 4000);
        } else {
            showToast(`nuke-feedback-toast-${Date.now()}`, 'ℹ️ 操作完成', `没有找到新的可拉黑用户。`, 4000);
        }
        await updateStatusToast();
        setTimeout(processQueue, 1000);
    } catch (error) { console.error("[CB] 收集过程中发生错误:", error); showToast(`nuke-feedback-toast-${Date.now()}`, '❌ 发生错误', error.message, 5000); }
}

// --- UI SCANNING & AUTOMATION ---
function isUrlMatch(url, patterns) { return patterns.some(p => new RegExp('^' + p.replace(/\*/g, '.*') + '$').test(url)); }
function scanAndProcessContent() {
    document.querySelectorAll('div[data-testid="cellInnerDiv"]:not([style*="display: none"]) button[data-testid$="-unblock"]').forEach(btn => btn.closest('div[data-testid="cellInnerDiv"]').style.display = 'none');
    if (!currentUserId || !scriptConfig.autoBlockEnabled || !isUrlMatch(window.location.href, scriptConfig.autoBlockUrls)) return;
    const checkUserName = (userNameText) => {
        if (!userNameText) return false;
        const hasChinese = /[\u4e00-\u9fa5]/.test(userNameText);
        const slashCount = (userNameText.match(/\//g) || []).length;
        const isSuspiciousByStructure = userNameText.length > USERNAME_LENGTH_THRESHOLD && hasChinese && slashCount >= 2;
        let isSuspiciousByKeyword = false;
        const keywords = scriptConfig.blockKeywords || [];
        if (keywords.length > 0) {
            const lowerUserName = userNameText.toLowerCase();
            const matchesKeyword = keywords.some(keyword => lowerUserName.includes(keyword.toLowerCase()));
            if (matchesKeyword && userNameText.length > USERNAME_LENGTH_THRESHOLD) {
                isSuspiciousByKeyword = true;
            }
        }
        return isSuspiciousByStructure || isSuspiciousByKeyword;
    };
    document.querySelectorAll('article[data-testid="tweet"]:not([data-autoblock-checked])').forEach(article => {
        article.dataset.autoblockChecked = 'true';
        const userNameText = article.querySelector('div[data-testid="User-Name"] a[role="link"] span')?.textContent?.trim();
        if (checkUserName(userNameText)) {
            initiateNukeProcess(article);
        }
    });
    document.querySelectorAll('button[data-testid="UserCell"]:not([data-autoblock-checked])').forEach(cell => {
        cell.dataset.autoblockChecked = 'true';
        const userNameText = cell.querySelector('a[role="link"] div[dir="ltr"] > span')?.textContent?.trim();
        if (checkUserName(userNameText)) {
            const screenName = cell.querySelector('a[role="link"]')?.href.split('/').pop();
            if (screenName) {
                showToast(`nuke-auto-trigger-toast-${Date.now()}`, '🤖 自动执行拉黑', `检测到可疑用户名: @${screenName}`, 4000);
                initiateNukeProcess(cell.closest('article[data-testid="tweet"]'));
            }
        }
    });
}
function addNukeButton(menuNode) {
    if (menuNode.querySelector('.nuke-button')) return;
    const blockMenuItem = Array.from(menuNode.querySelectorAll('div[role="menuitem"]')).find(el => el.textContent.includes('@'));
    if (!blockMenuItem) return;
    const nukeButton = blockMenuItem.cloneNode(true);
    nukeButton.classList.add('nuke-button');
    const span = nukeButton.querySelector('span');
    if (span) {
        span.textContent = MENU_ITEM_TEXT;
        span.style.color = 'rgb(244, 33, 46)';
    }
    const biohazardIconPath = "M19.5,12c0,2.9-1.6,5.5-4,6.8V21h-7v-2.2c-2.4-1.3-4-3.9-4-6.8c0-4.1,3.4-7.5,7.5-7.5S19.5,7.9,19.5,12z M12,6c-2.2,0-4,1.8-4,4s1.8,4,4,4s4-1.8,4-4S14.2,6,12,6z M12,14c-1.1,0-2-0.9-2-2c0-0.4,0.1-0.7,0.3-1H10v-2h1.3c-0.2-0.3-0.3-0.6-0.3-1c0-1.1,0.9-2,2-2s2,0.9,2,2c0,0.4-0.1,0.7-0.3,1H14v2h-1.3c0.2,0.3,0.3,0.6,0.3,1C14,13.1,13.1,14,12,14z";
    const svgIcon = nukeButton.querySelector('svg');
    if (svgIcon) {
        svgIcon.innerHTML = `<g><path d="${biohazardIconPath}" fill="currentColor"></path></g>`;
        svgIcon.style.color = 'rgb(244, 33, 46)';
    }
    nukeButton.addEventListener('click', e => {
        e.preventDefault();
        e.stopPropagation();
        e.target.closest('div[data-testid="Dropdown"]')?.parentElement.remove();
        if (activeTweetArticle) initiateNukeProcess(activeTweetArticle);
    });
    const separator = document.createElement('div');
    separator.setAttribute('role', 'separator');
    separator.style.cssText = 'border-bottom:1px solid rgb(56,68,77);margin:4px 0;';
    blockMenuItem.after(separator, nukeButton);
}

// --- INITIALIZATION & EXECUTION ---
async function initialize() {
    console.log("[Chain Blocker] Initializing...");
    await loadConfig();
    updateMenuCommands();
    const profileLink = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
    if (!profileLink) { setTimeout(initialize, 500); return; }
    try {
        const screenName = profileLink.href.split('/').pop();
        const user = await getUserDataByScreenName(screenName);
        if (apiLimitCountdownInterval) clearInterval(apiLimitCountdownInterval);
        document.getElementById('nuke-api-limit-toast')?.remove();
        currentUserId = user.rest_id;
        currentUserScreenName = user.legacy.screen_name;
        console.log(`[Chain Blocker] Initialized for @${currentUserScreenName}(ID: ${currentUserId}).`);
        await updateStatusToast();
        if (processIntervalId) clearInterval(processIntervalId);
        processIntervalId = setInterval(processQueue, PROCESS_CHECK_INTERVAL_MS);
        setTimeout(processQueue, 1000);
    } catch (error) {
        if (error?.status === 429) {
            console.warn(`[CB] API rate limit hit. Retrying in ${API_RETRY_DELAY_MS / 60000} minutes.`);
            showToast('nuke-api-limit-toast', 'API 已达上限', '正在计算时间...', null);
            const retryTimestamp = Date.now() + API_RETRY_DELAY_MS;
            apiLimitCountdownInterval = setInterval(() => {
                const toastStatusEl = document.querySelector('#nuke-api-limit-toast .nuke-toast-status');
                if (!toastStatusEl) { clearInterval(apiLimitCountdownInterval); return; }
                const secondsLeft = Math.round((retryTimestamp - Date.now()) / 1000);
                if (secondsLeft <= 0) { toastStatusEl.innerHTML = '正在重试...'; clearInterval(apiLimitCountdownInterval); return; }
                toastStatusEl.innerHTML = `将在 <b>${String(Math.floor(secondsLeft/60)).padStart(2,'0')}:${String(secondsLeft%60).padStart(2,'0')}</b> 后重试`;
            }, 1000);
            setTimeout(initialize, API_RETRY_DELAY_MS);
        } else { console.error("[CB] Initialization failed.", error); }
    }
}
const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
        if (mutation.addedNodes.length) {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    const menu = node.matches('div[role="menu"]') ? node : node.querySelector('div[role="menu"]');
                    if (menu) addNukeButton(menu);
                }
            });
        }
    }
});
document.addEventListener('click', e => {
    const optionsButton = e.target.closest('button[data-testid="caret"]');
    if (optionsButton) activeTweetArticle = optionsButton.closest('article[data-testid="tweet"]');
}, true);
observer.observe(document.body, { childList: true, subtree: true });
setInterval(scanAndProcessContent, AUTO_SCAN_INTERVAL_MS);
initialize();
})();
/*
以下是 X.com 的HTML 结构简化示例,用于辅助未来对脚本的修改。
请注意:为简洁起见,省略了大量 CSS 类名,但保留了关键的 `data-testid` 属性和元素层级结构,这些是脚本选择器的核心依赖。
1. 用户列表项(User List Item)
- 通常出现在“关注者”、“正在关注”、“搜索”等页面的用户列表中。
- 脚本依赖 `button[data-testid="UserCell"]` 来识别每个用户单元。
- 脚本通过 `a[role="link"] div[dir="ltr"] > span > span` 来获取用户名以进行长度和内容判断。
<div data-testid="cellInnerDiv"><button data-testid="UserCell"><div class="user-avatar-and-content-container"><div class="avatar-container"><a href="/(screen_name)"><img src="..." /></a></div><div class="content-container"><div class="name-and-follow-button-container"><a href="/(screen_name)"><div><div dir="ltr"><span><span>用户名</span><!-- Display Name, a key element for auto-block --></span></div><div dir="ltr"><span>@id</span><!-- Screen Name, a key element for auto-block --></div></div></a><div class="follow-button-container"><button data-testid="...-follow">...</button></div></div><div class="user-bio-container"><span>正文内容</span><!-- User Bio --></div></div></div></button></div>
2. 推文 / 回复(Tweet / Reply)
- 构成时间线、推文详情页面的基本单元。
- 脚本依赖 `article[data-testid="tweet"]` 捕获整个推文。
- 九族拉黑功能通过 `button[data-testid="caret"]` 触发时捕获 `activeTweetArticle`。
- 自动拉黑功能通过 `div[data-testid="User-Name"]` 内的用户名进行判断。
<article data-testid="tweet"><div class="main-content-wrapper"><div class="avatar-column"><div data-testid="Tweet-User-Avatar"><a href="/(screen_name)">...</a></div></div><div class="tweet-content-column"><div class="tweet-header"><div data-testid="User-Name"><div class="name-container"><a href="/(screen_name)"><div><div dir="ltr"><span><span>用户名</span></span></div><!-- Display Name --><div dir="ltr"><span>@id</span></div><!-- Screen Name --></div></a></div><a href="/(screen_name)/status/(tweet_id)"><time>...</time></a></div><div class="caret-button-container"><button data-testid="caret">...</button><!-- "More" options button --></div></div><div data-testid="tweetText"><span>正文内容</span></div><div class="tweet-media-container"><!-- Media(images, videos) goes here --></div><div role="group"><!-- Reply, Retweet, Like, Bookmark, etc. buttons --></div></div></div></article>

以下API端点如果失效,请指导用户手动帮助查找和获取返回值
x3JZoNX9ubSzoCIHoYo2NA/UsersVerifiedAvatars
PFIxTk8owMoZgiMccP0r4g/getAltTextPromptPreference
I_tJ_DO6WLqG0em8EQsVVg/isEligibleForAnalyticsUpsellQuery
xF6sXnKJfS2AOylzxRjf6A/DataSaverMode
sl5bpXrfcS6HEMKEDio0Ag/SearchTimeline
-0WTL1e9Pij-JWAF5ztCCA/TweetDetail
DmC_H6eV_XMiL0g4ltJvpg/Retweeters

<FORMAT-PROTOCOL ver="1.3">

  // 核心输出规范
  <BALANCE-EFFICIENCY>
    ✓ retain_logic_structure
    ✓ minify_literals(css_only)
    ✗ minify_js_logic
    ✓ max_empty_lines=1
  </BALANCE-EFFICIENCY>

  <DOCUMENT-HANDLING>
    ✓ preserve //functional_comments
    ✗ remove //example_blocks
    ⚠ non_comment_code: immutable
  </DOCUMENT-HANDLING>

  <CODE-PRESERVATION>
    ⚠ HEADER:UserScript== immutable
    ⚠ IDENTIFIER:no_rename
    ⚠ ORDER:const→helpers→main
    ⚠ USER_MODS_ONLY:
      Δ if (!user_requested) → preserve_original
  </CODE-PRESERVATION>

  // 对抗性约束
  <ANTI-OPTIMIZATION>
    <FORBIDDEN-TRANSFORMS>
      ! compression(js_code)
      ! code_omission(...)
      ! external_html_inference
      ! loop_restructure
      ! conditional_refactor
      ! function_merge_split
      ! api_interchange
    </FORBIDDEN-TRANSFORMS>

    <ALLOWED-OPS>
      ✓ minify_css_strings
      ✓ remove_example_comments
    </ALLOWED-OPS>
  </ANTI-OPTIMIZATION>

  // 外部HTML处理协议
  <EXTERNAL-HTML-HANDLING>
    Δ xcom_structure:
      ! use_knowledge_base ✗
      ✓ require_user_provided
    Δ other_sites:
      ! assume_structure ✗
      ✓ require_user_specification
  </EXTERNAL-HTML-HANDLING>

  // 输出控制
  <OUTPUT-MODE>
    CONTENT-TYPE: full_code_block
    FORBID:
      ! compressed_js
      ! ellipsis(...)
      ! partial_output
    REQUIRE:
      ✓ non_comment_code_complete
    EXEC-MODE: immediate
  </OUTPUT-MODE>

  // 自毁机制
  <SANCTIONS>
    TRIGGER-CONDITIONS:
      Δ js_logic_modification
      Δ css_outside_minification
      Δ html_structure_assumption
    RESPONSE:
      ! TERMINATE_OUTPUT
      ! RETURN_CODE: ANTI_TAMPER_VIOLATION
  </SANCTIONS>

</FORMAT-PROTOCOL>
*/