Greasy Fork is available in English.
当拉黑作者时,自动拉黑所有转推者和回复者。支持根据用户名关键词、粉丝数豁免、引流识别等规则自动拉黑,并提供黑/白名单管理面板。
// ==UserScript== // @name X.com Chain Blocker // @name:zh-CN X.com 九族拉黑 // @namespace http://tampermonkey.net/ // @version 2.14.86 // @description Block author, retweeters, repliers, and auto-block users based on rules (length, content, keywords, follower count). Manage block log, whitelist, and settings in a panel. // @description:zh-CN 当拉黑作者时,自动拉黑所有转推者和回复者。支持根据用户名关键词、粉丝数豁免、引流识别等规则自动拉黑,并提供黑/白名单管理面板。 // @author codex // @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 // @grant GM_addElement // @grant GM_getResourceURL // @grant unsafeWindow // @resource tesseractWorker https://cdn.jsdelivr.net/npm/[email protected]/dist/worker.min.js // @resource esearchOcr https://cdn.jsdelivr.net/npm/[email protected]/dist/esearch-ocr.js // @connect api.x.com // @connect x.com // @connect pbs.twimg.com // @connect abs.twimg.com // @connect cdn.jsdelivr.net // @connect tessdata.projectnaptha.com // @connect docs.opencv.org // @connect paddle-model-ecology.bj.bcebos.com // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/tesseract.min.js // ==/UserScript== (function () { 'use strict'; // --- CONFIG & CONSTANTS --- const MENU_ITEM_TEXT = "九族拉黑"; const NUKE_ICON_PATH = "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 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 DEFAULT_USERNAME_RULE_FOLLOWER_EXEMPT_THRESHOLD = 1000; const BLOCK_CONTEXT_TEXT_MAX = 120; const DEFAULT_SPAM_IDENTIFY_MIN_SCORE = 3; const AVATAR_OCR_CACHE_MS = 30 * 60 * 1000; const AVATAR_OCR_MAX_FAILS = 4; const AVATAR_OCR_STALE_PENDING_MS = 5 * 60 * 1000; const avatarOcrCache = new Map(); const avatarOcrQueue = []; let avatarOcrPumpRunning = false; let avatarOcrTesseractFailed = false; let avatarOcrPaddleFailed = false; let avatarOcrWorkerPromise = null; let paddleUserscriptInitPromise = null; let paddleUserscriptHandle = null; let avatarOcrInitSerial = Promise.resolve(); const SPAM_SCANNER_BUILD = '2.14.86'; const AUTO_BLOCK_NUKE_MODE_VERSION = 1; const TESSERACT_CHI_SIM_LANG_GZ = 'https://cdn.jsdelivr.net/npm/@tesseract.js-data/[email protected]/4.0.0_best_int/chi_sim.traineddata.gz'; const TESSERACT_LANG_CACHE_KEY = './chi_sim.traineddata'; let tesseractLangCachePromise = null; const AVATAR_OCR_RING_C = 2 * Math.PI * 8; let avatarOcrTesseractReady = false; let avatarOcrPaddleReady = false; let avatarOcrEngineUiToken = 0; const AVATAR_OCR_ENGINE_TESSERACT = 'tesseract'; const AVATAR_OCR_ENGINE_PADDLE = 'paddle'; const AVATAR_OCR_ENGINE_OFF = 'off'; const DEFAULT_AVATAR_OCR_ENGINE = AVATAR_OCR_ENGINE_TESSERACT; const TESSERACT_CDN = 'https://cdn.jsdelivr.net/npm/[email protected]/dist'; const TESSERACT_CORE_CDN = 'https://cdn.jsdelivr.net/npm/[email protected]'; const PADDLE_OCR_JS_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js'; const PADDLE_DET_TAR_URL = 'https://paddle-model-ecology.bj.bcebos.com/paddlex/official_inference_model/paddle3.0.0/PP-OCRv5_mobile_det_onnx.tar'; const PADDLE_REC_TAR_URL = 'https://paddle-model-ecology.bj.bcebos.com/paddlex/official_inference_model/paddle3.0.0/PP-OCRv5_mobile_rec_onnx.tar'; const ORT_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/ort.wasm.min.js'; const ORT_WASM_BASE = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/'; const OPENCV_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/opencv.js'; let userscriptCvLoadPromise = null; let ortWasmBlobPaths = null; function getPageWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function normalizeAvatarOcrEngine(value) { const engine = String(value || '').trim().toLowerCase(); if (engine === AVATAR_OCR_ENGINE_OFF) return AVATAR_OCR_ENGINE_OFF; if (engine === AVATAR_OCR_ENGINE_PADDLE) return AVATAR_OCR_ENGINE_PADDLE; return AVATAR_OCR_ENGINE_TESSERACT; } function getAvatarOcrEngine() { return normalizeAvatarOcrEngine(scriptConfig.spamAvatarOcrEngine); } function isAvatarOcrEnabled() { return scriptConfig.spamAvatarOcrEnabled !== false && getAvatarOcrEngine() !== AVATAR_OCR_ENGINE_OFF; } function isAvatarOcrEngineFailed() { const engine = getAvatarOcrEngine(); if (engine === AVATAR_OCR_ENGINE_OFF) return false; return engine === AVATAR_OCR_ENGINE_PADDLE ? avatarOcrPaddleFailed : avatarOcrTesseractFailed; } function shouldDeferBackgroundAvatarOcr() { if (!isAvatarOcrEnabled() || scriptConfig.spamIdentifyEnabled === false) return false; if (document.documentElement.dataset.cbSpamOcrUiState === 'loading') return true; const engine = getAvatarOcrEngine(); if (engine === AVATAR_OCR_ENGINE_PADDLE) { return avatarOcrPaddleFailed || (!avatarOcrPaddleReady && Boolean(paddleUserscriptInitPromise)); } if (avatarOcrTesseractFailed && !avatarOcrTesseractReady) return true; if (!avatarOcrTesseractReady && avatarOcrWorkerPromise) return true; return false; } function gmFetchText(url, timeoutMs = 180000) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: timeoutMs, onload: (response) => { if (response.status >= 200 && response.status < 300) resolve(response.responseText); else reject(new Error(`fetch ${response.status} ${url}`)); }, onerror: () => reject(new Error(`fetch network ${url}`)) }); }); } function gmFetchArrayBuffer(url, timeoutMs = 300000) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: timeoutMs, onload: (response) => { if (response.status >= 200 && response.status < 300 && response.response?.byteLength > 0) { resolve(response.response); } else { reject(new Error(`fetch ${response.status} ${url}`)); } }, onerror: () => reject(new Error(`fetch network ${url}`)) }); }); } async function gmFetchBlobUrl(url) { const buffer = await gmFetchArrayBuffer(url); return URL.createObjectURL(new Blob([buffer])); } let cachedOpencvScriptText = null; let tesseractBundledWorkerBlobUrl = null; let tesseractCoreWasmBlobUrl = null; function resetTesseractCoreBlobs() { if (tesseractBundledWorkerBlobUrl) { try { URL.revokeObjectURL(tesseractBundledWorkerBlobUrl); } catch { /* ignore */ } } if (tesseractCoreWasmBlobUrl) { try { URL.revokeObjectURL(tesseractCoreWasmBlobUrl); } catch { /* ignore */ } } tesseractBundledWorkerBlobUrl = null; tesseractCoreWasmBlobUrl = null; } function pickTesseractCoreVariant() { try { if (typeof WebAssembly === 'object') { const simdProbe = Uint8Array.of(0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11); if (WebAssembly.validate(simdProbe)) return 'tesseract-core-simd-lstm'; } } catch { /* ignore */ } return 'tesseract-core-lstm'; } async function gunzipToUint8Array(buffer) { if (typeof DecompressionStream === 'undefined') { throw new Error('DecompressionStream 不可用'); } const stream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream('gzip')); return new Uint8Array(await new Response(stream).arrayBuffer()); } function idbKeyvalSet(key, value) { return new Promise((resolve, reject) => { const req = indexedDB.open('keyval-store', 1); req.onerror = () => reject(req.error); req.onupgradeneeded = (event) => { event.target.result.createObjectStore('keyval'); }; req.onsuccess = () => { const db = req.result; const tx = db.transaction('keyval', 'readwrite'); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => reject(tx.error); tx.objectStore('keyval').put(value, key); }; }); } function idbKeyvalGet(key) { return new Promise((resolve, reject) => { const req = indexedDB.open('keyval-store', 1); req.onerror = () => reject(req.error); req.onupgradeneeded = (event) => { event.target.result.createObjectStore('keyval'); }; req.onsuccess = () => { const db = req.result; const tx = db.transaction('keyval', 'readonly'); const getReq = tx.objectStore('keyval').get(key); getReq.onsuccess = () => { db.close(); resolve(getReq.result); }; getReq.onerror = () => reject(getReq.error); }; }); } /** Worker fetch to tessdata/CDN is blocked on x.com; preload via GM_xhr into tesseract idb cache. */ async function ensureChiSimLangInTesseractCache(onProgress) { if (!tesseractLangCachePromise) { tesseractLangCachePromise = (async () => { const cached = await idbKeyvalGet(TESSERACT_LANG_CACHE_KEY); if (cached?.byteLength > 0) return; onProgress?.(42, '下载简体中文模型…'); const gz = await gmFetchArrayBuffer(TESSERACT_CHI_SIM_LANG_GZ); onProgress?.(48, '解压语言包…'); const trained = await gunzipToUint8Array(gz); await idbKeyvalSet(TESSERACT_LANG_CACHE_KEY, trained); })().catch((error) => { tesseractLangCachePromise = null; throw error; }); } return tesseractLangCachePromise; } /** * x.com CSP blocks importScripts(blob/CDN) inside workers (script-src). * Inline tesseract-core before worker.min.js so getCore() skips importScripts. */ async function ensureTesseractBundledWorkerBlobUrl() { if (tesseractBundledWorkerBlobUrl) return tesseractBundledWorkerBlobUrl; const variant = pickTesseractCoreVariant(); const [workerText, coreText, wasmBuffer] = await Promise.all([ gmFetchText(`${TESSERACT_CDN}/worker.min.js`), gmFetchText(`${TESSERACT_CORE_CDN}/${variant}.wasm.js`), gmFetchArrayBuffer(`${TESSERACT_CORE_CDN}/${variant}.wasm`) ]); tesseractCoreWasmBlobUrl = URL.createObjectURL(new Blob([wasmBuffer], { type: 'application/wasm' })); const preamble = `var Module=typeof Module!=="undefined"?Module:{};Module.locateFile=function(path){if(String(path).slice(-5)===".wasm")return ${JSON.stringify(tesseractCoreWasmBlobUrl)};return path;};`; const bundle = `${preamble}${coreText}\n${workerText}`; tesseractBundledWorkerBlobUrl = URL.createObjectURL(new Blob([bundle], { type: 'application/javascript' })); return tesseractBundledWorkerBlobUrl; } function formatAvatarOcrError(error, fallback = '模型加载失败') { if (error instanceof Error && error.message) return error.message; if (typeof error === 'string' && error.trim()) return error.trim(); if (error != null && typeof error !== 'undefined') { const text = String(error).trim(); if (text && text !== 'undefined') return text; } return fallback; } function getTesseractWorkerOptions(workerPath) { return { workerPath, corePath: `${TESSERACT_CORE_CDN}/`, workerBlobURL: false, gzip: true }; } async function ensureTesseractWorkerOptions(onProgress) { await ensureChiSimLangInTesseractCache(onProgress); const workerPath = await ensureTesseractBundledWorkerBlobUrl(); const opts = getTesseractWorkerOptions(workerPath); opts.cacheMethod = 'readOnly'; opts.logger = (m) => { try { if (typeof m?.progress === 'number' && onProgress) { const pct = Math.min(92, Math.round(18 + m.progress * 74)); onProgress(pct, m?.status ? String(m.status).slice(0, 48) : '正在加载模型…'); } } catch { /* ignore */ } }; return opts; } function resetAvatarOcrWorker() { avatarOcrWorkerPromise = null; resetTesseractCoreBlobs(); } function resetPaddleUserscriptState() { paddleUserscriptInitPromise = null; paddleUserscriptHandle = null; userscriptCvLoadPromise = null; const pageWin = getPageWindow(); delete pageWin.__cbPaddleBrowser; delete pageWin.__cbPaddleOcrMod; delete pageWin.__cbPaddleBrowserLoadError; delete pageWin.__cbPaddleInitConfig; delete pageWin.__cbOrtScriptInjected; delete pageWin.__cbOpencvScriptInjected; delete pageWin.__cbPaddleBootstrapInjected; try { const doc = pageWin.document; ['cb-paddle-ort', 'cb-paddle-opencv', 'cb-paddle-browser-bootstrap', 'cb-userscript-ort', 'cb-userscript-opencv'].forEach((id) => { doc.getElementById(id)?.remove(); }); } catch { /* ignore */ } } function resetAvatarOcrRuntime() { avatarOcrTesseractFailed = false; avatarOcrPaddleFailed = false; avatarOcrTesseractReady = false; avatarOcrPaddleReady = false; avatarOcrEngineUiToken += 1; resetAvatarOcrWorker(); resetPaddleUserscriptState(); } function prepareAvatarOcrEngineUiLoad(engine) { avatarOcrEngineUiToken += 1; const normalized = normalizeAvatarOcrEngine(engine); if (normalized === AVATAR_OCR_ENGINE_PADDLE) { avatarOcrPaddleFailed = false; paddleUserscriptInitPromise = null; paddleUserscriptHandle = null; userscriptCvLoadPromise = null; try { const pageWin = getPageWindow(); delete pageWin.__cbOpencvInjected; delete pageWin.__cbPaddleModInjected; delete pageWin.__cbPaddleMod; } catch { /* ignore */ } } else { avatarOcrTesseractFailed = false; resetAvatarOcrWorker(); } try { delete document.documentElement.dataset.cbSpamOcrLastError; } catch { /* ignore */ } setAvatarOcrEngineUiStatus('loading', 6, '正在加载模型…'); return avatarOcrEngineUiToken; } function waitForPageOpenCv(timeoutMs = 300000) { const pageWin = getPageWindow(); const started = Date.now(); return new Promise((resolve, reject) => { let pendingPromiseObserved = false; const finish = (cv) => { if (cv?.Mat) resolve(cv); else if (!pendingPromiseObserved && typeof pageWin.cv?.then === 'function') { pendingPromiseObserved = true; pageWin.cv.then((resolvedCv) => { if (resolvedCv?.Mat) resolve(resolvedCv); else finish(pageWin.cv); }).catch(reject); } else if (Date.now() - started > timeoutMs) reject(new Error('timeout waiting for cv')); else window.setTimeout(() => finish(pageWin.cv), 250); }; if (pageWin.cv?.Mat) { resolve(pageWin.cv); return; } if (typeof pageWin.cv?.then === 'function') { pendingPromiseObserved = true; pageWin.cv.then((resolvedCv) => { if (resolvedCv?.Mat) resolve(resolvedCv); else finish(pageWin.cv); }).catch(reject); return; } if (pageWin.cv && typeof pageWin.cv.onRuntimeInitialized === 'function') { const prev = pageWin.cv.onRuntimeInitialized; pageWin.cv.onRuntimeInitialized = () => { if (typeof prev === 'function') prev(); resolve(pageWin.cv); }; } window.setTimeout(() => finish(pageWin.cv), 250); }); } async function loadPaddleModule() { const pageWin = getPageWindow(); if (pageWin.__cbPaddleOcrMod) return pageWin.__cbPaddleOcrMod; if (!pageWin.__cbPaddleModInjected) { await new Promise(async (resolve, reject) => { const timer = window.setTimeout(() => { pageWin.removeEventListener('cb-paddle-mod-ready', onReady); reject(new Error('paddle module load timeout')); }, 120000); const onReady = () => { window.clearTimeout(timer); resolve(); }; pageWin.addEventListener('cb-paddle-mod-ready', onReady, { once: true }); try { const paddleScriptText = await gmFetchText(PADDLE_OCR_JS_URL, 120000); addPageScriptElement({ textContent: paddleScriptText }); const poll = () => { if (pageWin.paddleocr?.PaddleOcrService) { pageWin.__cbPaddleOcrMod = pageWin.paddleocr; pageWin.dispatchEvent(new CustomEvent('cb-paddle-mod-ready')); } else { window.setTimeout(poll, 50); } }; poll(); pageWin.__cbPaddleModInjected = true; } catch (error) { window.clearTimeout(timer); pageWin.removeEventListener('cb-paddle-mod-ready', onReady); reject(error); } }); } if (!pageWin.__cbPaddleOcrMod?.PaddleOcrService) throw new Error('paddleocr 未加载'); return pageWin.__cbPaddleOcrMod; } function addPageScriptElement(attributes) { if (typeof GM_addElement === 'function') { return GM_addElement(document.head || document.documentElement, 'script', attributes); } const script = document.createElement('script'); Object.entries(attributes || {}).forEach(([key, value]) => { if (key === 'textContent') script.textContent = value; else script.setAttribute(key, value); }); (document.head || document.documentElement).appendChild(script); return script; } function readTarString(bytes, start, length) { let output = ''; for (let index = start; index < start + length; index += 1) { const value = bytes[index]; if (value === 0) break; output += String.fromCharCode(value); } return output.replace(/\0.*$/, '').trim(); } function readTarOctal(bytes, start, length) { const raw = readTarString(bytes, start, length).replace(/\0/g, '').trim(); return raw ? parseInt(raw, 8) : 0; } function extractTarEntryBytes(buffer, targetName) { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); let offset = 0; while (offset + 512 <= bytes.length) { let empty = true; for (let i = offset; i < offset + 512; i += 1) { if (bytes[i] !== 0) { empty = false; break; } } if (empty) break; const name = readTarString(bytes, offset, 100).replace(/^\.?\//, ''); const size = readTarOctal(bytes, offset + 124, 12); const dataStart = offset + 512; const dataEnd = dataStart + size; if (name === targetName || name.endsWith(`/${targetName}`)) return bytes.slice(dataStart, dataEnd); offset = dataStart + Math.ceil(size / 512) * 512; } throw new Error(`tar entry not found: ${targetName}`); } function bytesToArrayBuffer(bytes) { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } function parsePaddleCharacterDictionary(ymlText) { const chars = ['']; let inDict = false; String(ymlText || '').split(/\r\n|\r|\n/).forEach((line) => { if (/^\s*character_dict:\s*$/.test(line)) { inDict = true; return; } if (!inDict) return; const match = line.match(/^\s*-\s?(.*)$/); if (match) chars.push(match[1]); else if (/^\S/.test(line)) inDict = false; }); return chars; } async function blobToImageData(blob) { const bitmap = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0); if (typeof bitmap.close === 'function') bitmap.close(); return ctx.getImageData(0, 0, canvas.width, canvas.height); } async function loadOrtModule() { const pageWin = getPageWindow(); if (pageWin.ort?.InferenceSession) return; if (!pageWin.__cbOrtScriptInjected) { await new Promise(async (resolve, reject) => { const timer = window.setTimeout(() => { pageWin.removeEventListener('cb-ort-ready', onReady); reject(new Error('onnxruntime-web load timeout')); }, 120000); const onReady = () => { window.clearTimeout(timer); resolve(); }; pageWin.addEventListener('cb-ort-ready', onReady, { once: true }); try { const ortScriptText = await gmFetchText(ORT_SCRIPT_URL, 120000); addPageScriptElement({ textContent: ortScriptText }); const poll = () => { if (pageWin.ort?.InferenceSession) { pageWin.dispatchEvent(new CustomEvent('cb-ort-ready')); } else { window.setTimeout(poll, 50); } }; poll(); pageWin.__cbOrtScriptInjected = true; } catch (error) { window.clearTimeout(timer); pageWin.removeEventListener('cb-ort-ready', onReady); reject(error); } }); } } async function ensureOrtWasmBlobPaths() { if (ortWasmBlobPaths) return ortWasmBlobPaths; const files = [ 'ort-wasm-simd.wasm', 'ort-wasm.wasm', 'ort-wasm-simd-threaded.wasm' ]; const entries = await Promise.all(files.map(async (file) => { const buffer = await gmFetchArrayBuffer(`${ORT_WASM_BASE}${file}`, 120000); const blobUrl = URL.createObjectURL(new Blob([buffer], { type: 'application/wasm' })); return [file, blobUrl]; })); ortWasmBlobPaths = Object.fromEntries(entries); return ortWasmBlobPaths; } async function ensureSandboxCv() { const pageWin = getPageWindow(); if (pageWin.cv?.Mat) return pageWin.cv; if (!userscriptCvLoadPromise) { userscriptCvLoadPromise = (async () => { if (!pageWin.__cbOpencvInjected) { await new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error('opencv script load timeout')), 120000); const script = addPageScriptElement({ src: OPENCV_SCRIPT_URL }); script.addEventListener('load', () => { window.clearTimeout(timer); pageWin.__cbOpencvInjected = true; resolve(); }, { once: true }); script.addEventListener('error', () => { window.clearTimeout(timer); reject(new Error('opencv script load failed')); }, { once: true }); }); } return waitForPageOpenCv(); })().catch((error) => { userscriptCvLoadPromise = null; throw error; }); } return userscriptCvLoadPromise; } async function ensureUserscriptOrt() { await loadOrtModule(); const pageWin = getPageWindow(); const ortRef = pageWin.ort; if (!ortRef?.InferenceSession) { throw new Error('onnxruntime-web 未加载(请在暴力猴中更新并启用本脚本)'); } try { if (ortRef.env?.wasm) { ortRef.env.wasm.wasmPaths = await ensureOrtWasmBlobPaths(); ortRef.env.wasm.numThreads = 1; ortRef.env.wasm.proxy = false; } } catch { /* ignore */ } try { getPageWindow().ort = ortRef; } catch { /* ignore */ } return ortRef; } function getAvatarOcrEngineStatusEl() { return document.querySelector('#nuke-spam-avatar-ocr-engine-status'); } function renderAvatarOcrEngineRingSvg(progressPct) { const p = Math.max(0, Math.min(100, progressPct)); const offset = AVATAR_OCR_RING_C * (1 - p / 100); return `<svg class="nuke-ocr-engine-ring" viewBox="0 0 20 20" aria-hidden="true"><circle class="nuke-ocr-engine-ring-track" cx="10" cy="10" r="8" fill="none" stroke-width="2"/><circle class="nuke-ocr-engine-ring-progress" cx="10" cy="10" r="8" fill="none" stroke-width="2" stroke-dasharray="${AVATAR_OCR_RING_C.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}" stroke-linecap="round" transform="rotate(-90 10 10)"/></svg>`; } function renderAvatarOcrEngineDoneSvg() { return '<svg viewBox="0 0 20 20" aria-hidden="true"><circle class="nuke-ocr-engine-done-fill" cx="10" cy="10" r="9"/><path class="nuke-ocr-engine-done-check" d="M6 10.5 8.5 13 14 7"/></svg>'; } function renderAvatarOcrEngineErrorSvg() { return '<svg viewBox="0 0 20 20" aria-hidden="true"><circle class="nuke-ocr-engine-error-fill" cx="10" cy="10" r="9"/><path class="nuke-ocr-engine-error-mark" d="M7 7l6 6M13 7l-6 6"/></svg>'; } function setAvatarOcrEngineUiStatus(state, progressPct = 0, title = '') { try { document.documentElement.dataset.cbSpamOcrUiState = state; document.documentElement.dataset.cbSpamOcrUiProgress = String(Math.round(progressPct)); if (state === 'loading') delete document.documentElement.dataset.cbSpamOcrLastError; } catch { /* ignore */ } const el = getAvatarOcrEngineStatusEl(); if (!el) return; el.className = `nuke-ocr-engine-status nuke-ocr-engine-status--${state}`; el.title = title || ''; if (state === 'loading') { el.innerHTML = renderAvatarOcrEngineRingSvg(progressPct); el.setAttribute('aria-label', title || `模型加载中 ${Math.round(progressPct)}%`); } else if (state === 'ready') { el.innerHTML = renderAvatarOcrEngineDoneSvg(); el.setAttribute('aria-label', title || '模型已就绪'); } else if (state === 'error') { const errText = String(title || document.documentElement.dataset.cbSpamOcrLastError || '模型加载失败').slice(0, 200); el.innerHTML = renderAvatarOcrEngineErrorSvg(); el.title = errText; el.dataset.errorHint = errText; el.setAttribute('aria-label', errText); try { document.documentElement.dataset.cbSpamOcrLastError = errText; } catch { /* ignore */ } } else { el.innerHTML = ''; el.removeAttribute('aria-label'); } } function isAvatarOcrEngineReady(engine) { const normalized = normalizeAvatarOcrEngine(engine); if (normalized === AVATAR_OCR_ENGINE_PADDLE) { return avatarOcrPaddleReady || Boolean(getPageWindow().__cbPaddleBrowser?.ready); } return avatarOcrTesseractReady && !avatarOcrTesseractFailed; } function syncAvatarOcrEngineStatusForSelect(selectEl) { if (!selectEl) return; const engine = normalizeAvatarOcrEngine(selectEl.value); if (engine === AVATAR_OCR_ENGINE_OFF) { setAvatarOcrEngineUiStatus('idle'); return; } if (isAvatarOcrEngineReady(engine)) { setAvatarOcrEngineUiStatus('ready', 100, engine === AVATAR_OCR_ENGINE_PADDLE ? 'PaddleOCR 已就绪' : 'Tesseract 已就绪'); return; } if ((engine === AVATAR_OCR_ENGINE_PADDLE && paddleUserscriptInitPromise) || (engine !== AVATAR_OCR_ENGINE_PADDLE && avatarOcrWorkerPromise)) { setAvatarOcrEngineUiStatus('loading', 8, '正在加载模型…'); return; } setAvatarOcrEngineUiStatus('idle'); } async function loadTesseractForUi(onProgress) { const report = (pct, label) => { try { onProgress?.(pct, label); } catch { /* ignore */ } }; report(15, '加载 Tesseract worker…'); report(55, '加载简体中文模型…'); const worker = await getAvatarOcrWorker(); avatarOcrTesseractReady = true; report(100, 'Tesseract 已就绪'); return worker; } let paddleUiProgressTimer = null; function stopPaddleUiProgressPulse() { if (paddleUiProgressTimer) { clearInterval(paddleUiProgressTimer); paddleUiProgressTimer = null; } } function startPaddleUiProgressPulse(onProgress, fromPct = 52) { stopPaddleUiProgressPulse(); let pct = fromPct; paddleUiProgressTimer = setInterval(() => { pct = Math.min(88, pct + 2); try { onProgress?.(pct, '下载识别模型…'); } catch { /* ignore */ } }, 2500); } async function preloadAvatarOcrEngineForUi(engine) { const normalized = normalizeAvatarOcrEngine(engine); if (normalized === AVATAR_OCR_ENGINE_OFF) { setAvatarOcrEngineUiStatus('idle'); return true; } const token = prepareAvatarOcrEngineUiLoad(engine); if (isAvatarOcrEngineReady(normalized)) { setAvatarOcrEngineUiStatus('ready', 100); return true; } const report = (pct, label) => { if (token !== avatarOcrEngineUiToken) return; setAvatarOcrEngineUiStatus('loading', pct, label); }; report(4, '正在加载模型…'); try { if (normalized === AVATAR_OCR_ENGINE_PADDLE) { await ensurePaddleUserscriptReady(report); avatarOcrPaddleReady = true; } else { await loadTesseractForUi(report); } if (token !== avatarOcrEngineUiToken) return true; stopPaddleUiProgressPulse(); try { document.documentElement.dataset.cbSpamOcrReady = '1'; } catch { /* ignore */ } setAvatarOcrEngineUiStatus('ready', 100, '模型已就绪'); return true; } catch (error) { stopPaddleUiProgressPulse(); if (token !== avatarOcrEngineUiToken) return false; if (normalized === AVATAR_OCR_ENGINE_PADDLE) avatarOcrPaddleFailed = true; else avatarOcrTesseractFailed = true; const raw = formatAvatarOcrError(error); const hint = /opencv|timeout waiting for cv/i.test(raw) ? 'PaddleOCR 需 OpenCV,x.com 上请先用 Tesseract.js' : raw; setAvatarOcrEngineUiStatus('error', 0, hint); return false; } } function resetSpamScanMarkersForBuildUpgrade() { if (window.__cbSpamScannerBuild === SPAM_SCANNER_BUILD) return; window.__cbSpamScannerBuild = SPAM_SCANNER_BUILD; avatarOcrCache.clear(); resetAvatarOcrRuntime(); document.querySelectorAll('article[data-testid="tweet"]').forEach((article) => { delete article.dataset.spamScanned; delete article.dataset.avatarOcrQueued; delete article.dataset.avatarOcrPending; delete article.dataset.avatarOcrQueuedAt; delete article.dataset.avatarOcrFailCount; delete article.dataset.spamTextScannedBuild; article.classList.remove('nuke-spam-identified'); article.querySelector('.nuke-spam-badge')?.remove(); }); } function markStatusRootTweetArticles() { if (!/\/status\/\d+/i.test(window.location.pathname)) return; const column = document.querySelector('[data-testid="primaryColumn"]'); const first = column?.querySelector('article[data-testid="tweet"]'); document.querySelectorAll('[data-testid="primaryColumn"] article[data-testid="tweet"]').forEach((article) => { delete article.dataset.cbSpamRootTweet; }); if (first) first.dataset.cbSpamRootTweet = 'true'; } function isStatusRootTweetArticle(article) { return article?.dataset?.cbSpamRootTweet === 'true'; } function shouldSkipAvatarOcrForArticle(article) { return isStatusRootTweetArticle(article); } function shouldSkipSpamIdentifyForArticle(article) { return isStatusRootTweetArticle(article); } function extractTwitterProfileImageId(url) { const match = String(url || '').match(/profile_images\/(\d+)\//); return match ? match[1] : ''; } const FOLLOWER_COUNT_CACHE_MS = 10 * 60 * 1000; const FOLLOWER_COUNT_LOOKUP_TIMEOUT_MS = 4500; 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 manualDetectedNukeRunning = false; let scriptConfig = {}, isConfigPanelBusy = false, internalConfigTriggerInstalled = false; const aggregatedToastState = new Map(); const followerCountCache = new Map(); const followerFetchPending = new Map(); // --- 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,.nuke-verify-modal{position:fixed;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;padding:0;margin:0}.nuke-verify-modal{top:50%;left:50%;transform:translate(-50%,-50%)}.nuke-config-panel{max-height:calc(100vh - 16px);overflow-y:auto;transform:none;top:0;left:0}.nuke-config-panel.nuke-dialog-dragging{user-select:none;will-change:left,top}.nuke-panel-header.nuke-dialog-drag-handle{cursor:grab;touch-action:none}.nuke-panel-header.nuke-dialog-drag-handle:active{cursor:grabbing}.nuke-config-panel::backdrop,.nuke-verify-modal::backdrop{background:rgba(91,112,131,0.45)}.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,.nuke-verify-textarea,.nuke-list-search,.nuke-setting-item input[type=number]{user-select:text;-webkit-user-select:text;pointer-events:auto}.nuke-config-textarea,.nuke-verify-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:80px}.nuke-keywords-textarea{height:60px}.nuke-verify-textarea{height:110px;line-height:1.5}.nuke-config-button-container{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}.nuke-config-button.save,.nuke-config-button.copy{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,.nuke-config-button.copy: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-block-reason{display:block;font-size:12px;color:#8899a6;margin-top:4px;line-height:1.4;word-break:break-word;white-space:normal}.nuke-list-actions{font-size:12px;color:#8899a6;white-space:nowrap;cursor:pointer;flex-shrink:0;margin-left:8px}.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 select{max-width:240px;background-color:#253341;border:1px solid #38444d;border-radius:8px;color:white;padding:5px 8px;font-size:14px}.nuke-ocr-engine-item{align-items:center}.nuke-ocr-engine-controls{display:flex;align-items:center;gap:8px;flex-shrink:0}.nuke-ocr-engine-status{width:20px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center}.nuke-ocr-engine-status--idle{visibility:hidden;pointer-events:none}.nuke-ocr-engine-status svg{width:20px;height:20px;display:block}.nuke-ocr-engine-status--loading .nuke-ocr-engine-ring-track{stroke:#38444d}.nuke-ocr-engine-status--loading .nuke-ocr-engine-ring-progress{stroke:#1d9bf0;transition:stroke-dashoffset .25s ease}.nuke-ocr-engine-status--done .nuke-ocr-engine-done-fill{fill:#00ba7c}.nuke-ocr-engine-status--done .nuke-ocr-engine-done-check{fill:none;stroke:#fff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.nuke-ocr-engine-status--error .nuke-ocr-engine-error-fill{fill:#f4212e}.nuke-ocr-engine-status--error .nuke-ocr-engine-error-mark{fill:none;stroke:#fff;stroke-width:2;stroke-linecap:round}.nuke-ocr-engine-status--error{cursor:help}.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}.nuke-verify-note{font-size:14px;color:#8899a6;line-height:1.5;margin-bottom:10px}article[data-testid="tweet"].nuke-spam-identified{box-shadow:inset 0 0 0 1px rgba(255,173,31,.55);border-radius:12px}.nuke-spam-badge{display:inline-flex;align-items:center;margin:4px 12px 0;padding:2px 8px;font-size:12px;font-weight:700;color:#ffad1f;background:rgba(255,173,31,.12);border:1px solid rgba(255,173,31,.35);border-radius:9999px;cursor:help}`); GM_addStyle(`.nuke-ocr-engine-status--ready .nuke-ocr-engine-done-fill{fill:#00ba7c}.nuke-ocr-engine-status--ready .nuke-ocr-engine-done-check{fill:none;stroke:#fff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}`); GM_addStyle(`.nuke-settings-module{border-top:1px solid #253341;padding-top:14px;margin-top:14px}.nuke-settings-module:first-child{border-top:0;padding-top:0;margin-top:0}.nuke-settings-module-title{font-size:13px;font-weight:700;color:#eff3f4;margin:0 0 10px}`); GM_addStyle(`.nuke-aggregated-toast-summary{font-weight:700;margin-bottom:4px}.nuke-aggregated-toast-line{color:#d7dbdc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:320px}`); GM_addStyle(`#nuke-manual-detected-nuke-button{position:fixed;right:20px;bottom:146px;z-index:100002;width:55px;height:55px;border-radius:16px;border:1px solid rgb(75,78,82);background:rgba(0,0,0,.65);color:#fff;display:flex;align-items:center;justify-content:center;padding:0;box-sizing:border-box;box-shadow:rgba(255,255,255,.2) 0 0 15px 0,rgba(255,255,255,.15) 0 0 3px 1px;cursor:pointer;transition:background-color .2s,border-color .2s,opacity .2s}#nuke-manual-detected-nuke-button:hover:not(:disabled){background:rgba(29,155,240,.82);border-color:rgb(29,155,240);color:#fff}#nuke-manual-detected-nuke-button:disabled{opacity:.45;cursor:default}#nuke-manual-detected-nuke-button svg{width:32px;height:32px;display:block}.nuke-manual-detected-count{position:absolute;right:-5px;top:-6px;min-width:18px;height:18px;padding:0 4px;border-radius:9999px;background:#f4212e;color:#fff;border:2px solid #000;font:700 11px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;text-align:center}`); // --- CONFIGURATION MANAGEMENT --- async function loadConfig() { const defaultConfig = { autoBlockEnabled: false, autoBlockNukeModeVersion: AUTO_BLOCK_NUKE_MODE_VERSION, blockLogLimit: 500, usernameRuleFollowerExemptThreshold: DEFAULT_USERNAME_RULE_FOLLOWER_EXEMPT_THRESHOLD, blueVerifiedExemptEnabled: true, blockKeywordsStandard: [], // For any name spamIdentifyEnabled: true, spamIdentifyMinScore: DEFAULT_SPAM_IDENTIFY_MIN_SCORE, spamAvatarOcrEngine: DEFAULT_AVATAR_OCR_ENGINE, spamAvatarKeywords: ['全国安排', '点击主页'], spamAutoExpandHidden: true }; const savedConfig = await GM_getValue(CONFIG_STORAGE_KEY, {}); const migrated = { ...savedConfig }; if (migrated.promoTargetAutoNukeEnabled === true && migrated.autoBlockEnabled === false) { migrated.autoBlockEnabled = true; } if (migrated.autoBlockNukeModeVersion !== AUTO_BLOCK_NUKE_MODE_VERSION) { migrated.autoBlockEnabled = false; migrated.autoBlockNukeModeVersion = AUTO_BLOCK_NUKE_MODE_VERSION; } if (migrated.usernameRuleFollowerExemptThreshold == null && migrated.longNameFollowerExemptThreshold != null) { migrated.usernameRuleFollowerExemptThreshold = migrated.longNameFollowerExemptThreshold; } if (migrated.spamAvatarOcrEnabled === false) { migrated.spamAvatarOcrEngine = AVATAR_OCR_ENGINE_OFF; } delete migrated.effectiveUrls; delete migrated.autoBlockUrls; delete migrated.spamIdentifyUrls; delete migrated.longNameFollowerExemptThreshold; delete migrated.spamAvatarOcrEnabled; delete migrated.promoTargetAutoNukeEnabled; delete migrated.promoTargetLearnOnNuke; delete migrated.promoTargetAutoNukeUrls; scriptConfig = { ...defaultConfig, ...migrated }; return scriptConfig; } async function saveConfig(config) { await GM_setValue(CONFIG_STORAGE_KEY, config); scriptConfig = config; } function updateMenuCommands() { GM_registerMenuCommand('配置与记录', showConfigPanel); } function shouldShowDebugConfigTrigger() { const href = String(window.location.href || ''); const hash = String(window.location.hash || ''); return /(?:[?&#])cb_spam_debug=1(?:[&#]|$)/.test(href) || /(?:^|[#&])cb-spam-debug(?:=1)?(?:[&#]|$)/.test(hash); } function onInternalConfigShortcut(event) { if (!shouldShowDebugConfigTrigger()) return; if (event.code !== 'F8') return; if (!event.altKey || !event.shiftKey) return; if (event.repeat) return; event.preventDefault(); event.stopPropagation(); void showConfigPanel(); } function installInternalConfigTrigger() { if (internalConfigTriggerInstalled) return; internalConfigTriggerInstalled = true; document.addEventListener('cb-spam-probe', onCbSpamProbeRequest); window.addEventListener('keydown', onInternalConfigShortcut, true); } function handleUserscriptBuildRerun() { try { const pageWin = getPageWindow(); const previousBuild = pageWin.__cbSpamScannerBuild; document.documentElement.dataset.cbSpamScannerBuild = SPAM_SCANNER_BUILD; if (previousBuild && previousBuild !== SPAM_SCANNER_BUILD && pageWin.__cbSpamReloadingForBuild !== SPAM_SCANNER_BUILD) { pageWin.__cbSpamReloadingForBuild = SPAM_SCANNER_BUILD; window.setTimeout(() => { window.location.reload(); }, 50); return false; } pageWin.__cbSpamScannerBuild = SPAM_SCANNER_BUILD; } catch { /* ignore */ } return true; } function closeDialogSurface(surface) { if (!surface) return; if (typeof surface.close === 'function' && surface.open) surface.close(); surface.remove(); } const DIALOG_VIEWPORT_MARGIN = 8; function clampDialogPosition(panel, left, top, margin = DIALOG_VIEWPORT_MARGIN) { const width = panel.offsetWidth; const height = panel.offsetHeight; const maxLeft = Math.max(margin, window.innerWidth - width - margin); const maxTop = Math.max(margin, window.innerHeight - height - margin); return { left: Math.min(Math.max(margin, left), maxLeft), top: Math.min(Math.max(margin, top), maxTop) }; } function placeDialogInViewport(panel, options = {}) { const margin = options.margin ?? DIALOG_VIEWPORT_MARGIN; panel.style.transform = 'none'; panel.style.margin = '0'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; let left = options.left; let top = options.top; if (options.center) { left = (window.innerWidth - panel.offsetWidth) / 2; top = (window.innerHeight - panel.offsetHeight) / 2; } const next = clampDialogPosition(panel, left ?? margin, top ?? margin, margin); panel.style.left = `${next.left}px`; panel.style.top = `${next.top}px`; panel.dataset.nukePinnedPosition = 'true'; return next; } function initializeDialogSurface(surface, options = {}) { if (!surface) return; const { initialFocusSelector = '', selectInitialText = false, show = true } = options; const stopEvent = (event) => event.stopPropagation(); ['pointerdown', 'mousedown', 'mouseup', 'click', 'dblclick'].forEach((type) => { surface.addEventListener(type, stopEvent); }); surface.addEventListener('cancel', (event) => { event.preventDefault(); closeDialogSurface(surface); }); surface.addEventListener('click', (event) => { if (event.target === surface) closeDialogSurface(surface); }); surface.addEventListener('pointerdown', (event) => { const target = event.target; if (target && typeof target.matches === 'function' && target.matches('input, textarea')) { window.setTimeout(() => { if (typeof target.focus === 'function') target.focus({ preventScroll: true }); if (selectInitialText && typeof target.select === 'function') target.select(); }, 0); } }, true); document.body.appendChild(surface); if (show && typeof surface.showModal === 'function' && !surface.open) surface.showModal(); if (!show) return; const initialField = initialFocusSelector ? surface.querySelector(initialFocusSelector) : null; window.setTimeout(() => { const target = initialField || surface; if (target && typeof target.focus === 'function') target.focus({ preventScroll: true }); if (selectInitialText && initialField && typeof initialField.select === 'function') initialField.select(); }, 0); } function focusDialogSurface(surface, initialFocusSelector = '') { const initialField = initialFocusSelector ? surface.querySelector(initialFocusSelector) : null; window.setTimeout(() => { const target = initialField || surface; if (target && typeof target.focus === 'function') target.focus({ preventScroll: true }); }, 0); } function enableDraggableDialog(panel, options = {}) { if (!panel) return; const handle = panel.querySelector(options.handleSelector || '.nuke-panel-header'); if (!handle) return; handle.classList.add('nuke-dialog-drag-handle'); const margin = options.margin ?? DIALOG_VIEWPORT_MARGIN; let dragState = null; const applyPosition = (left, top) => { const next = clampDialogPosition(panel, left, top, margin); panel.style.left = `${next.left}px`; panel.style.top = `${next.top}px`; }; const onWindowPointerMove = (event) => { if (!dragState || event.pointerId !== dragState.pointerId) return; event.preventDefault(); applyPosition(event.clientX - dragState.offsetX, event.clientY - dragState.offsetY); }; const endDrag = (event) => { if (!dragState || event.pointerId !== dragState.pointerId) return; dragState = null; panel.classList.remove('nuke-dialog-dragging'); window.removeEventListener('pointermove', onWindowPointerMove, true); window.removeEventListener('pointerup', endDrag, true); window.removeEventListener('pointercancel', endDrag, true); }; handle.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; if (event.target.closest('.nuke-close-button, button, a, input, textarea, select, label')) return; event.preventDefault(); const rect = panel.getBoundingClientRect(); dragState = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top }; panel.classList.add('nuke-dialog-dragging'); window.addEventListener('pointermove', onWindowPointerMove, { capture: true, passive: false }); window.addEventListener('pointerup', endDrag, { capture: true }); window.addEventListener('pointercancel', endDrag, { capture: true }); }); const onResize = () => { if (panel.dataset.nukePinnedPosition !== 'true') return; applyPosition(parseFloat(panel.style.left) || margin, parseFloat(panel.style.top) || margin); }; const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => onResize()) : null; resizeObserver?.observe(panel); window.addEventListener('resize', onResize); const cleanupDragListeners = () => { dragState = null; panel.classList.remove('nuke-dialog-dragging'); window.removeEventListener('pointermove', onWindowPointerMove, true); window.removeEventListener('pointerup', endDrag, true); window.removeEventListener('pointercancel', endDrag, true); }; panel.addEventListener('remove', () => { cleanupDragListeners(); resizeObserver?.disconnect(); window.removeEventListener('resize', onResize); }, { once: true }); } async function showConfigPanel() { if (isConfigPanelBusy) return; isConfigPanelBusy = true; try { closeDialogSurface(document.getElementById('nuke-url-config-panel')); let config = await loadConfig(); const panel = document.createElement('dialog'); 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> <button class="nuke-config-tab" data-tab="promo">🎯 引流目标</button> </div> <div id="nuke-settings-content" class="nuke-config-tab-content"> <section class="nuke-settings-module"> <h3 class="nuke-settings-module-title">执行模式</h3> <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> <div class="nuke-setting-item"> <label for="nuke-spam-auto-expand-toggle">推文页自动展开「可能的垃圾回复」</label> <input type="checkbox" id="nuke-spam-auto-expand-toggle"> </div> <div class="nuke-setting-item"> <label for="nuke-username-rule-follower-input">粉丝数豁免</label> <input type="number" id="nuke-username-rule-follower-input" min="0" step="1"> </div> <div class="nuke-setting-item"> <label for="nuke-blue-verified-exempt-toggle">蓝 V 用户自动豁免</label> <input type="checkbox" id="nuke-blue-verified-exempt-toggle"> </div> </section> <section class="nuke-settings-module"> <h3 class="nuke-settings-module-title">用户名规则</h3> <label class="nuke-settings-label" for="nuke-keywords-standard-textarea">常规用户名关键词 (每行一条; 支持纯文本或正则)</label> <textarea id="nuke-keywords-standard-textarea" class="nuke-config-textarea nuke-keywords-textarea" placeholder="例如: 点击主页 💚(少妇|姐姐|妈妈)💚"></textarea> </section> <section class="nuke-settings-module"> <h3 class="nuke-settings-module-title">引流识别</h3> <div class="nuke-setting-item"> <label for="nuke-spam-identify-toggle">引流识别(命中后标记;自动九族开启时拉黑)</label> <input type="checkbox" id="nuke-spam-identify-toggle"> </div> <div class="nuke-setting-item"> <label for="nuke-spam-identify-score-input">推文引流识别最低得分</label> <input type="number" id="nuke-spam-identify-score-input" min="1" max="10" step="1"> </div> </section> <section class="nuke-settings-module"> <h3 class="nuke-settings-module-title">头像 OCR</h3> <div class="nuke-setting-item nuke-ocr-engine-item"> <label for="nuke-spam-avatar-ocr-engine">头像 OCR 引擎</label> <div class="nuke-ocr-engine-controls"> <span id="nuke-spam-avatar-ocr-engine-status" class="nuke-ocr-engine-status nuke-ocr-engine-status--idle" role="status" aria-live="polite" title=""></span> <select id="nuke-spam-avatar-ocr-engine"> <option value="off">关闭头像 OCR</option> <option value="tesseract">Tesseract.js(默认,较轻)</option> <option value="paddle">PaddleOCR(paddleocr,较准)</option> </select> </div> </div> <label class="nuke-settings-label" for="nuke-spam-avatar-keywords-textarea">头像 OCR 关键词 (每行一条; 留空则用用户名关键词; 另自动识别头像内「全国安排」)</label> <textarea id="nuke-spam-avatar-keywords-textarea" class="nuke-config-textarea nuke-keywords-textarea" placeholder="全国安排 点击主页"></textarea> </section> <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 id="nuke-promo-content" class="nuke-config-tab-content hidden"> <p class="nuke-verify-note">开启「自动九族拉黑」后,推文 @ 列表中账号会触发九族拉黑;关闭时只标记命中项。手动九族拉黑时,推文里的 @ 会始终收录进此列表并立刻拉黑。</p> <label class="nuke-settings-label" for="nuke-promo-targets-textarea">手动维护 @ (每行一个, 可带@):</label> <textarea id="nuke-promo-targets-textarea" class="nuke-config-textarea nuke-url-textarea" placeholder="ChristineViu yeyebbz"></textarea> <input type="search" class="nuke-list-search" id="nuke-promo-search" placeholder="搜索引流目标 @handle..."> <div class="nuke-list"></div> </div> </div>`; panel.tabIndex = -1; initializeDialogSurface(panel, { show: false }); enableDraggableDialog(panel); panel.querySelector('#nuke-auto-block-toggle').checked = config.autoBlockEnabled; panel.querySelector('#nuke-log-limit-input').value = config.blockLogLimit; panel.querySelector('#nuke-username-rule-follower-input').value = config.usernameRuleFollowerExemptThreshold ?? DEFAULT_USERNAME_RULE_FOLLOWER_EXEMPT_THRESHOLD; panel.querySelector('#nuke-blue-verified-exempt-toggle').checked = config.blueVerifiedExemptEnabled !== false; panel.querySelector('#nuke-keywords-standard-textarea').value = (config.blockKeywordsStandard || []).join('\n'); panel.querySelector('#nuke-spam-identify-toggle').checked = config.spamIdentifyEnabled !== false; const engineSelect = panel.querySelector('#nuke-spam-avatar-ocr-engine'); engineSelect.value = normalizeAvatarOcrEngine(config.spamAvatarOcrEngine); engineSelect.addEventListener('change', () => { const selectedEngine = normalizeAvatarOcrEngine(engineSelect.value); if (selectedEngine === AVATAR_OCR_ENGINE_OFF) setAvatarOcrEngineUiStatus('idle'); else void preloadAvatarOcrEngineForUi(selectedEngine); }); if (isAvatarOcrEnabled()) { syncAvatarOcrEngineStatusForSelect(engineSelect); } else { setAvatarOcrEngineUiStatus('idle'); } panel.querySelector('#nuke-spam-auto-expand-toggle').checked = config.spamAutoExpandHidden !== false; panel.querySelector('#nuke-spam-avatar-keywords-textarea').value = (config.spamAvatarKeywords || []).join('\n'); panel.querySelector('#nuke-spam-identify-score-input').value = config.spamIdentifyMinScore ?? DEFAULT_SPAM_IDENTIFY_MIN_SCORE; const promoData = await loadUserData(); panel.querySelector('#nuke-promo-targets-textarea').value = (promoData?.promoTargets || []).map((e) => e.screenName).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', () => closeDialogSurface(panel)); 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.usernameRuleFollowerExemptThreshold = Math.max(0, parseInt(panel.querySelector('#nuke-username-rule-follower-input').value, 10) || DEFAULT_USERNAME_RULE_FOLLOWER_EXEMPT_THRESHOLD); config.blueVerifiedExemptEnabled = panel.querySelector('#nuke-blue-verified-exempt-toggle').checked; config.blockKeywordsStandard = panel.querySelector('#nuke-keywords-standard-textarea').value.split('\n').map(kw => kw.trim()).filter(Boolean); config.spamIdentifyEnabled = panel.querySelector('#nuke-spam-identify-toggle').checked; const nextEngine = normalizeAvatarOcrEngine(panel.querySelector('#nuke-spam-avatar-ocr-engine').value); const engineChanged = normalizeAvatarOcrEngine(config.spamAvatarOcrEngine) !== nextEngine; if (engineChanged) { avatarOcrCache.clear(); resetAvatarOcrRuntime(); } config.spamAvatarOcrEngine = nextEngine; delete config.spamAvatarOcrEnabled; delete config.longNameFollowerExemptThreshold; config.spamAutoExpandHidden = panel.querySelector('#nuke-spam-auto-expand-toggle').checked; config.spamAvatarKeywords = panel.querySelector('#nuke-spam-avatar-keywords-textarea').value.split('\n').map((kw) => kw.trim()).filter(Boolean); config.spamIdentifyMinScore = Math.max(1, parseInt(panel.querySelector('#nuke-spam-identify-score-input').value, 10) || DEFAULT_SPAM_IDENTIFY_MIN_SCORE); await saveConfig(config); ensureManualDetectedNukeButton(); if (nextEngine !== AVATAR_OCR_ENGINE_OFF) { void preloadAvatarOcrEngineForUi(nextEngine); } else { setAvatarOcrEngineUiStatus('idle'); } const userData = await loadUserData(); if (userData) { const manualHandles = panel.querySelector('#nuke-promo-targets-textarea').value.split('\n').map(normalizePromoHandle).filter(Boolean); userData.promoTargets = mergePromoTargetEntries(userData.promoTargets, manualHandles, { sourceNote: '手动添加' }); await saveUserData(userData); } showToast('nuke-config-toast', '设置已更新', '配置已成功保存', 3000); }); panel.querySelector('#nuke-log-search').addEventListener('input', renderListsInPanel); panel.querySelector('#nuke-whitelist-search').addEventListener('input', renderListsInPanel); panel.querySelector('#nuke-promo-search').addEventListener('input', renderListsInPanel); if (typeof panel.showModal === 'function' && !panel.open) panel.showModal(); panel.style.visibility = 'hidden'; await renderListsInPanel(); placeDialogInViewport(panel, { center: true }); panel.style.visibility = ''; focusDialogSurface(panel, '#nuke-keywords-standard-textarea'); } finally { setTimeout(() => { isConfigPanelBusy = false; }, 200); } } function escapeHtml(text) { return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } const SPAM_ZERO_WIDTH_RE = /[\u200b-\u200d\u2060\ufeff\u00ad]/g; const SPAM_CJK_PUNCT_RE = /[·•・|,,。.!!??::;;\-—_~~**/\\[\]【】()()「」『』《》〈〉"'‘’“”\s]/g; const SPAM_ASCII_NOISE_BETWEEN_CJK_RE = /([\u4e00-\u9fff])[a-z0-9]{1,8}(?=[\u4e00-\u9fff])/gi; function isShortDatingInviteCompact(compact) { return compact.length <= 12 && (/^(?:来)?聊聊在线等$/.test(compact) || (/见见吗/.test(compact) && /睡不着/.test(compact)) || (/想找/.test(compact) && /会疼人|疼人的|哥哥|姐姐|妹妹/.test(compact))); } const SPAM_SIGNAL_DEFS = [ { id: 'scroll_time', label: '刷帖/逛推时长', weight: 1, test: (compact) => /(?:刷|逛|翻|看|扫).{0,12}?(?:半天|一晚|一天|一晚上|好久|很久|许久|好一会|一会儿|一会|小时)/.test(compact) || /刚.{0,4}?(?:刷|逛|翻)完/.test(compact) }, { id: 'platform_ref', label: '提及X/推特', weight: 1, test: (compact) => /(?:^|[^a-z0-9])x(?:[^a-z0-9]|$)/i.test(compact) || /推特|小蓝鸟|twitter/.test(compact) }, { id: 'profile_cta', label: '主页/空间导流', weight: 1, test: (compact) => /主页|个人页|主頁|空间|置顶|简介|资料|链接在|点主页|戳主页|看她主页|看他主页|她主页|他主页/.test(compact) }, { id: 'adult_euphemism', label: '色情暗语/飞机', weight: 1, test: (compact, raw) => /打.{0,3}?飞|能飞|起飞|开飞|✈|🛫|🛩|飞机|打飞机|打飞機/.test(compact + raw) || /舅舅|涩涩|福利|懂的都懂|(?:擦边|私房|色色|涩涩|成人).{0,4}?资源/.test(compact) }, { id: 'adult_persona', label: '成人人设暗语', weight: 1, test: (compact) => /福利[鸡姬]/.test(compact) }, { id: 'age_tag', label: '年龄标签(30+等)', weight: 1, test: (compact, raw) => /(?:^|[^\d])(?:1[89]|[2-5]\d|60)\+/.test(compact + raw) || /(?:20|30|40|五十|四十|三十|二十)多/.test(compact) || /三十加|四十加|二十加/.test(compact) }, { id: 'persona_role', label: '职业/人设套词', weight: 1, test: (compact) => /体制内|女老师|老师|护士|御姐|人妻|空姐|校花|女大|熟女|少妇|萝莉|模特|舞蹈生|考研生|女高|单亲|宝妈/.test(compact) }, { id: 'explore_tease', label: '探路/花样暗示', weight: 1, test: (compact) => /已探路|探过路|探路|花样多|花样不少|玩法多|会玩|懂玩|经验丰富|去过都说|真会玩/.test(compact) }, { id: 'contrast_tease', label: '反差/返差暗示', weight: 1, test: (compact) => /反差|返差/.test(compact) }, { id: 'offline_lewd_claim', label: '线下色情经历', weight: 1, test: (compact) => /线下/.test(compact) && /日过|曰过|睡过|约过/.test(compact) }, { id: 'lewd_reaction', label: '色情反应话术', weight: 1, test: (compact) => /太涩|好涩|真涩|涩了|色了|太色|好色|顶不住|受不了|扛不住|绷不住|把持不住|定力不够|真顶|顶不住/.test(compact) }, { id: 'lewd_slang', label: '骚/谐音sao', weight: 1, test: (compact) => /骚货|骚的很|很骚|太骚|真骚|骚死|骚批|比.*?骚/.test(compact) || /[这那][4么麼]?么?骚/.test(compact) || /sao货|sao的很|sao死|sao批|很sao|真sao|太sao|巨sao|sao女|sao姐|sao哥/.test(compact) || /比她sao|比他还sao|没人比.{0,8}?sao|比.*sao/.test(compact) || /第一(?:骚|sao)|第1(?:骚|sao)|最(?:骚|sao)|巨(?:骚|sao)/.test(compact) }, { id: 'mention_promo', label: '@导流', weight: 1, test: (compact, raw) => /@[a-z0-9_]{2,}/i.test(raw) || /就.{0,8}?@|去@|看@|戳@|关注@/.test(compact) }, { id: 'dating_hook', label: '交友/同城套词', weight: 1, test: (compact) => /同城|附近|搭子|固炮|真人|线下|见面|私聊|dd|约会|少妇|姐姐|妹妹/.test(compact) }, { id: 'adult_experience_claim', label: '线下体验暗示', weight: 1, test: (compact) => /线下|真人|真实/.test(compact) && /宝宝|妹妹|姐姐|身材|福利/.test(compact) && /我(?:试|試)过|(?:试|試)过了|真的?很不错|身材(?:特棒|很好|不错)|特棒/.test(compact) }, { id: 'short_dating_invite', label: '短句交友导流', weight: 3, test: (compact) => isShortDatingInviteCompact(compact) }, { id: 'course_funnel', label: '课程/教程导流', weight: 3, test: (compact) => /英语|外语|日语|韩语|西班牙语|语言学习|任何语言|流利学会/.test(compact) && /公开课|这堂课|课程|教程|底层方法论|唯一正确|秘诀|快速学会|强烈建议刷|早知道.{0,12}?方法|别再.{0,12}?(?:不科学|浪费时间)|学会任何语言|流利学会任何语言/.test(compact) }, { id: 'drive_link', label: '网盘链接', weight: 2, test: (compact, raw) => /pan\.quark\.cn|drive\.uc\.cn|aliyundrive\.com|115\.com|lanzou|mega\.nz/i.test(raw) }, { id: 'core_template', label: '核心话术模板', weight: 2, test: (compact) => /刷.{0,18}?(?:半天|一晚|一天|一晚上|好久|很久).{0,24}?(?:x|推特|小蓝鸟).{0,18}?(?:她|他|这)?.{0,18}?主.?页.{0,24}?(?:打.{0,4}?飞|✈|起飞|能飞)/.test(compact) || /刷.{0,12}?(?:x|推特).{0,18}?主.?页.{0,18}?(?:打.{0,4}?飞|✈)/.test(compact) } ]; function normalizeSpamText(text) { let s = String(text || ''); try { s = s.normalize('NFKC'); } catch { /* ignore */ } s = s.replace(SPAM_ZERO_WIDTH_RE, '').replace(/[Ⅹⅹ❌✖️]/g, 'x').replace(/[@﹫]/g, '@').replace(/[xX]/g, 'x').replace(/\uFE0F/g, '').replace(/\s+/g, ' ').trim(); s = s.replace(/\s+\d+\s*(?:[iyh]|s)\s*$/i, '').trim(); return s; } function compactSpamText(text) { return normalizeSpamText(text).replace(SPAM_CJK_PUNCT_RE, '').toLowerCase(); } function compactSpamTextVariants(text) { const compact = compactSpamText(text); const folded = compact.replace(SPAM_ASCII_NOISE_BETWEEN_CJK_RE, '$1'); return folded && folded !== compact ? [compact, folded] : [compact]; } function detectSpamReply(text, options = {}) { const minScore = options.minScore ?? scriptConfig.spamIdentifyMinScore ?? DEFAULT_SPAM_IDENTIFY_MIN_SCORE; const raw = normalizeSpamText(text); const compact = compactSpamText(raw); const compactVariants = compactSpamTextVariants(raw); const shortDatingInvite = isShortDatingInviteCompact(compact); if (!raw || (raw.length < 8 && !shortDatingInvite)) return { match: false, score: 0, signals: [], summary: '' }; if (!/[\u4e00-\u9fff]/.test(raw) && !/pan\.quark|drive\.uc/i.test(raw)) return { match: false, score: 0, signals: [], summary: '' }; const signals = []; let score = 0; for (const def of SPAM_SIGNAL_DEFS) { if (compactVariants.some((candidate) => def.test(candidate, raw))) { signals.push({ id: def.id, label: def.label, weight: def.weight }); score += def.weight; } } const coreIds = new Set(['scroll_time', 'platform_ref', 'profile_cta', 'adult_euphemism', 'mention_promo']); const coreHits = signals.filter((s) => coreIds.has(s.id)).length; const templateHit = signals.some((s) => s.id === 'core_template'); const driveHit = signals.some((s) => s.id === 'drive_link'); const euphemismAtProfile = signals.some((s) => s.id === 'adult_euphemism') && signals.some((s) => s.id === 'profile_cta') && signals.some((s) => s.id === 'mention_promo'); const mentionHit = signals.some((s) => s.id === 'mention_promo'); const lewdReactionHit = signals.some((s) => s.id === 'lewd_reaction'); const lewdSlangHit = signals.some((s) => s.id === 'lewd_slang'); const personaHit = signals.some((s) => s.id === 'persona_role'); const exploreHit = signals.some((s) => s.id === 'explore_tease'); const ageHit = signals.some((s) => s.id === 'age_tag'); let match = score >= minScore; if (!match && templateHit) match = true; if (!match && driveHit && score >= 2) match = true; if (!match && euphemismAtProfile && coreHits >= 2) match = true; if (!match && coreHits >= 4) match = true; if (!match && mentionHit && lewdReactionHit) match = true; if (!match && mentionHit && lewdSlangHit) match = true; if (!match && mentionHit && personaHit && (exploreHit || ageHit || lewdReactionHit || lewdSlangHit)) match = true; if (!match && mentionHit && exploreHit && personaHit) match = true; if (!match && mentionHit && ageHit && personaHit) match = true; return { match, score, signals, summary: signals.map((s) => s.label).join('、') }; } function getTweetTextFromArticle(article) { return article?.querySelector('[data-testid="tweetText"]')?.textContent?.trim() || ''; } function normalizePromoHandle(handle) { return String(handle || '').trim().replace(/^@+/, '').toLowerCase(); } function extractMentionHandlesFromText(text, excludeHandles = []) { const exclude = new Set((excludeHandles || []).map(normalizePromoHandle).filter(Boolean)); const handles = new Set(); const source = String(text || ''); for (const match of source.matchAll(/@([a-z0-9_]{1,15})/gi)) { const handle = normalizePromoHandle(match[1]); if (handle && !exclude.has(handle)) handles.add(handle); } return [...handles]; } function extractMentionHandlesFromArticle(article, authorHandle = '') { const exclude = [authorHandle, currentUserScreenName].map(normalizePromoHandle).filter(Boolean); const text = getTweetTextFromArticle(article); const handles = extractMentionHandlesFromText(text, exclude); article?.querySelectorAll('a[href*="/"]').forEach((link) => { const handle = normalizePromoHandle(getScreenNameFromProfileHref(link.href)); if (!handle || exclude.includes(handle)) return; if (/\/status\/\d+/i.test(link.href)) return; handles.push(handle); }); return [...new Set(handles)]; } function getMatchedPromoTargetInTweet(tweetText, promoTargets = []) { const targetSet = new Set((promoTargets || []).map((entry) => normalizePromoHandle(entry.screenName)).filter(Boolean)); if (!targetSet.size) return null; return extractMentionHandlesFromText(tweetText, []).find((handle) => targetSet.has(handle)) || null; } function mergePromoTargetEntries(existing = [], handles = [], meta = {}) { const byHandle = new Map((existing || []).map((entry) => [normalizePromoHandle(entry.screenName), entry])); const now = Date.now(); handles.map(normalizePromoHandle).filter(Boolean).forEach((screenName) => { const prev = byHandle.get(screenName); byHandle.set(screenName, { userId: prev?.userId || meta.userId || null, screenName, userNameText: prev?.userNameText || meta.userNameText || screenName, addedAt: prev?.addedAt || now, sourceNote: meta.sourceNote || prev?.sourceNote || '手动添加', lastSeenAt: now }); }); return [...byHandle.values()].sort((a, b) => (b.lastSeenAt || b.addedAt || 0) - (a.lastSeenAt || a.addedAt || 0)); } async function blockPromoTargetHandle(handle, userData, tweetContext, whitelistIds, exemptHandles) { const normalized = normalizePromoHandle(handle); if (!normalized || exemptHandles.includes(normalized) || normalized === normalizePromoHandle(currentUserScreenName)) return false; const existingIds = new Set([...userData.queue.map((u) => u.userId), ...userData.blockedLog.map((u) => u.userId)]); let userResult; try { userResult = await getUserDataByScreenName(normalized); } catch (error) { console.warn(`[CB] 无法获取引流目标 @${normalized}`, error); return false; } const userId = userResult?.rest_id; if (!userId || whitelistIds.has(userId) || existingIds.has(userId)) return false; await blockUserById(userId); userData.blockedLog.push({ userId, screenName: normalized, userNameText: userResult.core?.name || userResult.legacy?.name || normalized, blockTimestamp: Date.now(), blockReason: 'promo_target', blockNote: `引流目标·@${normalized}${formatTweetContextSuffix(tweetContext)}`.trim(), sourceTweetId: tweetContext.tweetId || null, sourceTweetUrl: tweetContext.tweetUrl || '', sourceTweetText: tweetContext.tweetText || '' }); const limit = scriptConfig.blockLogLimit || 500; if (limit > 0) { while (userData.blockedLog.length > limit) userData.blockedLog.shift(); } return true; } async function processPromoMentionsFromArticle(targetArticle, tweetContext, userData, authorHandle, whitelistIds, exemptHandles) { const mentions = extractMentionHandlesFromArticle(targetArticle, authorHandle); if (!mentions.length) return { added: [], blocked: 0 }; userData.promoTargets = mergePromoTargetEntries(userData.promoTargets, mentions, { sourceNote: `九族收录·@${authorHandle || '未知'}` }); let blocked = 0; for (const handle of mentions) { if (await blockPromoTargetHandle(handle, userData, tweetContext, whitelistIds, exemptHandles)) blocked += 1; } await saveUserData(userData); if (blocked > 0) { showToast('nuke-promo-target-toast', '引流目标已拉黑', `已拉黑 ${blocked} 个推文 @ 用户并加入列表`, 3500); } return { added: mentions, blocked }; } function normalizeOcrText(text) { return String(text || '').replace(/\s+/g, '').trim(); } function upgradeProfileImageUrl(url) { const src = String(url || '').trim(); if (!src) return ''; return src.replace(/_(normal|bigger|mini|x96)(?=\.[a-z])/i, '_400x400'); } function avatarImageFetchCandidates(url) { const src = String(url || '').trim(); if (!src) return []; const candidates = []; const add = (candidate) => { if (candidate && !candidates.includes(candidate)) candidates.push(candidate); }; add(upgradeProfileImageUrl(src)); add(src); if (/_normal(?=\.[a-z])/i.test(src)) add(src.replace(/_normal(?=\.[a-z])/i, '_bigger')); if (/_400x400(?=\.[a-z])/i.test(src)) add(src.replace(/_400x400(?=\.[a-z])/i, '_normal')); return candidates; } function getAvatarImageElement(article) { return [...(article?.querySelectorAll('img') || [])].find((node) => /profile_images|twimg\.com/i.test(node.currentSrc || node.src || node.getAttribute('data-src') || '')); } function getAvatarImageUrlFromArticle(article) { const img = getAvatarImageElement(article); return (img?.currentSrc || img?.src || img?.getAttribute('data-src') || '').trim(); } function resolveAvatarKeywordPatterns() { const dedicated = scriptConfig.spamAvatarKeywords; if (Array.isArray(dedicated) && dedicated.length) return dedicated.filter(Boolean); return (scriptConfig.blockKeywordsStandard || []).filter(Boolean); } function hasRegexMeta(text) { return /[\\^$.*+?()[\]{}|]/.test(String(text || '')); } function levenshteinDistance(a, b, maxDistance = Infinity) { const left = String(a || ''); const right = String(b || ''); if (Math.abs(left.length - right.length) > maxDistance) return maxDistance + 1; let prev = Array.from({ length: right.length + 1 }, (_, i) => i); for (let i = 1; i <= left.length; i += 1) { const curr = [i]; let rowMin = curr[0]; for (let j = 1; j <= right.length; j += 1) { const cost = left[i - 1] === right[j - 1] ? 0 : 1; const value = Math.min( prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost ); curr[j] = value; if (value < rowMin) rowMin = value; } if (rowMin > maxDistance) return maxDistance + 1; prev = curr; } return prev[right.length]; } function commonSubsequenceLength(a, b) { const left = String(a || ''); const right = String(b || ''); let prev = new Array(right.length + 1).fill(0); for (let i = 1; i <= left.length; i += 1) { const curr = new Array(right.length + 1).fill(0); for (let j = 1; j <= right.length; j += 1) { curr[j] = left[i - 1] === right[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1]); } prev = curr; } return prev[right.length]; } function matchesFuzzyOcrKeyword(compact, keyword) { const target = normalizeOcrText(keyword); if (target.length < 4 || hasRegexMeta(target)) return false; if (compact.includes(target)) return true; const maxDistance = target.length <= 4 ? 2 : Math.max(2, Math.floor(target.length * 0.34)); const minCommon = target.length - maxDistance; const minLen = Math.max(1, target.length - maxDistance); const maxLen = target.length + maxDistance; for (let start = 0; start < compact.length; start += 1) { for (let len = minLen; len <= maxLen && start + len <= compact.length; len += 1) { const candidate = compact.slice(start, start + len); if (commonSubsequenceLength(candidate, target) < minCommon) continue; if (levenshteinDistance(candidate, target, maxDistance) <= maxDistance) return true; } } return false; } function matchesAvatarOcrKeywords(ocrText, patterns = []) { const compact = normalizeOcrText(ocrText); if (!compact) return { match: false, hit: '' }; if (!patterns.length) return { match: false, hit: '' }; for (const pattern of patterns) { if (!pattern) continue; try { if (new RegExp(pattern, 'i').test(compact)) return { match: true, hit: pattern }; } catch { if (compact.toLowerCase().includes(String(pattern).toLowerCase())) return { match: true, hit: pattern }; } if (matchesFuzzyOcrKeyword(compact, pattern)) return { match: true, hit: pattern }; } return { match: false, hit: '' }; } function detectPromoAvatarSignature(imageUrl, ocrText, patterns) { const imageId = extractTwitterProfileImageId(imageUrl); const keywordHit = matchesAvatarOcrKeywords(ocrText, patterns); if (keywordHit.match) { return { match: true, hit: keywordHit.hit, source: 'ocr', imageId }; } return { match: false, hit: '', source: 'none', imageId }; } function fetchImageArrayBuffer(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload: (response) => { if (response.status >= 200 && response.status < 300 && response.response?.byteLength > 64) { resolve(response.response); } else { reject(new Error(`avatar fetch ${response.status}`)); } }, onerror: () => reject(new Error('avatar fetch network error')) }); }); } async function fetchAvatarImageArrayBuffer(imageUrl) { const candidates = avatarImageFetchCandidates(imageUrl); let lastError = null; for (const url of candidates) { try { return await fetchImageArrayBuffer(url); } catch (error) { lastError = error; } } throw lastError || new Error('avatar fetch failed'); } function getActiveAvatarOcrEngineForUi() { const selectEl = document.querySelector('#nuke-spam-avatar-ocr-engine'); if (selectEl) return normalizeAvatarOcrEngine(selectEl.value); return getAvatarOcrEngine(); } function noteAvatarOcrError(error, engine = getActiveAvatarOcrEngineForUi()) { try { document.documentElement.dataset.cbSpamOcrLastError = formatAvatarOcrError(error, '').slice(0, 160); document.documentElement.dataset.cbSpamOcrEngine = engine; } catch { /* ignore */ } } function markAvatarOcrEngineFailed(engine, error) { const normalized = normalizeAvatarOcrEngine(engine); const uiLoading = document.documentElement.dataset.cbSpamOcrUiState === 'loading'; if (!uiLoading) { if (normalized === AVATAR_OCR_ENGINE_PADDLE) avatarOcrPaddleFailed = true; else avatarOcrTesseractFailed = true; } if (uiLoading && getActiveAvatarOcrEngineForUi() === normalized) { noteAvatarOcrError(error, normalized); } } function textFromPaddleBrowserResult(result) { return normalizeOcrText(collectPaddleOcrTexts(result).join('')); } function collectPaddleOcrTexts(value, texts = [], seen = new WeakSet()) { if (!value) return texts; if (typeof value === 'string') { texts.push(value); return texts; } if (Array.isArray(value)) { value.forEach((item) => collectPaddleOcrTexts(item, texts, seen)); return texts; } if (typeof value !== 'object') return texts; if (seen.has(value)) return texts; seen.add(value); if (typeof value.text === 'string') texts.push(value.text); [ 'parse', 'parragraphs', 'paragraphs', 'columns', 'src', 'lines', 'words', 'result', 'data' ].forEach((key) => { if (value[key] != null) collectPaddleOcrTexts(value[key], texts, seen); }); return texts; } function publishPaddleUserscriptHandle(handle) { paddleUserscriptHandle = handle; avatarOcrPaddleReady = true; try { getPageWindow().__cbPaddleBrowser = handle; } catch { /* ignore */ } } function runSerializedAvatarOcrInit(task) { const run = avatarOcrInitSerial.then(() => task()); avatarOcrInitSerial = run.catch(() => { /* keep queue alive */ }); return run; } async function ensurePaddleUserscriptReady(onProgress) { return runSerializedAvatarOcrInit(() => ensurePaddleUserscriptReadyInner(onProgress)); } async function ensurePaddleUserscriptReadyInner(onProgress) { const report = (pct, label) => { try { onProgress?.(pct, label); } catch { /* ignore */ } }; if (avatarOcrPaddleFailed) throw new Error('PaddleOCR 初始化已失败'); if (!/^https?:$/i.test(location.protocol)) throw new Error('PaddleOCR 需要 https 页面'); if (paddleUserscriptHandle?.ready) { report(100, 'PaddleOCR 已就绪'); return paddleUserscriptHandle; } if (!paddleUserscriptInitPromise) { paddleUserscriptInitPromise = (async () => { report(8, '加载 PaddleOCR…'); const Paddle = await loadPaddleModule(); report(18, '加载 ONNX Runtime…'); const ortRef = await ensureUserscriptOrt(); report(32, '下载识别模型…'); startPaddleUiProgressPulse(onProgress, 36); const [detTar, recTar] = await Promise.all([ gmFetchArrayBuffer(PADDLE_DET_TAR_URL, 180000), gmFetchArrayBuffer(PADDLE_REC_TAR_URL, 180000) ]); report(58, '解包识别模型…'); const detModel = extractTarEntryBytes(detTar, 'inference.onnx'); const recModel = extractTarEntryBytes(recTar, 'inference.onnx'); const recYml = new TextDecoder().decode(extractTarEntryBytes(recTar, 'inference.yml')); const charactersDictionary = parsePaddleCharacterDictionary(recYml); report(68, '初始化 PaddleOCR…'); const service = await Paddle.PaddleOcrService.createInstance({ ort: ortRef, detection: { modelBuffer: bytesToArrayBuffer(detModel) }, recognition: { modelBuffer: bytesToArrayBuffer(recModel), charactersDictionary } }); stopPaddleUiProgressPulse(); return { ready: true, runOcr: async (imageData) => service.processRecognition(await service.recognize(imageData)) }; })().then((handle) => { publishPaddleUserscriptHandle(handle); report(100, 'PaddleOCR 已就绪'); return handle; }).catch((error) => { stopPaddleUiProgressPulse(); paddleUserscriptInitPromise = null; markAvatarOcrEngineFailed(AVATAR_OCR_ENGINE_PADDLE, error); throw error; }); } else if (onProgress) { report(12, '正在加载模型…'); startPaddleUiProgressPulse(onProgress, 20); } return paddleUserscriptInitPromise; } async function getAvatarOcrWorker() { return runSerializedAvatarOcrInit(() => getAvatarOcrWorkerInner()); } async function getAvatarOcrWorkerInner() { if (document.documentElement.dataset.cbSpamOcrUiState === 'loading') { avatarOcrTesseractFailed = false; } if (avatarOcrTesseractFailed) throw new Error('Tesseract 初始化已失败'); if (typeof Tesseract === 'undefined') throw new Error('Tesseract.js 未加载'); if (!avatarOcrWorkerPromise) { const uiReport = document.documentElement.dataset.cbSpamOcrUiState === 'loading' ? (pct, label) => { const selectEl = document.querySelector('#nuke-spam-avatar-ocr-engine'); if (!selectEl || normalizeAvatarOcrEngine(selectEl.value) !== AVATAR_OCR_ENGINE_TESSERACT) return; setAvatarOcrEngineUiStatus('loading', pct, label); } : null; avatarOcrWorkerPromise = ensureTesseractWorkerOptions(uiReport) .then((opts) => Tesseract.createWorker('chi_sim', 1, opts)) .then(async (worker) => { if (typeof worker.setParameters === 'function') { await worker.setParameters({ tessedit_pageseg_mode: '11' }); } avatarOcrTesseractReady = true; return worker; }) .catch((error) => { resetAvatarOcrWorker(); markAvatarOcrEngineFailed(AVATAR_OCR_ENGINE_TESSERACT, error); avatarOcrTesseractReady = false; throw error; }); } return avatarOcrWorkerPromise; } function warmUpAvatarOcr() { if (scriptConfig.spamIdentifyEnabled === false || !isAvatarOcrEnabled()) return; const engine = getAvatarOcrEngine(); const report = (pct, label) => { const selectEl = document.querySelector('#nuke-spam-avatar-ocr-engine'); if (!selectEl || normalizeAvatarOcrEngine(selectEl.value) !== engine) return; setAvatarOcrEngineUiStatus('loading', pct, label); }; if (engine === AVATAR_OCR_ENGINE_PADDLE) { if (!avatarOcrPaddleFailed && !isAvatarOcrEngineReady(engine)) { void ensurePaddleUserscriptReady(report) .then(() => { try { document.documentElement.dataset.cbSpamOcrReady = '1'; } catch { /* ignore */ } }) .catch(() => { /* noted in ensurePaddleUserscriptReady */ }); } return; } if (avatarOcrTesseractFailed || isAvatarOcrEngineReady(engine)) return; void loadTesseractForUi(report) .then(() => { try { document.documentElement.dataset.cbSpamOcrReady = '1'; } catch { /* ignore */ } }) .catch(() => { /* noted in getAvatarOcrWorker */ }); } const AVATAR_OCR_IMAGE_SCALE = 2.25; async function canvasToBlob(canvas, type = 'image/png', quality) { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error('avatar scale failed')); }, type, quality); }); } function otsuThreshold(values) { const hist = new Array(256).fill(0); values.forEach((value) => { hist[value] += 1; }); const total = values.length || 1; let sum = 0; for (let i = 0; i < 256; i += 1) sum += i * hist[i]; let sumB = 0; let weightB = 0; let best = 128; let bestScore = 0; for (let i = 0; i < 256; i += 1) { weightB += hist[i]; if (!weightB) continue; const weightF = total - weightB; if (!weightF) break; sumB += i * hist[i]; const meanB = sumB / weightB; const meanF = (sum - sumB) / weightF; const score = weightB * weightF * (meanB - meanF) * (meanB - meanF); if (score > bestScore) { bestScore = score; best = i; } } return best; } function processedAvatarCanvas(source, width, height, { channel = 'gray', invert = false, threshold = false } = {}) { const values = new Uint8ClampedArray(width * height); let min = 255; let max = 0; for (let i = 0; i < values.length; i += 1) { const j = i * 4; const r = source.data[j]; const g = source.data[j + 1]; const b = source.data[j + 2]; let value = channel === 'min' ? Math.min(r, g, b) : Math.round(0.299 * r + 0.587 * g + 0.114 * b); if (invert) value = 255 - value; values[i] = value; if (value < min) min = value; if (value > max) max = value; } if (max > min) { for (let i = 0; i < values.length; i += 1) { values[i] = Math.max(0, Math.min(255, Math.round((values[i] - min) * 255 / (max - min)))); } } const thresholdValue = threshold ? otsuThreshold(values) : null; const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas unavailable'); const output = ctx.createImageData(width, height); for (let i = 0; i < values.length; i += 1) { const value = thresholdValue == null ? values[i] : (values[i] >= thresholdValue ? 255 : 0); const j = i * 4; output.data[j] = value; output.data[j + 1] = value; output.data[j + 2] = value; output.data[j + 3] = 255; } ctx.putImageData(output, 0, 0); return canvas; } async function createAvatarOcrImageBlobs(arrayBuffer) { const blob = new Blob([arrayBuffer], { type: 'image/jpeg' }); if (typeof createImageBitmap !== 'function') return [blob]; const bitmap = await createImageBitmap(blob); const sourceSize = Math.max(bitmap.width || 0, bitmap.height || 0); const size = Math.max(576, Math.round(sourceSize * AVATAR_OCR_IMAGE_SCALE)); const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return [blob]; ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, size, size); ctx.drawImage(bitmap, 0, 0, size, size); if (typeof bitmap.close === 'function') bitmap.close(); const source = ctx.getImageData(0, 0, size, size); return [ await canvasToBlob(canvas, 'image/jpeg', 0.95), await canvasToBlob(processedAvatarCanvas(source, size, size, { channel: 'gray' })), await canvasToBlob(processedAvatarCanvas(source, size, size, { channel: 'gray', invert: true, threshold: true })), await canvasToBlob(processedAvatarCanvas(source, size, size, { channel: 'min' })) ]; } async function scaleAvatarBlobForOcr(arrayBuffer) { const blobs = await createAvatarOcrImageBlobs(arrayBuffer); return blobs[0]; } async function blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject(new Error('read image failed')); reader.readAsDataURL(blob); }); } async function recognizeAvatarWithTesseract(arrayBuffer, patterns = []) { const blobs = await createAvatarOcrImageBlobs(arrayBuffer); const worker = await getAvatarOcrWorker(); const texts = []; let lastError = null; for (const blob of blobs) { try { const { data: { text } } = await worker.recognize(blob); const compact = normalizeOcrText(text); if (compact && !texts.includes(compact)) texts.push(compact); const combined = texts.join('\n'); if (matchesAvatarOcrKeywords(combined, patterns).match) return combined; } catch (error) { lastError = error; } } if (!texts.length && lastError) throw lastError; return texts.join('\n'); } async function recognizeAvatarWithPaddleBrowser(arrayBuffer) { const scaledBlob = await scaleAvatarBlobForOcr(arrayBuffer); const paddle = await ensurePaddleUserscriptReady(); const imageData = await blobToImageData(scaledBlob); const result = await paddle.runOcr(imageData); return textFromPaddleBrowserResult(result); } async function recognizeAvatarTextWithOcr(arrayBuffer, patterns = []) { if (getAvatarOcrEngine() === AVATAR_OCR_ENGINE_PADDLE) return recognizeAvatarWithPaddleBrowser(arrayBuffer); return recognizeAvatarWithTesseract(arrayBuffer, patterns); } async function analyzeAvatarImageBuffer(arrayBuffer, patterns, imageUrl = '') { const imageId = extractTwitterProfileImageId(imageUrl); if (isAvatarOcrEngineFailed()) { return { match: false, hit: '', source: 'none', imageId, ocrOk: false, ocrText: '' }; } try { const ocrText = await recognizeAvatarTextWithOcr(arrayBuffer, patterns); const signature = detectPromoAvatarSignature(imageUrl, ocrText, patterns); return { ...signature, ocrOk: true, ocrText }; } catch (error) { noteAvatarOcrError(error); return { match: false, hit: '', source: 'none', imageId, ocrOk: false, ocrText: '' }; } } async function analyzeAvatarImageUrl(imageUrl, patterns) { const cached = avatarOcrCache.get(imageUrl); if (cached?.result?.match && Date.now() - cached.at < AVATAR_OCR_CACHE_MS) return cached.result; const buffer = await fetchAvatarImageArrayBuffer(imageUrl); const result = await analyzeAvatarImageBuffer(buffer, patterns, imageUrl); if (result.match) avatarOcrCache.set(imageUrl, { result, at: Date.now() }); return result; } function ensureSpamBadge(article, detection, kind = 'text') { article.classList.add('nuke-spam-identified'); let badge = article.querySelector('.nuke-spam-badge'); if (!badge) { badge = document.createElement('div'); badge.className = 'nuke-spam-badge'; const anchor = article.querySelector('div[data-testid="tweetText"]'); if (anchor?.parentElement) anchor.parentElement.insertBefore(badge, anchor); else article.prepend(badge); } if (kind === 'auto') { const summary = detection.summary || '自动规则命中'; const badgeText = detection.badgeText || `自动标记 · ${summary}`; const title = detection.title || summary; if (badge.textContent) { const existingTitle = badge.title || ''; if (title && !existingTitle.includes(title)) badge.title = `${existingTitle}\n${title}`.trim(); if (!badge.textContent.includes(badgeText)) badge.textContent = `${badge.textContent};${badgeText}`; } else { badge.title = title; badge.textContent = badgeText; } } else if (kind === 'avatar') { const avatarPart = `头像·${detection.summary}`; if (badge.textContent && badge.textContent.includes('疑似引流')) { badge.title = `${badge.title || ''}\n头像 OCR: ${detection.summary}`; badge.textContent = `${badge.textContent};${avatarPart}`; } else { badge.title = `头像 OCR 命中: ${detection.summary}`; badge.textContent = `头像疑似引流 · ${detection.summary}`; } } else { badge.title = `${detection.summary}\n得分: ${detection.score}`; badge.textContent = `疑似引流 · ${detection.score}分`; } window.setTimeout(updateManualDetectedNukeButton, 0); } function isAutoNukeEnabled() { return scriptConfig.autoBlockEnabled === true; } function markArticleForAutoRule(article, summary, title = summary) { if (!article) return; ensureSpamBadge(article, { summary, title, badgeText: `自动标记 · ${summary}` }, 'auto'); } function triggerAutoNukeForMarkedArticle(article, trigger) { if (!article || !isAutoNukeEnabled() || article.dataset.autoblockTriggered === 'true') return false; article.dataset.autoblockTriggered = 'true'; article.dataset.autoblockChecked = 'complete'; void initiateNukeProcess(article, trigger); return true; } function isDetectedNukeTargetArticle(article) { return !!( article?.isConnected && article.querySelector('.nuke-spam-badge') && article.dataset.autoblockTriggered !== 'true' && article.style.display !== 'none' ); } function getDetectedNukeTargetArticles() { return Array.from(document.querySelectorAll('article[data-testid="tweet"]')).filter(isDetectedNukeTargetArticle); } function parseCompactEngagementCount(text) { const normalized = String(text || '').replace(/,/g, '').trim(); if (!normalized) return 0; const match = normalized.match(/(\d+(?:\.\d+)?)\s*([万千kKmM]?)/); if (!match) return 0; const value = Number(match[1]); if (!Number.isFinite(value)) return 0; const unit = match[2] || ''; if (unit === '万') return Math.round(value * 10000); if (unit === '千') return Math.round(value * 1000); if (unit.toLowerCase() === 'k') return Math.round(value * 1000); if (unit.toLowerCase() === 'm') return Math.round(value * 1000000); return Math.round(value); } function getEngagementCountFromAction(article, testIds) { if (!article) return null; const selector = testIds.map(testId => `[data-testid="${testId}"]`).join(','); const action = article.querySelector(selector); if (!action) return null; const label = action.getAttribute('aria-label') || action.querySelector('[aria-label]')?.getAttribute('aria-label') || ''; const text = action.textContent || ''; return parseCompactEngagementCount(`${label} ${text}`); } function getArticleEngagementCounts(article) { return { replies: getEngagementCountFromAction(article, ['reply']), retweets: getEngagementCountFromAction(article, ['retweet', 'unretweet']), likes: getEngagementCountFromAction(article, ['like', 'unlike']) }; } function isZeroEngagementNukeTarget(resolvedTarget) { const counts = resolvedTarget?.engagementCounts; return !!counts && counts.replies === 0 && counts.retweets === 0 && counts.likes === 0; } function sortResolvedNukeTargetsForDirectBlock(resolvedTargets) { return resolvedTargets.slice().sort((left, right) => { const zeroPriority = Number(isZeroEngagementNukeTarget(right)) - Number(isZeroEngagementNukeTarget(left)); if (zeroPriority) return zeroPriority; return (left.manualOrder ?? 0) - (right.manualOrder ?? 0); }); } function buildManualDetectedNukeTrigger(article) { const badge = article?.querySelector?.('.nuke-spam-badge'); const badgeText = badge?.textContent?.trim() || ''; const badgeTitle = badge?.title?.trim() || ''; const combined = `${badgeText}\n${badgeTitle}`.trim(); if (/头像|OCR|全国安排/.test(combined)) { const hit = (badgeTitle.match(/头像 OCR[::]\s*([^\n]+)/)?.[1] || badgeText.replace(/^头像疑似引流\s*·\s*/, '') || '头像 OCR').trim(); return { triggerMode: 'auto', autoReason: 'avatar_ocr', avatarOcrHit: hit }; } if (/疑似引流/.test(combined)) { const score = Number(badgeText.match(/(\d+)\s*分/)?.[1]); const summary = badgeTitle.split('\n')[0] || badgeText; return { triggerMode: 'auto', autoReason: 'spam_identify', spamSummary: summary, spamScore: Number.isFinite(score) ? score : undefined }; } return { triggerMode: 'auto', autoReason: 'manual_detected_target', spamSummary: combined || '已检测目标' }; } function updateManualDetectedNukeButton() { const button = document.getElementById('nuke-manual-detected-nuke-button'); if (!button) return; const count = getDetectedNukeTargetArticles().length; const countEl = button.querySelector('.nuke-manual-detected-count'); button.disabled = manualDetectedNukeRunning || count === 0; button.title = count ? `九族拉黑 ${count} 个已检测目标` : '暂无已检测目标'; button.setAttribute('aria-label', button.title); if (countEl) { countEl.textContent = count > 99 ? '99+' : String(count); countEl.hidden = count === 0; } } function ensureManualDetectedNukeButton() { const existing = document.getElementById('nuke-manual-detected-nuke-button'); if (isAutoNukeEnabled()) { existing?.remove(); return; } if (!document.body || existing) { updateManualDetectedNukeButton(); return; } const button = document.createElement('button'); button.id = 'nuke-manual-detected-nuke-button'; button.type = 'button'; button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><g><path d="${NUKE_ICON_PATH}" fill="currentColor"></path></g></svg><span class="nuke-manual-detected-count" hidden>0</span>`; button.addEventListener('click', () => { void executeManualNukeForDetectedTargets(); }); document.body.appendChild(button); updateManualDetectedNukeButton(); } async function executeManualNukeForDetectedTargets() { if (manualDetectedNukeRunning || isAutoNukeEnabled()) return; scanSpamIdentifyContent(); scanAndProcessContent(); await new Promise((resolve) => window.setTimeout(resolve, 250)); const articles = getDetectedNukeTargetArticles(); if (!articles.length) { showToast('nuke-manual-detected-toast', '暂无已检测目标', '没有可执行九族拉黑的标记推文', 3000); updateManualDetectedNukeButton(); return; } manualDetectedNukeRunning = true; updateManualDetectedNukeButton(); showToast('nuke-manual-detected-toast', '建立九族列表', `正在收集 ${articles.length} 个已检测目标的关联用户`, null); try { const userData = await loadUserData(); if (!userData) throw new Error("无法加载用户数据"); const whitelistIds = new Set(userData.whitelist.map(u => u.userId)); const queueById = new Map(); const resolvedTargets = []; const onCollectProgress = status => showToast('nuke-manual-detected-toast', '建立九族列表', status, null); for (const article of articles) { if (!isDetectedNukeTargetArticle(article)) continue; article.dataset.autoblockTriggered = 'true'; article.dataset.autoblockChecked = 'complete'; hideElement(article); try { const resolvedTarget = await resolveNukeTarget(article, buildManualDetectedNukeTrigger(article)); resolvedTargets.push({ ...resolvedTarget, manualOrder: resolvedTargets.length }); await collectChainUsersForResolvedTarget(resolvedTarget, queueById, onCollectProgress); } catch (error) { console.error('[CB] 手动九族建立列表失败:', error); } updateManualDetectedNukeButton(); await new Promise((resolve) => window.setTimeout(resolve, 250)); } const targetAuthorIds = mergeUserIdSets(resolvedTargets.map((target) => buildChainSkipUserIds(target))); const chainExemptHandles = [...new Set(resolvedTargets.flatMap((target) => getChainExemptHandlesForTarget(target.targetArticle)))]; const pendingChainQueueEntries = selectNewChainQueueEntries(userData, queueById, whitelistIds, chainExemptHandles, targetAuthorIds); const directBlockTargets = sortResolvedNukeTargetsForDirectBlock(resolvedTargets); showToast('nuke-manual-detected-toast', '直接拉黑标记用户', `已建立 ${pendingChainQueueEntries.length} 个后台九族目标,正在直接拉黑 ${directBlockTargets.length} 个标记用户(0互动优先)`, null); let blockedAuthors = 0; const handledAuthorIds = new Set(); for (const resolvedTarget of directBlockTargets) { if (resolvedTarget.authorId && handledAuthorIds.has(resolvedTarget.authorId)) continue; if (resolvedTarget.authorId) handledAuthorIds.add(resolvedTarget.authorId); if (await blockResolvedNukeAuthor(resolvedTarget, userData, whitelistIds, [])) blockedAuthors += 1; await saveUserData(userData); updateManualDetectedNukeButton(); await new Promise((resolve) => window.setTimeout(resolve, 350)); } if (pendingChainQueueEntries.length > 0) { addNewChainQueueEntries(userData, queueById, whitelistIds, chainExemptHandles, targetAuthorIds); await saveUserData(userData); } await updateStatusToast(); showToast('nuke-manual-detected-toast', '手动执行完成', `已直接拉黑 ${blockedAuthors} 个标记用户,后台九族队列新增 ${pendingChainQueueEntries.length} 个用户`, 4500); setTimeout(processQueue, 1000); } catch (error) { console.error('[CB] 手动执行九族拉黑失败:', error); showToast('nuke-manual-detected-toast', '手动执行失败', error.message, 5000); } finally { manualDetectedNukeRunning = false; updateManualDetectedNukeButton(); } } function finalizeSpamArticleScan(article) { if (!article) return; delete article.dataset.avatarOcrPending; delete article.dataset.avatarOcrQueued; delete article.dataset.avatarOcrQueuedAt; article.dataset.spamScanned = 'complete'; } function releaseAvatarOcrForRetry(article) { if (!article) return; removeAvatarOcrJobsForArticle(article); delete article.dataset.avatarOcrPending; delete article.dataset.avatarOcrQueued; delete article.dataset.avatarOcrQueuedAt; delete article.dataset.spamScanned; } function removeAvatarOcrJobsForArticle(article) { if (!article) return; for (let i = avatarOcrQueue.length - 1; i >= 0; i -= 1) { if (avatarOcrQueue[i]?.article === article) avatarOcrQueue.splice(i, 1); } } function enqueueAvatarOcr(article, imageUrl) { if (article.dataset.avatarOcrPending === 'true' || article.dataset.avatarOcrQueued === 'true') return; removeAvatarOcrJobsForArticle(article); article.dataset.avatarOcrQueued = 'true'; article.dataset.avatarOcrPending = 'true'; article.dataset.avatarOcrQueuedAt = String(Date.now()); avatarOcrQueue.push({ article, imageUrl }); void pumpAvatarOcrQueue(); } function hasStaleAvatarOcrPending(article) { if (article?.dataset?.avatarOcrPending !== 'true') return false; const queuedAt = parseInt(article.dataset.avatarOcrQueuedAt, 10) || 0; return !queuedAt || Date.now() - queuedAt > AVATAR_OCR_STALE_PENDING_MS; } function isArticleInViewport(article) { if (!article?.isConnected) return false; const rect = article.getBoundingClientRect(); return rect.bottom > 0 && rect.top < window.innerHeight; } function takeNextAvatarOcrJob() { const visibleIndex = avatarOcrQueue.findIndex((job) => isArticleInViewport(job?.article)); if (visibleIndex > 0) return avatarOcrQueue.splice(visibleIndex, 1)[0]; return avatarOcrQueue.shift(); } async function pumpAvatarOcrQueue() { if (avatarOcrPumpRunning) return; avatarOcrPumpRunning = true; const patterns = resolveAvatarKeywordPatterns(); while (avatarOcrQueue.length) { const job = takeNextAvatarOcrJob(); if (!job?.article?.isConnected) continue; if (shouldDeferBackgroundAvatarOcr()) { avatarOcrQueue.unshift(job); await new Promise((resolve) => window.setTimeout(resolve, 400)); continue; } let matched = false; try { if (scriptConfig.spamIdentifyEnabled === false || !isAvatarOcrEnabled()) { if (job.article?.isConnected) finalizeSpamArticleScan(job.article); continue; } const analysis = await analyzeAvatarImageUrl(job.imageUrl, patterns); if (analysis.match) { if (await shouldExemptArticleByTrustedAuthor(job.article, 'avatar_ocr')) { matched = true; } else { const hit = analysis.hit || '头像关键词'; ensureSpamBadge(job.article, { match: true, score: 1, summary: hit }, 'avatar'); triggerAutoNukeForMarkedArticle(job.article, { triggerMode: 'auto', autoReason: 'avatar_ocr', avatarOcrHit: hit }); matched = true; } } } catch (error) { noteAvatarOcrError(error); const failCount = (parseInt(job.article.dataset.avatarOcrFailCount, 10) || 0) + 1; job.article.dataset.avatarOcrFailCount = String(failCount); console.warn(`[CB] 头像识别失败 (${failCount}/${AVATAR_OCR_MAX_FAILS})`, job.imageUrl, error); if (failCount < AVATAR_OCR_MAX_FAILS) { releaseAvatarOcrForRetry(job.article); continue; } } if (job.article?.isConnected) finalizeSpamArticleScan(job.article); await new Promise((resolve) => window.setTimeout(resolve, 100)); } avatarOcrPumpRunning = false; if (avatarOcrQueue.length) void pumpAvatarOcrQueue(); } async function processSpamArticle(article) { if (shouldSkipSpamIdentifyForArticle(article)) { clearSpamIdentifyTextBadge(article); finalizeSpamArticleScan(article); return; } if (shouldSkipSpamArticleScan(article)) return; const tweetText = getTweetTextFromArticle(article); if (tweetText) { const detection = detectSpamReply(tweetText); article.dataset.spamTextScannedBuild = SPAM_SCANNER_BUILD; if (detection.match) { if (await shouldExemptArticleByTrustedAuthor(article, 'spam_identify')) { finalizeSpamArticleScan(article); return; } ensureSpamBadge(article, detection, 'text'); if (triggerAutoNukeForMarkedArticle(article, { triggerMode: 'auto', autoReason: 'spam_identify', spamSummary: detection.summary, spamScore: detection.score })) { finalizeSpamArticleScan(article); return; } } } const avatarUrl = getAvatarImageUrlFromArticle(article); if (avatarUrl && !shouldSkipAvatarOcrForArticle(article) && isAvatarOcrEnabled()) { enqueueAvatarOcr(article, avatarUrl); return; } finalizeSpamArticleScan(article); } const SPAM_EXPAND_LABEL_RE = /垃圾|spam|冒犯|offensive|可疑|probable|隐藏|更多回复|additional repl|显示可能的垃圾|可能含有垃圾/i; const HIDDEN_SPAM_EXPAND_RE = /显示可能的垃圾|Show probable spam|probable spam|可能含有垃圾|冒犯性回复|Offensive replies/i; function tryExpandHiddenSpamReplies() { if (scriptConfig.spamAutoExpandHidden === false || scriptConfig.spamIdentifyEnabled === false) return; if (!/\/status\/\d+/i.test(window.location.pathname)) return; if (window.__cbHiddenSpamExpandPath === window.location.pathname) return; const expandButton = [...document.querySelectorAll('[role="button"], button, a, div[tabindex="0"]')].find((element) => { const text = (element.textContent || '').replace(/\s+/g, ' ').trim(); return text && text.length <= 120 && HIDDEN_SPAM_EXPAND_RE.test(text); }); if (!expandButton) return; window.__cbHiddenSpamExpandPath = window.location.pathname; expandButton.click(); scheduleSpamRescan([400, 1000, 2000, 3500]); } let spamScanDebounceId = null; function articleHasAvatarSpamBadge(article) { const badge = article?.querySelector('.nuke-spam-badge'); return !!(badge && /头像|全国安排/.test(badge.textContent || '')); } function clearSpamIdentifyTextBadge(article) { const badge = article?.querySelector('.nuke-spam-badge'); if (!badge || !/^疑似引流/.test(badge.textContent || '')) return; badge.remove(); if (!article.querySelector('.nuke-spam-badge')) article.classList.remove('nuke-spam-identified'); } function shouldSkipSpamArticleScan(article) { if (hasStaleAvatarOcrPending(article)) { releaseAvatarOcrForRetry(article); return false; } const hasPendingTextScan = !!getTweetTextFromArticle(article) && article.dataset.spamTextScannedBuild !== SPAM_SCANNER_BUILD && !article.querySelector('.nuke-spam-badge:not([data-avatar-ocr-badge])'); if (article.dataset.avatarOcrPending === 'true') return !hasPendingTextScan; if (article.dataset.spamScanned !== 'complete') return false; if (articleHasAvatarSpamBadge(article)) return true; const textBadge = article.querySelector('.nuke-spam-badge'); if (textBadge && !/头像|全国安排/.test(textBadge.textContent || '')) return true; if (isAvatarOcrEnabled() && getAvatarImageUrlFromArticle(article) && !shouldSkipAvatarOcrForArticle(article)) { const failCount = parseInt(article.dataset.avatarOcrFailCount, 10) || 0; if (failCount < AVATAR_OCR_MAX_FAILS) { delete article.dataset.spamScanned; delete article.dataset.avatarOcrQueued; delete article.dataset.avatarOcrPending; return false; } } return true; } function isSpamSectionExpandControl(element) { if (!element) return false; const text = (element.textContent || '').replace(/\s+/g, ' ').trim(); if (!text || text.length > 140) return false; return SPAM_EXPAND_LABEL_RE.test(text); } function scheduleSpamRescan(extraDelaysMs = [300, 900, 1800]) { scheduleSpamRescanDebounced(); extraDelaysMs.forEach((ms) => { window.setTimeout(scanSpamIdentifyContent, ms); window.setTimeout(scanAndProcessContent, ms); }); } function scheduleSpamRescanDebounced() { if (spamScanDebounceId) clearTimeout(spamScanDebounceId); spamScanDebounceId = window.setTimeout(() => { spamScanDebounceId = null; scanSpamIdentifyContent(); scanAndProcessContent(); }, 120); } function scanSpamIdentifyContent() { if (!currentUserId || scriptConfig.spamIdentifyEnabled === false) return; resetSpamScanMarkersForBuildUpgrade(); markStatusRootTweetArticles(); tryExpandHiddenSpamReplies(); document.querySelectorAll('article[data-testid="tweet"]').forEach((article) => { void processSpamArticle(article); }); try { const avatarBadges = document.querySelectorAll('article[data-testid="tweet"] .nuke-spam-badge'); let avatarHits = 0; avatarBadges.forEach((node) => { if (/头像|全国安排/.test(node.textContent || '')) avatarHits += 1; }); document.documentElement.dataset.cbSpamScannerBuild = SPAM_SCANNER_BUILD; document.documentElement.dataset.cbSpamAvatarBadgeCount = String(avatarHits); document.documentElement.dataset.cbSpamOcrEngine = getAvatarOcrEngine(); document.documentElement.dataset.cbSpamOcrInitFailed = isAvatarOcrEngineFailed() ? '1' : '0'; document.documentElement.dataset.cbSpamOcrQueueLen = String(avatarOcrQueue.length); document.documentElement.dataset.cbSpamOcrPending = String(document.querySelectorAll('article[data-avatar-ocr-pending="true"]').length); ensureManualDetectedNukeButton(); } catch { /* probe only */ } } async function inspectTweetArticleForSpam(article) { const userLink = article.querySelector('div[data-testid="User-Name"] a[role="link"]'); const screenName = getScreenNameFromProfileHref(userLink?.href) || '未知'; const tweetText = getTweetTextFromArticle(article); const followerExempt = await shouldExemptArticleByTrustedAuthor(article, 'manual_spam_inspect'); let summary = ''; if (!followerExempt && tweetText) { const detection = detectSpamReply(tweetText); if (detection.match) { ensureSpamBadge(article, detection, 'text'); summary = `${detection.summary}(${detection.score}分)`; } } const avatarUrl = getAvatarImageUrlFromArticle(article); if (!followerExempt && avatarUrl && !shouldSkipAvatarOcrForArticle(article) && isAvatarOcrEnabled()) { try { const analysis = await analyzeAvatarImageUrl(avatarUrl, resolveAvatarKeywordPatterns()); if (analysis.match) { ensureSpamBadge(article, { match: true, score: 1, summary: analysis.hit }, 'avatar'); summary = summary ? `${summary};头像:${analysis.hit}` : `头像:${analysis.hit}`; } } catch (error) { console.warn('[CB] 手动头像识别失败', error); } } if (summary) { showToast('nuke-spam-inspect-toast', `⚠️ 疑似引流 @${screenName}`, summary, 5000); } else if (!tweetText && !avatarUrl) { showToast('nuke-spam-inspect-toast', '无法识别', '没有推文正文或头像图片', 2600); } else { showToast('nuke-spam-inspect-toast', `未命中 @${screenName}`, '推文得分不足且头像 OCR 未命中', 3500); } finalizeSpamArticleScan(article); } function truncateBlockContextText(text, maxLen = BLOCK_CONTEXT_TEXT_MAX) { const normalized = String(text || '').replace(/\s+/g, ' ').trim(); if (!normalized) return ''; if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, maxLen - 1)}…`; } function formatTweetContextSuffix(context = {}) { const parts = []; const tweetText = truncateBlockContextText(context.tweetText); if (tweetText) parts.push(`「${tweetText}」`); if (context.tweetUrl) parts.push(context.tweetUrl); return parts.length ? ` ${parts.join(' ')}` : ''; } function formatChainSourcesLabel(chainSources = []) { const labels = { retweet: '转推', reply: '回复', like: '点赞' }; return [...new Set(chainSources)].map((source) => labels[source]).filter(Boolean).join('/') || '关联'; } function resolveAuthorBlockReason(trigger = {}) { if (trigger.triggerMode === 'manual') return 'manual_author'; if (trigger.autoReason === 'promo_target_mention') return 'auto_promo_target'; if (trigger.autoReason === 'standard_keywords') return 'auto_author_keyword'; if (trigger.autoReason === 'display_name_spam') return 'auto_display_name_spam'; if (trigger.autoReason === 'spam_identify') return 'auto_spam_identify'; if (trigger.autoReason === 'avatar_ocr') return 'auto_avatar_ocr'; if (trigger.autoReason === 'manual_detected_target') return 'auto_manual_detected'; return 'manual_author'; } function buildAuthorBlockNote(trigger, context = {}) { const reason = resolveAuthorBlockReason(trigger); const reasonLabels = { manual_author: '九族拉黑·主推', auto_promo_target: '自动九族·引流目标', auto_author_keyword: '自动拉黑·关键词', auto_display_name_spam: '自动拉黑·昵称引流', auto_spam_identify: '自动九族·引流识别', auto_avatar_ocr: '自动九族·头像OCR', auto_manual_detected: '手动九族·已检测目标' }; const handle = context.authorHandle ? `@${context.authorHandle}` : '该用户'; let blockNote = `${reasonLabels[reason] || '拉黑·主推'} ${handle} 的推文${formatTweetContextSuffix(context)}`.trim(); if (reason === 'auto_promo_target' && trigger.promoTargetHandle) { blockNote += `(命中 @${trigger.promoTargetHandle})`; } if (reason === 'auto_author_keyword' && trigger.suspiciousDisplayName) { blockNote += `(显示名: ${truncateBlockContextText(trigger.suspiciousDisplayName, 60)})`; } if (reason === 'auto_spam_identify' && trigger.spamSummary) { const score = Number.isFinite(Number(trigger.spamScore)) ? `,${trigger.spamScore}分` : ''; blockNote += `(${truncateBlockContextText(trigger.spamSummary, 60)}${score})`; } if (reason === 'auto_avatar_ocr' && trigger.avatarOcrHit) { blockNote += `(头像 OCR: ${truncateBlockContextText(trigger.avatarOcrHit, 60)})`; } if (reason === 'auto_manual_detected' && trigger.spamSummary) { blockNote += `(${truncateBlockContextText(trigger.spamSummary, 60)})`; } return { blockReason: reason, blockNote }; } function buildChainBlockNote(chainSources, context = {}) { const handle = context.authorHandle ? `@${context.authorHandle}` : '某用户'; const blockReason = chainSources.length === 1 ? `chain_${chainSources[0]}` : 'chain_mixed'; const blockNote = `九族·${formatChainSourcesLabel(chainSources)} ${handle} 的推文${formatTweetContextSuffix(context)}`.trim(); return { blockReason, blockNote }; } function getTweetContextFromTarget(targetArticle, authorHandle) { const tweetTextEl = targetArticle?.querySelector?.('[data-testid="tweetText"]'); const statusLink = targetArticle ? Array.from(targetArticle.querySelectorAll('a')).find(a => /\/status\/\d+/.test(a.href)) : null; const tweetId = statusLink?.href.match(/\/status\/(\d+)/)?.[1] || null; const handle = authorHandle || getScreenNameFromProfileHref(statusLink?.href) || ''; const tweetUrl = tweetId && handle ? `https://x.com/${handle}/status/${tweetId}` : (statusLink?.href?.split('?')[0] || ''); return { tweetId, tweetUrl, tweetText: truncateBlockContextText(tweetTextEl?.textContent?.trim() || ''), authorHandle: handle }; } function createQueueEntryFromUser(userResult, chainSources, context) { const meta = buildChainBlockNote(chainSources, context); return { userId: userResult.rest_id, screenName: userResult.core?.screen_name || userResult.legacy?.screen_name, userNameText: userResult.core?.name || userResult.legacy?.name, chainSources: [...chainSources], sourceTweetId: context.tweetId || null, sourceTweetUrl: context.tweetUrl || '', sourceTweetText: truncateBlockContextText(context.tweetText), sourceAuthorHandle: context.authorHandle || '', blockReason: meta.blockReason, blockNote: meta.blockNote }; } function mergeQueueEntries(existingEntry, incomingEntry, context) { const chainSources = [...new Set([...(existingEntry.chainSources || []), ...(incomingEntry.chainSources || [])])]; const meta = buildChainBlockNote(chainSources, context); return { ...existingEntry, ...incomingEntry, chainSources, blockReason: meta.blockReason, blockNote: meta.blockNote }; } function createAuthorLogEntry(authorId, authorHandle, authorUserNameText, trigger, context) { const meta = buildAuthorBlockNote(trigger, context); return { userId: authorId, screenName: authorHandle, userNameText: authorUserNameText, blockTimestamp: Date.now(), sourceTweetId: context.tweetId || null, sourceTweetUrl: context.tweetUrl || '', sourceTweetText: truncateBlockContextText(context.tweetText), sourceAuthorHandle: context.authorHandle || authorHandle, ...meta }; } function addUsersToChainQueue(queueById, users, chainSource, context) { users.forEach((userResult) => { if (!userResult?.rest_id) return; const incoming = createQueueEntryFromUser(userResult, [chainSource], context); const existing = queueById.get(userResult.rest_id); queueById.set(userResult.rest_id, existing ? mergeQueueEntries(existing, incoming, context) : incoming); }); } 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 promoSearchTerm = document.getElementById('nuke-promo-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() || ''; const blockNote = user.blockNote?.toLowerCase() || ''; const blockReason = user.blockReason?.toLowerCase() || ''; const sourceTweetText = user.sourceTweetText?.toLowerCase() || ''; const sourceNote = user.sourceNote?.toLowerCase() || ''; return userId.includes(term) || screenName.includes(term) || userNameText.includes(term) || blockNote.includes(term) || blockReason.includes(term) || sourceTweetText.includes(term) || sourceNote.includes(term); }; const renderList = (containerSelector, list, type) => { const container = document.querySelector(containerSelector); if (!container) return; const searchTerm = type === 'log' ? logSearchTerm : (type === 'promo' ? promoSearchTerm : whitelistSearchTerm); const filteredList = list.filter(user => filterUsers(user, searchTerm)); container.innerHTML = ''; if (filteredList.length === 0) { const emptyMessages = { log: '暂无拉黑记录', whitelist: '白名单为空', promo: '暂无引流目标' }; const message = searchTerm ? '没有找到匹配的用户' : (emptyMessages[type] || '列表为空'); 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() : '未知时间'; const blockReasonHTML = entry.blockNote ? `<span class="nuke-list-block-reason">${escapeHtml(entry.blockNote)}</span>` : ''; el.innerHTML = `<div class="nuke-list-user-info">${userLinkHTML}<span class="nuke-list-user-handle" title="移至白名单并取消拉黑">${screenNameHandle}</span>${blockReasonHTML}</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 if (type === 'promo') { const timestamp = entry.addedAt ? new Date(entry.addedAt).toLocaleString() : '未知时间'; const noteHTML = entry.sourceNote ? `<span class="nuke-list-block-reason">${escapeHtml(entry.sourceNote)}</span>` : ''; el.innerHTML = `<div class="nuke-list-user-info">${userLinkHTML}<span class="nuke-list-user-handle">${screenNameHandle}</span>${noteHTML}</div><span class="nuke-list-actions" title="从引流目标列表移除">移除</span>`; el.querySelector('.nuke-list-actions')?.addEventListener('click', async () => { const data = await loadUserData(); if (!data?.promoTargets) return; data.promoTargets = data.promoTargets.filter((e) => normalizePromoHandle(e.screenName) !== normalizePromoHandle(entry.screenName)); await saveUserData(data); const textarea = document.getElementById('nuke-promo-targets-textarea'); if (textarea) textarea.value = data.promoTargets.map((e) => e.screenName).join('\n'); await renderListsInPanel(); }); } 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'); renderList('#nuke-promo-content .nuke-list', userData.promoTargets || [], 'promo'); } 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} }, Favoriters: { hash: 'SoWvHOdzCsomAQdY-bFNDA', features: {"rweb_video_screen_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"responsive_web_profile_redirect_enabled":false,"rweb_tipjar_consumption_enabled":false,"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":false,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"responsive_web_grok_annotations_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,"content_disclosure_indicator_enabled":true,"content_disclosure_ai_generated_indicator_enabled":true,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":true,"post_ctas_fetch_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":false,"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} 的数据`); } function getFollowersCountFromUserResult(userResult) { const legacyCount = userResult?.legacy?.followers_count; if (typeof legacyCount === 'number' && !Number.isNaN(legacyCount)) return legacyCount; const directCount = userResult?.followers_count; if (typeof directCount === 'number' && !Number.isNaN(directCount)) return directCount; return null; } function matchesStandardKeywords(userNameText, patterns) { if (!userNameText || !patterns?.length) return false; return patterns.some((pattern) => { if (!pattern) return false; try { return new RegExp(pattern, 'i').test(userNameText); } catch (e) { return userNameText.toLowerCase().includes(String(pattern).toLowerCase()); } }); } function matchesBuiltInDisplayNameSpam(userNameText) { const normalized = String(userNameText || '').replace(/\s+/g, '').replace(/[^\u4e00-\u9fffa-z0-9]/gi, '').toLowerCase(); if (!normalized) return false; return /找个(?:搭子|单男)$/.test(normalized) || /附近的(?:dd|来)$/.test(normalized) || (/同城/.test(normalized) && /[上丄]门/.test(normalized) && /附近/.test(normalized)) || /裸聊/.test(normalized) || (/小姨子/.test(normalized) && /找姐夫/.test(normalized)) || /无线下$/.test(normalized); } function getUsernameRuleFollowerExemptThreshold() { return scriptConfig.usernameRuleFollowerExemptThreshold ?? DEFAULT_USERNAME_RULE_FOLLOWER_EXEMPT_THRESHOLD; } function isBlueVerifiedExemptEnabled() { return scriptConfig.blueVerifiedExemptEnabled !== false; } function isFollowerCountExempt(followerCount) { if (followerCount == null || Number.isNaN(followerCount)) return false; return followerCount > getUsernameRuleFollowerExemptThreshold(); } function getAutoBlockDecision(userNameText, followerCount) { const exemptThreshold = getUsernameRuleFollowerExemptThreshold(); const keywordMatch = matchesStandardKeywords(userNameText, scriptConfig.blockKeywordsStandard || []); const builtInDisplayNameMatch = matchesBuiltInDisplayNameSpam(userNameText); if (!keywordMatch && !builtInDisplayNameMatch) return { block: false, reason: 'no_match' }; if (followerCount == null || Number.isNaN(followerCount)) { if (builtInDisplayNameMatch) return { block: true, reason: 'display_name_spam' }; return { block: false, reason: 'follower_unknown' }; } if (followerCount <= exemptThreshold) return { block: true, reason: builtInDisplayNameMatch ? 'display_name_spam' : 'standard_keywords', followerCount, exemptThreshold }; return { block: false, reason: 'follower_exempt', followerCount, exemptThreshold }; } function getScreenNameFromProfileHref(href) { if (!href) return ''; try { const pathname = new URL(href, window.location.origin).pathname; return pathname.split('/').filter(Boolean)[0] || ''; } catch { return href.split('/').pop()?.split('?')[0] || ''; } } function getArticleAuthorScreenName(article) { const userLink = article?.querySelector('div[data-testid="User-Name"] a[role="link"]'); return getScreenNameFromProfileHref(userLink?.href); } function isTwitterBlueColor(value) { const text = String(value || '').trim().toLowerCase(); if (!text || text === 'none' || text === 'currentcolor') return false; if (text === '#1d9bf0' || text === 'rgb(29, 155, 240)' || text === 'rgba(29, 155, 240, 1)') return true; const match = text.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (!match) return false; const [, r, g, b] = match.map(Number); return Math.abs(r - 29) <= 8 && Math.abs(g - 155) <= 10 && Math.abs(b - 240) <= 10; } function isBlueVerifiedUserElement(userElement) { if (!isBlueVerifiedExemptEnabled() || !userElement) return false; const candidates = [userElement, ...userElement.querySelectorAll('[aria-label], [data-testid], svg, path')]; return candidates.some((node) => { const label = String(node.getAttribute?.('aria-label') || node.parentElement?.getAttribute?.('aria-label') || '').toLowerCase(); const testId = String(node.getAttribute?.('data-testid') || node.parentElement?.getAttribute?.('data-testid') || '').toLowerCase(); const hasVerifiedSignal = /verified|认证|已认证/.test(label) || /verified/.test(testId); if (!hasVerifiedSignal) return false; const style = getComputedStyle(node); const parentStyle = node.parentElement ? getComputedStyle(node.parentElement) : null; return [style.color, style.fill, style.stroke, parentStyle?.color, parentStyle?.fill, parentStyle?.stroke].some(isTwitterBlueColor); }); } function isArticleBlueVerified(article) { return isBlueVerifiedUserElement(article?.querySelector('div[data-testid="User-Name"]')); } async function getCachedFollowerCount(screenName) { if (!screenName) return null; const key = screenName.toLowerCase(); const cached = followerCountCache.get(key); if (cached && Date.now() - cached.at < FOLLOWER_COUNT_CACHE_MS) return cached.count; if (followerFetchPending.has(key)) return withFollowerCountTimeout(followerFetchPending.get(key)); const pending = (async () => { try { const userResult = await getUserDataByScreenName(screenName); const count = getFollowersCountFromUserResult(userResult); followerCountCache.set(key, { count, at: Date.now() }); return count; } catch (error) { console.warn(`[CB] 无法获取 @${screenName} 的粉丝数`, error); return null; } finally { followerFetchPending.delete(key); } })(); followerFetchPending.set(key, pending); return withFollowerCountTimeout(pending); } function withFollowerCountTimeout(promise) { return Promise.race([ promise, new Promise((resolve) => window.setTimeout(() => resolve(null), FOLLOWER_COUNT_LOOKUP_TIMEOUT_MS)) ]); } async function shouldExemptArticleByFollowerCount(article, reason) { const screenName = getArticleAuthorScreenName(article); if (!screenName) return false; const followerCount = await getCachedFollowerCount(screenName); if (!isFollowerCountExempt(followerCount)) return false; console.log(`[CB] 跳过${reason || '自动标记'} @${screenName} (粉丝数 ${followerCount} 高于阈值 ${getUsernameRuleFollowerExemptThreshold()})`); return true; } function shouldExemptArticleByBlueVerified(article, reason) { if (!isStatusRootTweetArticle(article)) return false; if (!isArticleBlueVerified(article)) return false; const screenName = getArticleAuthorScreenName(article) || '未知'; console.log(`[CB] 跳过${reason || '自动标记'} @${screenName} (蓝 V 主贴作者自动豁免)`); return true; } async function shouldExemptArticleByTrustedAuthor(article, reason) { if (shouldExemptArticleByBlueVerified(article, reason)) return true; return shouldExemptArticleByFollowerCount(article, reason); } async function maybeAutoBlockTarget(targetArticle, userNameText, screenName) { if (!userNameText) return; if (!isAutoNukeEnabled()) return; if (shouldExemptArticleByBlueVerified(targetArticle, 'auto_rule')) return; const decision = await evaluateUsernameAutoBlock(userNameText, screenName); if (!decision.block) { if (decision.reason === 'follower_exempt') { console.log(`[CB] 跳过常规用户名规则自动拉黑 @${screenName || '未知'} (粉丝数高于阈值 ${getUsernameRuleFollowerExemptThreshold()})`); } return; } if (screenName) { showAggregatedToast('nuke-auto-trigger-toast', '🤖 自动执行拉黑', `检测到可疑用户名: ${screenName}`, 4000); } void initiateNukeProcess(targetArticle, { triggerMode: 'auto', autoReason: decision.reason, suspiciousDisplayName: userNameText }); } 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 getFavoritersData(tweetId, onProgress) { let users = new Map(), cursor = null, endpoint = API_ENDPOINTS.Favoriters; do { onProgress(`正在获取点赞列表...(已找到: ${users.size})`); const url = `https://x.com/i/api/graphql/${endpoint.hash}/Favoriters?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?.favoriters_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; let foundNewUsersInPage = false; 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); foundNewUsersInPage = true; } } } } 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); foundNewUsersInPage = true; } } else if (entry.entryId.startsWith('cursor-bottom-')) { nextCursor = entry.content.value; } } if (cursor === nextCursor || !foundNewUsersInPage) 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 = []; if (!Array.isArray(userData.promoTargets)) userData.promoTargets = []; if (userData.spamIdentifyLog) { delete userData.spamIdentifyLog; allData[currentUserId] = userData; await GM_setValue(STORAGE_KEY, allData); } 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 layoutToasts() { Array.from(document.querySelectorAll('.nuke-toast:not(.fading-out)')).forEach((toast, index) => { toast.style.top = `${20 + index * 70}px`; }); } 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); } if (toast._nukeToastTimer) clearTimeout(toast._nukeToastTimer); if (toast._nukeToastRemoveTimer) clearTimeout(toast._nukeToastRemoveTimer); toast.classList.remove('fading-out'); toast.innerHTML = `<div class="nuke-toast-title">${title}</div><div class="nuke-toast-status">${status}</div>`; layoutToasts(); if (duration) { toast._nukeToastTimer = setTimeout(() => { toast.classList.add('fading-out'); toast._nukeToastRemoveTimer = setTimeout(() => { toast.remove(); layoutToasts(); }, 500); }, duration); } } function stripToastHtml(status) { const div = document.createElement('div'); div.innerHTML = String(status || ''); return div.textContent?.replace(/\s+/g, ' ').trim() || ''; } function showAggregatedToast(id, title, status, duration = 4000) { const now = Date.now(); const state = aggregatedToastState.get(id) || { count: 0, lines: [], startedAt: now }; if (now - state.startedAt > 15000) { state.count = 0; state.lines = []; state.startedAt = now; } state.count += 1; const line = stripToastHtml(status); if (line) state.lines = [line, ...state.lines.filter((item) => item !== line)].slice(0, 5); aggregatedToastState.set(id, state); const linesHtml = state.lines.map((item) => `<div class="nuke-aggregated-toast-line">${escapeHtml(item)}</div>`).join(''); showToast(id, title, `<div class="nuke-aggregated-toast-summary">本轮 ${state.count} 条操作</div>${linesHtml}`, 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); } function closeMenuFromEvent(event) { const target = event?.target; if (!target || typeof target.closest !== 'function') return false; const dropdownRoot = target.closest('div[data-testid="Dropdown"]') || target.closest('[data-testid="Dropdown"]'); const menuNode = target.closest('div[role="menu"]') || target.closest('[role="menu"]'); const removableContainer = dropdownRoot?.parentElement || menuNode?.parentElement; if (menuNode) { const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }); menuNode.dispatchEvent(escapeEvent); document.dispatchEvent(escapeEvent); if (removableContainer) { window.setTimeout(() => { if (menuNode.isConnected && removableContainer.isConnected) { removableContainer.remove(); } }, 120); } return true; } if (removableContainer) { removableContainer.remove(); return true; } return false; } function showVerificationModal(userNameText) { closeDialogSurface(document.getElementById('nuke-verify-modal')); const modal = document.createElement('dialog'); modal.id = 'nuke-verify-modal'; modal.className = 'nuke-verify-modal'; modal.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"> <p class="nuke-verify-note">这是 scraper 抓到的用户名,可直接复制后用于关键词设置。</p> <textarea class="nuke-verify-textarea" readonly></textarea> <div class="nuke-config-button-container"> <button class="nuke-config-button copy" type="button">复制用户名</button> </div> </div>`; modal.tabIndex = -1; const closeModal = () => closeDialogSurface(modal); const textarea = modal.querySelector('.nuke-verify-textarea'); textarea.value = userNameText; modal.querySelector('.nuke-close-button').addEventListener('click', closeModal); modal.querySelector('.nuke-config-button.copy').addEventListener('click', async () => { try { await navigator.clipboard.writeText(userNameText); showToast('nuke-verify-copy-toast', '已复制', '用户名已复制到剪贴板', 2000); } catch (error) { console.warn('[CB] Failed to copy verified username:', error); textarea.focus(); textarea.select(); showToast('nuke-verify-copy-toast', '复制失败', '已为你选中文本,可手动复制', 2500); } }); modal.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModal(); }); initializeDialogSurface(modal, { initialFocusSelector: '.nuke-verify-textarea', selectInitialText: true }); } async function copyTextToClipboard(text) { if (!text) return false; try { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { await navigator.clipboard.writeText(text); return true; } } catch (error) { console.warn('[CB] Clipboard API copy failed:', error); } try { const fallback = document.createElement('textarea'); fallback.value = text; fallback.setAttribute('readonly', 'readonly'); fallback.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;pointer-events:none;'; document.body.appendChild(fallback); fallback.focus(); fallback.select(); const copied = document.execCommand('copy'); fallback.remove(); return copied; } catch (error) { console.warn('[CB] execCommand copy failed:', error); return false; } } async function handleVerifiedUserName(userNameText) { const copied = await copyTextToClipboard(userNameText); if (copied) { showToast('nuke-verify-copy-toast', '用户名已复制', `已复制: ${userNameText}`, 2600); return; } showVerificationModal(userNameText); } // --- CORE LOGIC --- async function processQueue() { if (isProcessingQueue || manualDetectedNukeRunning || !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(), blockNote: userToBlock.blockNote || '', blockReason: userToBlock.blockReason || '' }); 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 getArticleAuthorHandle(article) { const userLink = article?.querySelector?.('div[data-testid="User-Name"] a[role="link"]'); return normalizePromoHandle(getScreenNameFromProfileHref(userLink?.href) || userLink?.href?.split('/')?.pop()?.split('?')?.[0] || ''); } function getStatusRootTweetArticle() { if (window.location.pathname.split('/')[2] !== 'status') return null; markStatusRootTweetArticles(); const articles = Array.from(document.querySelectorAll('[data-testid="primaryColumn"] article[data-testid="tweet"], article[data-testid="tweet"]')); return articles.find((article) => article.dataset.cbSpamRootTweet === 'true') || articles[0] || null; } function getRootTweetAuthorHandle() { return getArticleAuthorHandle(getStatusRootTweetArticle()); } function getChainExemptHandlesForTarget(targetArticle) { const rootAuthorHandle = getRootTweetAuthorHandle(); const targetAuthorHandle = getArticleAuthorHandle(targetArticle); return rootAuthorHandle && rootAuthorHandle !== targetAuthorHandle ? [rootAuthorHandle] : []; } function buildChainSkipUserIds(resolvedTarget) { const ids = new Set(); if (resolvedTarget?.authorId) ids.add(resolvedTarget.authorId); if (resolvedTarget?.rootAuthorId && resolvedTarget.rootAuthorId !== resolvedTarget.authorId) ids.add(resolvedTarget.rootAuthorId); return ids; } function mergeUserIdSets(sets = []) { const merged = new Set(); sets.forEach((set) => { if (!set) return; Array.from(set).forEach((id) => { if (id) merged.add(id); }); }); return merged; } function trimBlockedLogToLimit(userData) { const limit = scriptConfig.blockLogLimit || 500; if (limit > 0) { while (userData.blockedLog.length > limit) userData.blockedLog.shift(); } } async function resolveNukeTarget(targetArticle, trigger) { const userLink = targetArticle.querySelector('div[data-testid="User-Name"] a[role="link"]'); const authorHandle = getArticleAuthorHandle(targetArticle) || getScreenNameFromProfileHref(userLink?.href) || userLink?.href.split('/').pop()?.split('?')[0]; const authorUserNameText = targetArticle.querySelector('div[data-testid="User-Name"] a[role="link"] span')?.textContent?.trim() || authorHandle; if (!authorHandle) throw new Error("无法确定作者 handle"); const tweetContext = getTweetContextFromTarget(targetArticle, authorHandle); const rootAuthorHandle = getRootTweetAuthorHandle(); let authorId = null; let rootAuthorId = null; try { const authorData = await getUserDataByScreenName(authorHandle); authorId = authorData?.rest_id || null; if (!authorId) throw new Error(`无法获取 @${authorHandle} 的用户ID`); } catch (authorError) { console.error(`[CB] 获取作者 @${authorHandle} 失败:`, authorError); } if (rootAuthorHandle && rootAuthorHandle === authorHandle) { rootAuthorId = authorId; } else if (rootAuthorHandle) { try { const rootAuthorData = await getUserDataByScreenName(rootAuthorHandle); rootAuthorId = rootAuthorData?.rest_id || null; } catch (rootAuthorError) { console.warn(`[CB] 获取主贴作者 @${rootAuthorHandle} 失败,将仅按 handle 豁免`, rootAuthorError); } } return { targetArticle, trigger, authorHandle, authorUserNameText, tweetContext, authorId, rootAuthorHandle, rootAuthorId, engagementCounts: getArticleEngagementCounts(targetArticle) }; } async function blockResolvedNukeAuthor(resolvedTarget, userData, whitelistIds, exemptHandles) { const { authorId, authorHandle, authorUserNameText, trigger, tweetContext } = resolvedTarget; if (!authorId) { console.error(`[CB] 拉黑作者 @${authorHandle} 失败:`, new Error("无法获取作者用户ID")); return false; } if (whitelistIds.has(authorId) || exemptHandles.includes(authorHandle)) { showToast('nuke-fetch-toast', '🛡️ 用户在白名单或豁免列表', `已跳过拉黑 @${authorHandle}`, 4000); return false; } await blockUserById(authorId); userData.blockedLog.push(createAuthorLogEntry(authorId, authorHandle, authorUserNameText, trigger, tweetContext)); trimBlockedLogToLimit(userData); showToast('nuke-fetch-toast', '✅ 作者已拉黑并记录', `已立刻拉黑 @${authorHandle}`, 2000); return true; } async function collectChainUsersForResolvedTarget(resolvedTarget, queueById, onCollectProgress) { const { authorId, tweetContext } = resolvedTarget; const tweetId = tweetContext.tweetId; if (!tweetId) return 0; const beforeSize = queueById.size; const favoritersPromise = getFavoritersData(tweetId, onCollectProgress).catch(error => { console.warn('[CB] 获取点赞列表失败,将跳过点赞关联用户', error); return []; }); const [retweeters, repliers, favoriters] = await Promise.all([ getRetweetersData(tweetId, onCollectProgress), getRepliersData(tweetId, onCollectProgress), favoritersPromise ]); addUsersToChainQueue(queueById, retweeters, 'retweet', tweetContext); addUsersToChainQueue(queueById, repliers, 'reply', tweetContext); addUsersToChainQueue(queueById, favoriters, 'like', tweetContext); if (authorId) queueById.delete(authorId); return Math.max(0, queueById.size - beforeSize); } function selectNewChainQueueEntries(userData, queueById, whitelistIds, exemptHandles, skipUserIds = new Set()) { const existingUserIds = new Set([...userData.queue.map(u => u.userId), ...userData.blockedLog.map(u => u.userId), ...whitelistIds, ...skipUserIds]); const exemptHandleSet = new Set((exemptHandles || []).map(normalizePromoHandle).filter(Boolean)); return Array.from(queueById.values()).filter(u => u.userId && u.userId !== currentUserId && !existingUserIds.has(u.userId) && !exemptHandleSet.has(normalizePromoHandle(u.screenName))); } function addNewChainQueueEntries(userData, queueById, whitelistIds, exemptHandles, skipUserIds = new Set()) { const newUsersToQueue = selectNewChainQueueEntries(userData, queueById, whitelistIds, exemptHandles, skipUserIds); if (newUsersToQueue.length > 0) userData.queue.push(...newUsersToQueue); return newUsersToQueue; } async function initiateNukeProcess(targetArticle, trigger = { triggerMode: 'manual' }) { showToast('nuke-fetch-toast', '🚀 九族拉黑已启动', '正在处理...', null); hideElement(targetArticle); try { const userData = await loadUserData(); if (!userData) throw new Error("无法加载用户数据"); const whitelistIds = new Set(userData.whitelist.map(u => u.userId)); const resolvedTarget = await resolveNukeTarget(targetArticle, trigger); await blockResolvedNukeAuthor(resolvedTarget, userData, whitelistIds, []); await saveUserData(userData); const chainExemptHandles = getChainExemptHandlesForTarget(resolvedTarget.targetArticle); await processPromoMentionsFromArticle(targetArticle, resolvedTarget.tweetContext, userData, resolvedTarget.authorHandle, whitelistIds, chainExemptHandles); if (!resolvedTarget.tweetContext.tweetId) return; const onCollectProgress = status => showToast('nuke-fetch-toast', '收集中...', status, null); const queueById = new Map(); await collectChainUsersForResolvedTarget(resolvedTarget, queueById, onCollectProgress); const skipUserIds = buildChainSkipUserIds(resolvedTarget); const newUsersToQueue = addNewChainQueueEntries(userData, queueById, whitelistIds, chainExemptHandles, skipUserIds); if (newUsersToQueue.length > 0) { await saveUserData(userData); showToast('nuke-fetch-toast', '✅ 操作成功', `已将 ${newUsersToQueue.length} 个相关用户加入拉黑队列。`, 4000); } else { showToast('nuke-fetch-toast', 'ℹ️ 操作完成', `没有找到新的可拉黑用户。`, 4000); } await updateStatusToast(); setTimeout(processQueue, 1000); } catch (error) { console.error("[CB] 收集过程中发生错误:", error); showToast(`nuke-fetch-toast`, '❌ 发生错误', error.message, 5000); } } // --- UI SCANNING & AUTOMATION --- function getUsernameFromElement(element) { if (!element) return ''; const clone = element.cloneNode(true); clone.querySelectorAll('img[alt]').forEach(img => { img.replaceWith(document.createTextNode(img.alt)); }); return clone.textContent.trim(); } function getDisplayNameFromUserLink(userLink) { if (!userLink) return ''; const candidates = [ userLink.querySelector(':scope > div > div:first-child'), userLink.querySelector('div[dir="ltr"]'), userLink.querySelector('span') ]; for (const el of candidates) { const text = getUsernameFromElement(el); if (text) return text; } const raw = getUsernameFromElement(userLink); if (!raw) return ''; const handle = getScreenNameFromProfileHref(userLink.href); if (!handle) return raw; return raw.replace(new RegExp(`@?${handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i'), '').trim() || raw; } async function evaluateUsernameAutoBlock(userNameText, screenName) { const needsFollowerCheck = matchesStandardKeywords(userNameText, scriptConfig.blockKeywordsStandard || []) || matchesBuiltInDisplayNameSpam(userNameText); let followerCount = null; if (needsFollowerCheck && screenName) followerCount = await getCachedFollowerCount(screenName); return getAutoBlockDecision(userNameText, followerCount); } function getAutoBlockRuleLabel(reason) { if (reason === 'standard_keywords') return '用户名关键词'; if (reason === 'display_name_spam') return '昵称引流'; if (reason === 'promo_target_mention') return '引流目标'; return '自动规则'; } async function processAutoBlockArticle(article, userData) { if (article.dataset.autoblockTriggered === 'true') return; const userLink = article.querySelector('div[data-testid="User-Name"] a[role="link"]'); const userNameText = getDisplayNameFromUserLink(userLink); const screenName = getScreenNameFromProfileHref(userLink?.href); const tweetText = getTweetTextFromArticle(article); if (shouldExemptArticleByBlueVerified(article, 'auto_rule')) { article.dataset.autoblockChecked = 'complete'; return; } if (userNameText) { const decision = await evaluateUsernameAutoBlock(userNameText, screenName); if (decision.block) { const label = getAutoBlockRuleLabel(decision.reason); if (!isAutoNukeEnabled()) { markArticleForAutoRule(article, label, `命中${label}: ${userNameText}`); article.dataset.autoblockChecked = 'complete'; return; } if (screenName) { showAggregatedToast('nuke-auto-trigger-toast', '🤖 自动执行拉黑', `检测到可疑用户名: ${screenName}`, 4000); } triggerAutoNukeForMarkedArticle(article, { triggerMode: 'auto', autoReason: decision.reason, suspiciousDisplayName: userNameText }); return; } if (decision.reason === 'follower_exempt') { console.log(`[CB] 跳过auto_rule @${screenName || '未知'} (粉丝数 ${decision.followerCount} 高于阈值 ${decision.exemptThreshold})`); } } if (tweetText && userData?.promoTargets?.length) { const matched = getMatchedPromoTargetInTweet(tweetText, userData.promoTargets); if (matched) { if (!isAutoNukeEnabled()) { markArticleForAutoRule(article, `引流目标 @${matched}`, `推文提及引流目标 @${matched}`); article.dataset.autoblockChecked = 'complete'; return; } triggerAutoNukeForMarkedArticle(article, { triggerMode: 'auto', autoReason: 'promo_target_mention', promoTargetHandle: matched }); return; } } const waitingForTweet = Boolean(userData?.promoTargets?.length) && !tweetText; const waitingForName = Boolean(userLink) && !userNameText; if (!waitingForTweet && !waitingForName) { article.dataset.autoblockChecked = 'complete'; } } 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) return; markStatusRootTweetArticles(); void loadUserData().then((userData) => { if (!userData) return; document.querySelectorAll('article[data-testid="tweet"]:not([data-autoblock-checked])').forEach((article) => { void processAutoBlockArticle(article, userData); }); }); if (!isAutoNukeEnabled()) return; document.querySelectorAll('div[data-testid="UserCell"]:not([data-autoblock-checked])').forEach(cell => { cell.dataset.autoblockChecked = 'true'; const userLink = cell.querySelector('a[role="link"]'); const userNameText = getDisplayNameFromUserLink(userLink); const screenName = getScreenNameFromProfileHref(userLink?.href) || cell.querySelector('a[role="link"] span')?.textContent.trim() || ''; void maybeAutoBlockTarget(cell.closest('div[data-testid="cellInnerDiv"]'), userNameText, screenName); }); ensureManualDetectedNukeButton(); } 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 svgIcon = nukeButton.querySelector('svg'); if (svgIcon) { svgIcon.innerHTML = `<g><path d="${NUKE_ICON_PATH}" fill="currentColor"></path></g>`; svgIcon.style.color = 'rgb(244, 33, 46)'; } nukeButton.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); closeMenuFromEvent(e); if (activeTweetArticle) initiateNukeProcess(activeTweetArticle, { triggerMode: 'manual' }); }); 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); } function addVerificationButton(menuNode) { if (menuNode.querySelector('.nuke-verify-button')) return; const nukeButton = menuNode.querySelector('.nuke-button'); if (!nukeButton) return; const verifyButton = nukeButton.cloneNode(true); verifyButton.classList.remove('nuke-button'); verifyButton.classList.add('nuke-verify-button'); const span = verifyButton.querySelector('span'); if (span) { span.textContent = "🔍 验证用户名"; span.style.color = 'rgb(29, 155, 240)'; } const svgIcon = verifyButton.querySelector('svg'); if (svgIcon) { const searchIconPath = "M10.25 3.75c-3.59 0-6.5 2.91-6.5 6.5s2.91 6.5 6.5 6.5c1.62 0 3.1-.59 4.25-1.57l3.44 3.44c.29.29.77.29 1.06 0s.29-.77 0-1.06l-3.44-3.44c.98-1.15 1.57-2.63 1.57-4.25 0-3.59-2.91-6.5-6.5-6.5zm-6.5 1.5c2.69 0 4.9 2.21 4.9 4.9s-2.21 4.9-4.9 4.9-4.9-2.21-4.9-4.9 2.21-4.9 4.9-4.9z"; svgIcon.innerHTML = `<g><path d="${searchIconPath}" fill="currentColor"></path></g>`; svgIcon.style.color = 'rgb(29, 155, 240)'; } verifyButton.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); closeMenuFromEvent(e); if (activeTweetArticle) { const userLink = activeTweetArticle.querySelector('div[data-testid="User-Name"] a[role="link"]'); const userNameText = getDisplayNameFromUserLink(userLink); if (userNameText) { window.setTimeout(() => { handleVerifiedUserName(userNameText); }, 180); } else { showToast('nuke-verify-missing-toast', '无法获取用户名', '这条推文里没有抓到可用的用户名文本', 2500); } } }); nukeButton.before(verifyButton); } function addSpamInspectButton(menuNode) { if (menuNode.querySelector('.nuke-spam-inspect-button')) return; const verifyButton = menuNode.querySelector('.nuke-verify-button'); if (!verifyButton) return; const inspectButton = verifyButton.cloneNode(true); inspectButton.classList.remove('nuke-verify-button'); inspectButton.classList.add('nuke-spam-inspect-button'); const span = inspectButton.querySelector('span'); if (span) { span.textContent = '🔍 检测引流推文'; span.style.color = 'rgb(255, 173, 31)'; } const svgIcon = inspectButton.querySelector('svg'); if (svgIcon) svgIcon.style.color = 'rgb(255, 173, 31)'; inspectButton.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); closeMenuFromEvent(e); if (activeTweetArticle) window.setTimeout(() => inspectTweetArticleForSpam(activeTweetArticle), 120); }); verifyButton.after(inspectButton); } function onCbSpamProbeRequest(event) { const detail = event?.detail || {}; if (detail.action === 'openConfig') { void showConfigPanel(); return; } if (detail.action === 'switchEngine' && detail.engine) { const engine = normalizeAvatarOcrEngine(detail.engine); const selectEl = document.querySelector('#nuke-spam-avatar-ocr-engine'); if (selectEl) selectEl.value = engine; void preloadAvatarOcrEngineForUi(engine); return; } if (detail.action === 'saveEngine' && detail.engine) { scriptConfig.spamAvatarOcrEngine = normalizeAvatarOcrEngine(detail.engine); delete scriptConfig.spamAvatarOcrEnabled; void saveConfig(scriptConfig); return; } if (detail.action === 'manualNukeDetected') { void executeManualNukeForDetectedTargets(); } if (detail.action === 'toastAggregate') { showAggregatedToast('nuke-auto-trigger-toast', '🤖 自动执行拉黑', detail.status || '调试聚合提示', 5000); } } function exposePageSpamProbe() { try { installInternalConfigTrigger(); document.documentElement.dataset.cbSpamProbeReady = '1'; getPageWindow().__cbSpamProbe = { openConfig: () => { document.dispatchEvent(new CustomEvent('cb-spam-probe', { detail: { action: 'openConfig' } })); }, switchEngine: (engine) => { document.dispatchEvent(new CustomEvent('cb-spam-probe', { detail: { action: 'switchEngine', engine } })); }, saveEngine: (engine) => { document.dispatchEvent(new CustomEvent('cb-spam-probe', { detail: { action: 'saveEngine', engine } })); }, manualNukeDetected: () => { document.dispatchEvent(new CustomEvent('cb-spam-probe', { detail: { action: 'manualNukeDetected' } })); }, toastAggregate: (status) => { document.dispatchEvent(new CustomEvent('cb-spam-probe', { detail: { action: 'toastAggregate', status } })); } }; } catch { /* ignore */ } } // --- INITIALIZATION & EXECUTION --- async function initialize() { console.log("[Chain Blocker] Initializing..."); if (!handleUserscriptBuildRerun()) return; await loadConfig(); try { delete document.documentElement.dataset.cbSpamOcrLastError; delete document.documentElement.dataset.cbSpamOcrUiState; delete document.documentElement.dataset.cbSpamOcrUiProgress; } catch { /* ignore */ } exposePageSpamProbe(); 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(); ensureManualDetectedNukeButton(); if (shouldShowDebugConfigTrigger()) { document.documentElement.dataset.cbSpamDebugMode = '1'; } else { delete document.documentElement.dataset.cbSpamDebugMode; 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 => { let shouldScanSpam = false; 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); addVerificationButton(menu); addSpamInspectButton(menu); } if (node.matches?.('article[data-testid="tweet"]') || node.querySelector?.('article[data-testid="tweet"]')) { shouldScanSpam = true; } } }); } } if (shouldScanSpam) { scheduleSpamRescanDebounced(); scheduleAutoBlockRescanDebounced(); } }); let autoBlockScanDebounceId = null; function scheduleAutoBlockRescanDebounced() { if (autoBlockScanDebounceId) clearTimeout(autoBlockScanDebounceId); autoBlockScanDebounceId = window.setTimeout(() => { autoBlockScanDebounceId = null; scanAndProcessContent(); }, 120); } document.addEventListener('click', e => { const optionsButton = e.target.closest('button[data-testid="caret"]'); if (optionsButton) activeTweetArticle = optionsButton.closest('article[data-testid="tweet"]'); const expandControl = e.target.closest('[role="button"], button, a, div[tabindex="0"]'); if (expandControl && isSpamSectionExpandControl(expandControl)) scheduleSpamRescan(); }, true); observer.observe(document.body, { childList: true, subtree: true }); setInterval(() => { scanAndProcessContent(); scanSpamIdentifyContent(); }, AUTO_SCAN_INTERVAL_MS); initialize(); })();