// ==UserScript== // @name B站内容屏蔽(bili_rebuild改) // @namespace dreamcenter // @version 5.0.0 // @description B站评论过滤器 · AI 语义分析 · 可视化配置界面 // @license MIT // @match *://*.bilibili.com/video/* // @match *://*.bilibili.com/opus/* // @match *://space.bilibili.com/* // @match *://t.bilibili.com/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // 以下为 AI API 域名授权(用哪个就保留哪个,需要自定义域名时在此追加一行 @connect) // @connect api.deepseek.com // @connect api.openai.com // @connect api.moonshot.cn // @connect open.bigmodel.cn // @connect dashscope.aliyuncs.com // @connect api.siliconflow.cn // @connect generativelanguage.googleapis.com // @connect localhost // ==/UserScript== (function () { 'use strict'; /* ❗❗ 悬浮窗按钮开关 ❗❗ * true = 显示右下角 FILTER 悬浮按钮 * false = 隐藏悬浮按钮(配置面板将无法从页面打开) * 修改后重新加载脚本即可生效 */ const SHOW_FAB = true; /* ================================================================ * 默认配置(首次使用 / 点击「重置」时恢复) * 日常修改请使用页面右下角的 FILTER 面板 * ================================================================ */ const DEFAULT_CONFIG = { // 文本关键词屏蔽(默认空白,请在面板中添加) banMap: [], // 屏蔽指定用户(默认空白) banUser: [], // 等级屏蔽(level_0~level_6,level_h = 加闪电六级号;默认空白 = 不屏蔽等级) banLevel: [], // 表情屏蔽(表情的 alt 值,F12 检查后复制) banEmoji: [], // 屏蔽 @某人(默认空白) banAt: [], // 屏蔽全部笔记 banExpand: false, // 调试:控制台日志 logMode: false, // 调试:红色标记而不隐藏 redLine: false, // 悬浮按钮(null = 未自定义,使用默认右下角) fabX: null, // 距视口左侧 px fabTop: null, // 跟随屏幕(开启):距视口顶部 px fabDocY: null, // 钉在网页(关闭):文档纵向位置 px fabFollow: false, // true = 跟随屏幕滚动保持可见 / false = 钉在网页内容上随页面移动 // 主题:auto = 跟随系统 / light = 浅色 / dark = 深色 theme: 'auto', // 全局过滤开关(面板可随时切换) enabled: true, // 正则表达式屏蔽(整条评论文本匹配,支持正则语法) banRegex: [], // 按 UID 屏蔽用户(改昵称也不受影响) banUid: [], // ===== AI 语义分析(OpenAI 兼容接口 /chat/completions)===== aiEnabled: false, // AI 分析总开关 aiApiUrl: '', // API 地址(自配置,需 OpenAI 兼容格式) aiApiKey: '', // API Key(仅保存在本地油猴存储) aiModel: '', // 模型名称,如 deepseek-chat / gpt-4o-mini aiUsePrompt: true, // 使用自定义偏好描述 aiPrompt: '', // 自定义描述:你不想看到的内容类型 aiUseBanlist: true, // 结合现有屏蔽词库作为用户偏好参考 aiBatchSize: 20, // 单批最多分析条数(1~100) aiMinScore: 0, // 最低置信度 0~100(0 = 不限制,返回即屏蔽) aiStrictness: 1 // AI 屏蔽强度 0宽松 / 1标准 / 2严格 / 3激进 }; const STORAGE_KEY = 'bili_rebuild_config_v4'; const LEVELS = ['level_0', 'level_1', 'level_2', 'level_3', 'level_4', 'level_5', 'level_6', 'level_h']; /* ================================================================ * 持久化(GM 存储优先,失败则回退 localStorage) * ================================================================ */ function deepClone(o) { return JSON.parse(JSON.stringify(o)); } // 配置字段白名单与类型校验(防止损坏/恶意备份或旧版存储导致脚本异常) const CONFIG_ARRAY_KEYS = ['banMap', 'banUser', 'banLevel', 'banEmoji', 'banAt', 'banRegex', 'banUid']; const CONFIG_STRING_KEYS = ['aiApiUrl', 'aiApiKey', 'aiModel', 'aiPrompt']; const CONFIG_BOOL_KEYS = ['banExpand', 'logMode', 'redLine', 'fabFollow', 'enabled', 'aiEnabled', 'aiUsePrompt', 'aiUseBanlist']; const CONFIG_NUM_KEYS = ['fabX', 'fabTop', 'fabDocY', 'aiBatchSize', 'aiMinScore', 'aiStrictness']; function sanitizeConfig(obj) { const merged = deepClone(DEFAULT_CONFIG); for (const k of CONFIG_ARRAY_KEYS) if (k in obj && Array.isArray(obj[k])) merged[k] = obj[k].filter(v => typeof v === 'string'); for (const k of CONFIG_STRING_KEYS) if (k in obj && typeof obj[k] === 'string') merged[k] = obj[k]; for (const k of CONFIG_BOOL_KEYS) if (k in obj) merged[k] = !!obj[k]; for (const k of CONFIG_NUM_KEYS) if (k in obj && (typeof obj[k] === 'number' || obj[k] === null)) merged[k] = obj[k]; if (obj.theme === 'light' || obj.theme === 'dark' || obj.theme === 'auto') merged.theme = obj.theme; // 数值范围收敛 if (merged.aiBatchSize < 1) merged.aiBatchSize = 1; if (merged.aiBatchSize > 100) merged.aiBatchSize = 100; if (merged.aiMinScore < 0) merged.aiMinScore = 0; if (merged.aiMinScore > 100) merged.aiMinScore = 100; if (merged.aiStrictness < 0) merged.aiStrictness = 0; if (merged.aiStrictness > 3) merged.aiStrictness = 3; return merged; } function loadConfig() { let raw = null; try { if (typeof GM_getValue === 'function') raw = GM_getValue(STORAGE_KEY, null); } catch (e) { } if (!raw) { try { raw = localStorage.getItem(STORAGE_KEY); } catch (e) { } } if (raw) { try { const obj = JSON.parse(raw); return sanitizeConfig(obj); } catch (e) { } } return deepClone(DEFAULT_CONFIG); } function saveConfig() { const raw = JSON.stringify(config); try { if (typeof GM_setValue === 'function') GM_setValue(STORAGE_KEY, raw); } catch (e) { } try { localStorage.setItem(STORAGE_KEY, raw); } catch (e) { } } let config = loadConfig(); /* ================================================================ * 评论过滤引擎(逻辑与原版一致,数据源改为动态 config) * ================================================================ */ const filterFunction = { bySpan(raw) { for (const key of config.banMap) if (raw.includes(key)) return true; }, byImg(raw) { for (const key of config.banEmoji) if (raw.includes(key)) return true; }, byAt(raw) { for (const key of config.banAt) if (raw.includes(key)) return true; }, bySearch(raw) { for (const key of config.banMap) if (raw.includes(key)) return true; } }; /* ========== 正则屏蔽:异步 + 硬超时(防 ReDoS) ========== */ const REGEX_TIMEOUT_MS = 16; // 单轮正则匹配硬超时(毫秒) // 用于正则添加时的耗时基准文本(大量重复字符,放大回溯开销) const BENCH_TEXT = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + 'B站评论过滤基准' + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; // 常见 ReDoS 危险模式预检(嵌套/重叠量词、双重闭合、交替分组量词等) const DANGEROUS_REGEX = /\([^)]*\)[+*][^)]*\)[+*]|\([^)]*\|[^)]*\)[+*]|\+\+|\*\*|\.\*\.\*|\.\*[+*]/; // 正则匹配在 Web Worker 中执行,主线程用定时器硬超时;超时即终止 Worker 重建,并将该轮正则拉黑 const REGEX_WORKER_SRC = ` self.onmessage = function (e) { var d = e.data, matched = false; for (var i = 0; i < d.patterns.length; i++) { try { if (new RegExp(d.patterns[i]).test(d.text)) { matched = true; break; } } catch (err) {} } self.postMessage({ id: d.id, matched: matched }); };`; let regexWorker = null; let regexWorkerBusy = false; let regexWorkerQueue = []; let regexTaskId = 0; let currentRegexTask = null; let currentRegexTaskId = -1; let regexWorkerTimer = null; let regexWorkerDisabled = false; // Worker 不可用(CSP 限制/创建失败/连续超时)时置真,走同步兜底 let regexTimeoutCount = 0; let regexBlacklist = new Set(); // 曾超时的危险正则(会话级跳过,避免反复拖慢) function ensureRegexWorker() { if (regexWorker) return regexWorker; if (regexWorkerDisabled) return null; try { const blob = new Blob([REGEX_WORKER_SRC], { type: 'application/javascript' }); regexWorker = new Worker(URL.createObjectURL(blob)); regexWorker.onmessage = (ev) => { const d = ev.data || {}; if (d.id !== currentRegexTaskId) return; const task = currentRegexTask; currentRegexTask = null; currentRegexTaskId = -1; regexWorkerBusy = false; clearTimeout(regexWorkerTimer); if (task) task.resolve(!!d.matched); pumpRegexQueue(); }; } catch (e) { regexWorker = null; regexWorkerDisabled = true; } return regexWorker; } // Worker 不可用时的同步兜底:整体时间预算 + 单条超时拉黑 + 跳过危险模式,尽量防 ReDoS function testRegexSyncFallback(text, patterns) { const budgetStart = performance.now(); for (const p of patterns) { if (performance.now() - budgetStart > REGEX_TIMEOUT_MS) break; if (DANGEROUS_REGEX.test(p)) { regexBlacklist.add(p); continue; } // 危险模式直接跳过,避免同步卡死 const t0 = performance.now(); let matched = false; try { matched = new RegExp(p).test(text || ''); } catch (e) { } if (performance.now() - t0 > REGEX_TIMEOUT_MS) regexBlacklist.add(p); if (matched) return true; } return false; } function pumpRegexQueue() { if (regexWorkerBusy || !regexWorkerQueue.length) return; const task = regexWorkerQueue.shift(); const patterns = (task.patterns || []).filter(p => !regexBlacklist.has(p)); if (!patterns.length) { task.resolve(false); setTimeout(pumpRegexQueue, 0); return; } regexWorkerBusy = true; currentRegexTask = task; currentRegexTaskId = ++regexTaskId; const w = ensureRegexWorker(); if (!w) { regexWorkerBusy = false; // Worker 不可用(CSP/创建失败):同步兜底匹配(异步调度避免深递归爆栈) task.resolve(testRegexSyncFallback(task.text, patterns)); setTimeout(pumpRegexQueue, 0); return; } w.postMessage({ id: currentRegexTaskId, text: task.text, patterns: patterns }); regexWorkerTimer = setTimeout(() => { const t = currentRegexTask; currentRegexTask = null; currentRegexTaskId = -1; regexWorkerBusy = false; try { regexWorker.terminate(); } catch (e) { } regexWorker = null; // 不在超时处整轮拉黑(避免误伤正常正则);由同步兜底按单条耗时精准拉黑 regexTimeoutCount++; if (regexTimeoutCount >= 3) regexWorkerDisabled = true; if (t) t.resolve(testRegexSyncFallback(t.text, patterns)); setTimeout(pumpRegexQueue, 0); }, REGEX_TIMEOUT_MS); } // 异步测试:文本是否命中任一正则(硬超时,超时视为未命中) function testRegexAsync(text, patterns) { return new Promise((resolve) => { regexWorkerQueue.push({ text: text || '', patterns: patterns || [], resolve }); pumpRegexQueue(); }); } // 同步测试(仅用于添加正则时的耗时基准,不参与评论过滤) function testRegexSync(text, pattern) { const t0 = performance.now(); try { new RegExp(pattern).test(text); } catch (e) { } return performance.now() - t0; } // 待异步正则检测的评论队列(关键词未命中时才入队) const pendingRegexChecks = []; let regexDraining = false; function drainRegexQueue() { if (regexDraining) return; regexDraining = true; const loop = () => { const item = pendingRegexChecks.shift(); if (!item) { regexDraining = false; return; } testRegexAsync(item.text, item.patterns).then(hit => { if (hit) item.apply(); loop(); }); }; loop(); } // 判断通用评论屏蔽(同步:关键词/表情/@,正则交由异步路径) function judgeIfBanned(raw) { const fullText = (raw.innerText || raw.textContent || '').trim(); const replyMatch = fullText.match(/^回复\s*(@[^\s]+)\s*[::]/); let targetUser = replyMatch ? replyMatch[1] : null; for (let node of raw.childNodes) { if (node.nodeType === Node.TEXT_NODE) { // 暂无此情形 } if (node.tagName === 'SPAN') { const text = node.innerText; if (text && filterFunction.bySpan(text)) return true; } else if (node.tagName === 'IMG') { if (filterFunction.byImg(node.alt)) return true; } else if (node.tagName === 'A') { var type = node.dataset.type; if (type == "mention") { const atText = node.innerText.trim(); if (targetUser && targetUser === atText) { targetUser = null; continue; } if (filterFunction.byAt(atText)) return true; } if (type == "search") { const searchText = node.innerText.trim(); if (searchText && filterFunction.bySearch(searchText)) return true; } } } return false; } function judgeIfLevelBanned(levelImg) { for (const key of config.banLevel) if (levelImg.includes(key)) return true; return false; } function judgeIfExpandBanned(expandEL) { return config.banExpand && !!expandEL; } function judgeIfUserBanned(userName) { for (const key of config.banUser) if (userName.includes(key)) return true; return false; } // 从评论节点中提取 UID:B站新版评论在 #user-avatar 的 data-user-profile-id 上 function getUid(scopeEl) { if (!scopeEl || !scopeEl.shadowRoot) return null; const avatar = scopeEl.shadowRoot.querySelector('#user-avatar'); const v = avatar && (avatar.dataset.userProfileId || avatar.getAttribute('data-user-profile-id')); if (v) return String(v).trim(); // 回退:扫描空间链接 const links = Array.from(scopeEl.shadowRoot.querySelectorAll('a[href]')); for (const a of links) { const m = (a.href || '').match(/(?:space\.bilibili\.com|space\/)(\d+)/); if (m) return m[1]; } return null; } function judgeIfUidBanned(uid) { if (!uid) return false; for (const key of config.banUid) if (uid === key) return true; return false; } // 统一隐藏/标红逻辑 function hideComment(el, textEl) { if (config.logMode) console.log(textEl.innerText); if (config.redLine) textEl.style.color = "red"; else el.style.display = "none"; } function runCore() { // 全局过滤开关:关闭时不做任何屏蔽 if (!config.enabled) return; var reviews = Array.from(document.querySelectorAll("bili-comments")).flatMap(el => el.shadowRoot ? Array.from(el.shadowRoot.querySelectorAll("#feed > bili-comment-thread-renderer")) : []); for (var review of reviews) { if (review.getAttribute('filtered') == null) { try { var comment = review.shadowRoot.querySelector('#comment'); var level = comment.shadowRoot.querySelector("#header > bili-comment-user-info").shadowRoot.querySelector("#user-level > img").src; var uid = getUid(comment); var userInfo = comment.shadowRoot.querySelector("#header > bili-comment-user-info"); var userName = userInfo.shadowRoot.querySelector("#user-name").innerText.trim(); var commentText = comment.shadowRoot.querySelector("#content > bili-rich-text").shadowRoot.querySelector("#contents"); var expandEL = comment.shadowRoot.querySelector("#expand"); review.setAttribute('filtered', true); // 顺序规则:关键词/等级/用户等同步判断优先,命中直接隐藏,不再走正则 if (judgeIfLevelBanned(level) || judgeIfBanned(commentText) || judgeIfExpandBanned(expandEL) || judgeIfUserBanned(userName) || judgeIfUidBanned(uid)) { hideComment(review, commentText); } else { // 同步未命中:正则异步判定 + AI 语义分析并行(谁先命中谁隐藏) if (config.banRegex.length) { const text = (commentText.innerText || commentText.textContent || '').trim(); if (text) pendingRegexChecks.push({ text, patterns: config.banRegex, apply: () => hideComment(review, commentText) }); } collectAiTarget(review, commentText); } } catch (e) { /* B站结构变化时跳过该条,不影响其它评论 */ } } try { var replies = review.shadowRoot.querySelector("#replies > bili-comment-replies-renderer").shadowRoot.querySelectorAll("#expander-contents > bili-comment-reply-renderer:not([filtered])"); for (var reply of replies) { var replyLevel = reply.shadowRoot.querySelector("#main > bili-comment-user-info").shadowRoot.querySelector("#user-level > img").src; var replyUid = getUid(reply); var replyUserInfo = reply.shadowRoot.querySelector("#main > bili-comment-user-info"); var replyUserName = replyUserInfo.shadowRoot.querySelector("#user-name").innerText.trim(); var replyCommentText = reply.shadowRoot.querySelector("#main > bili-rich-text").shadowRoot.querySelector("#contents"); reply.setAttribute('filtered', true); if (judgeIfLevelBanned(replyLevel) || judgeIfBanned(replyCommentText) || judgeIfUserBanned(replyUserName) || judgeIfUidBanned(replyUid)) { hideComment(reply, replyCommentText); } else { if (config.banRegex.length) { const text = (replyCommentText.innerText || replyCommentText.textContent || '').trim(); if (text) pendingRegexChecks.push({ text, patterns: config.banRegex, apply: () => hideComment(reply, replyCommentText) }); } collectAiTarget(reply, replyCommentText); } } } catch (e) { /* B站结构变化时跳过该楼回复,不影响其它 */ } } // 异步处理正则队列(关键词未命中的评论) drainRegexQueue(); // 异步处理 AI 队列(同步规则未命中的评论) drainAiQueue(); } // 清除过滤标记与样式,便于配置变更后重新过滤(含 AI 标记,重新分析) function resetFilterMarks() { aiQueue.length = 0; // 丢弃未发送的 AI 分析任务 const threads = Array.from(document.querySelectorAll("bili-comments")).flatMap(el => el.shadowRoot ? Array.from(el.shadowRoot.querySelectorAll("bili-comment-thread-renderer")) : []); for (const review of threads) { review.removeAttribute('title'); // 清除 AI 悬浮理由 if (review.hasAttribute('ai-checked')) review.removeAttribute('ai-checked'); if (review.hasAttribute('filtered')) { review.removeAttribute('filtered'); review.style.display = ''; const comment = review.shadowRoot.querySelector('#comment'); if (comment) { const rich = comment.shadowRoot && comment.shadowRoot.querySelector('#content > bili-rich-text'); if (rich && rich.shadowRoot) { const cc = rich.shadowRoot.querySelector('#contents'); if (cc) cc.style.color = ''; } } } const replyRoot = review.shadowRoot && review.shadowRoot.querySelector('#replies > bili-comment-replies-renderer'); if (replyRoot) { replyRoot.querySelectorAll('bili-comment-reply-renderer').forEach(r => { r.removeAttribute('title'); // 清除 AI 悬浮理由 if (r.hasAttribute('ai-checked')) r.removeAttribute('ai-checked'); if (!r.hasAttribute('filtered')) return; r.removeAttribute('filtered'); r.style.display = ''; const main = r.shadowRoot.querySelector('#main'); if (main) { const rich = main.shadowRoot && main.shadowRoot.querySelector('bili-rich-text'); if (rich && rich.shadowRoot) { const cc = rich.shadowRoot.querySelector('#contents'); if (cc) cc.style.color = ''; } } }); } } } /* ================================================================ * 过滤引擎启动 * ================================================================ */ // 过滤引擎启动(不覆盖页面 window.onload,避免 load 已触发或被他脚本覆盖) function startEngine() { var startMark = false; setInterval(function () { if (startMark) { runCore(); } else { startMark = document.querySelector("bili-comments") != null; } }, 100); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startEngine); } else { startEngine(); } /* ================================================================ * AI 语义分析引擎(OpenAI 兼容 /chat/completions · 批量异步 · 失败静默降级) * 规则:仅对未被关键词/等级/用户等同步规则命中的评论做 AI 分析; * 命中结果用 hideComment 统一隐藏;任何异常都不影响原有过滤。 * ================================================================ */ const AI_MAX_TEXT = 200; // 单条评论送入 AI 的最大字符数 const AI_FLUSH_GAP = 2500; // 未凑满一批时,距上次发送超过该毫秒数则强制发送 const AI_REQUEST_TIMEOUT = 45000; // 单次请求硬超时(考虑慢速模型/推理模型) let aiQueue = []; // 待分析队列 {el, text} let aiSending = false; // 是否正在请求 let aiLastFlush = 0; // 上次发送时间戳 let aiStats = { analyzed: 0, hidden: 0 }; // AI 判定结果统计(累计) const AI_HISTORY_MAX = 25; // AI 屏蔽记录缓存上限 let aiHistory = []; // 最近被 AI 屏蔽的评论 {text, reason, score, time}(最新在末尾) // 屏蔽强度 → 判定标准描述(0宽松 / 1标准 / 2严格 / 3激进) const AI_STRICT_DESC = { 0: '宽松:只屏蔽明确的人身攻击、辱骂、广告刷屏、剧透等硬性违规内容;有疑问的一律放行。', 1: '标准:屏蔽引战、拉踩、攻击辱骂、刷屏广告、剧透、人身攻击、明显无意义灌水等常见负面内容。', 2: '严格:在标准基础上,还屏蔽与话题无关的离题讨论、低质水帖、复读刷屏、阴阳怪气、含沙射影等内容。', 3: '激进:凡可能让用户不适、或明显不符合用户偏好的评论都应判定为需屏蔽,宁可多屏蔽也不漏过。' }; const AI_STRICT_LABELS = { 0: '宽松', 1: '标准', 2: '严格', 3: '激进' }; // 组装 system 提示词:强度标准 + 用户偏好描述 + 现有词库参考 function buildAiSystemPrompt() { const strict = config.aiStrictness in AI_STRICT_DESC ? config.aiStrictness : 1; const parts = [ '你是 B 站评论审核助手。你的任务:从给定的评论列表中,挑出"用户不想看到的"评论。', '当前屏蔽强度【' + AI_STRICT_LABELS[strict] + '】:' + AI_STRICT_DESC[strict] ]; if (config.aiUsePrompt && config.aiPrompt && config.aiPrompt.trim()) { parts.push('用户特别不想看到的内容(严格按此判断):' + config.aiPrompt.trim()); } if (config.aiUseBanlist) { const refs = []; if (config.banMap.length) refs.push('关键词:' + config.banMap.join('、')); if (config.banUser.length) refs.push('用户名:' + config.banUser.join('、')); if (config.banUid.length) refs.push('UID:' + config.banUid.join('、')); if (config.banRegex.length) refs.push('正则:' + config.banRegex.join('、')); if (refs.length) parts.push('用户现有的屏蔽词库(体现用户偏好,命中这些内容的评论应判定为需屏蔽):' + refs.join(';')); } parts.push('严格只输出 JSON 数组,每个元素格式:{"id":评论编号,"score":置信度0~100,"reason":"一句话理由"}。若没有要屏蔽的,输出 []。禁止输出数组以外的任何内容。'); return parts.join('\n'); } // 收集一条待 AI 分析的评论(ai-checked 标记去重) function collectAiTarget(el, textEl) { if (!config.aiEnabled || !config.aiApiUrl || !config.aiApiKey || !config.aiModel) return; if (el.hasAttribute('ai-checked')) return; el.setAttribute('ai-checked', ''); // 换行/制表符统一为空格,避免破坏 AI 列表"每行一条"的编号映射 const text = (textEl.innerText || textEl.textContent || '').replace(/\s+/g, ' ').trim().slice(0, AI_MAX_TEXT); if (!text) return; aiQueue.push({ el, text }); } // 触发批量发送:凑满一批,或队列非空且超过间隔时间则强制发送 function drainAiQueue() { if (!config.aiEnabled || aiSending || !aiQueue.length) return; const now = Date.now(); if (aiQueue.length < (config.aiBatchSize || 1) && now - aiLastFlush < AI_FLUSH_GAP) return; aiLastFlush = now; flushAiBatch(); } // 发送一批到 AI,解析结果并隐藏命中项 function flushAiBatch() { if (aiSending || !aiQueue.length) return; const batch = aiQueue.splice(0, Math.max(1, config.aiBatchSize || 1)); aiSending = true; const payload = { model: config.aiModel, temperature: 0, messages: [ { role: 'system', content: buildAiSystemPrompt() }, { role: 'user', content: '评论列表(每行一条):\n' + batch.map((item, i) => '#' + (i + 1) + ': ' + item.text).join('\n') } ] }; // DeepSeek V4 默认开启思考模式,会显著拖慢批量响应甚至超时;评论分类无需深度思考,显式关闭 if (/deepseek\.com/i.test(config.aiApiUrl) && /deepseek/i.test(config.aiModel)) { payload.thinking = { type: 'disabled' }; } const finish = () => { aiSending = false; setTimeout(drainAiQueue, 0); }; const logFail = (tag, res) => { if (config.logMode) console.log('[AI]', tag, res && res.error ? res.error : '', res && res.status ? 'HTTP ' + res.status : ''); }; try { GM_xmlhttpRequest({ method: 'POST', url: config.aiApiUrl, timeout: AI_REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + config.aiApiKey }, data: JSON.stringify(payload), onload: (res) => { finish(); if (res.status < 200 || res.status >= 300) { logFail('请求失败', res); return; } const bans = parseAiResult(res.responseText); if (!bans) { logFail('AI 返回无法解析', res); if (config.logMode) console.log('[AI] 原始返回前300字符:', String(res.responseText || '').slice(0, 300)); return; } let hidden = 0; for (const b of bans) { const item = batch[b.id - 1]; if (!item) continue; if (config.aiMinScore > 0 && (b.score == null || b.score < config.aiMinScore)) continue; hideComment(item.el, item.el); // 悬浮提示:理由写入 title(配合「红色标记不隐藏」调试模式可看到 AI 判定理由) item.el.title = 'AI屏蔽' + (b.reason ? ':' + b.reason : ''); // 缓存屏蔽记录,超上限自动淘汰最旧 aiHistory.push({ text: item.text, reason: b.reason || '', score: b.score, time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }); if (aiHistory.length > AI_HISTORY_MAX) aiHistory.shift(); hidden++; } aiStats.analyzed += batch.length; aiStats.hidden += hidden; renderAiStats(); if (hidden > 0) renderAiHistory(); // 刷新屏蔽记录列表 if (config.logMode) console.log('[AI] 分析', batch.length, '条 → 屏蔽', hidden, '条'); if (hidden > 0) flashStatus('AI 屏蔽 ' + hidden + ' 条'); }, onerror: (res) => { logFail('网络错误', res); finish(); }, ontimeout: () => { logFail('请求超时', {}); finish(); } }); } catch (e) { logFail('异常', {}); finish(); } } // 解析 AI 返回:先解出 OpenAI 兼容响应的 message.content,再提取其中的 JSON 数组 // (避免贪婪匹配误取外层 choices 数组导致结果为空;容忍代码块/前后多余文字) function parseAiResult(text) { if (!text) return null; let content = text; try { const resp = JSON.parse(text); const c = resp && Array.isArray(resp.choices) && resp.choices[0] && resp.choices[0].message && resp.choices[0].message.content; if (typeof c === 'string') content = c; else if (c && typeof c === 'object') content = JSON.stringify(c); } catch (e) { /* 非标准响应体,按原始文本处理 */ } const m = String(content).match(/\[[\s\S]*\]/); if (!m) return null; try { const arr = JSON.parse(m[0]); if (!Array.isArray(arr)) return null; return arr .filter(x => x && typeof x === 'object' && Number.isFinite(+x.id)) .map(x => ({ id: +x.id, score: x.score == null ? 0 : +x.score, reason: typeof x.reason === 'string' ? x.reason : '' })); } catch (e) { return null; } } // 测试 API 连通性(面板「测试连接」按钮) function testAiConnection() { return new Promise((resolve) => { if (!config.aiApiUrl || !config.aiApiKey || !config.aiModel) { resolve({ ok: false, msg: '请先填写 API 地址 / Key / 模型' }); return; } try { GM_xmlhttpRequest({ method: 'POST', url: config.aiApiUrl, timeout: 15000, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + config.aiApiKey }, data: JSON.stringify({ model: config.aiModel, messages: [{ role: 'user', content: '请只回复:OK' }], max_tokens: 5 }), onload: (res) => { if (res.status >= 200 && res.status < 300) { const m = String(res.responseText || '').match(/"content"\s*:\s*"([^"]*)"/); resolve({ ok: true, msg: '连接成功' + (m && m[1] ? ':' + m[1].slice(0, 20) : '') + ';请确认已开启"启用 AI 分析"开关' }); } else { resolve({ ok: false, msg: 'HTTP ' + res.status + ':' + String(res.responseText || '').slice(0, 120) }); } }, onerror: (res) => { const err = String((res && res.error) || '网络错误'); if (/connect/i.test(err)) resolve({ ok: false, msg: '域名未授权:请重新安装脚本使 @connect 生效' }); else resolve({ ok: false, msg: '网络错误:' + err.slice(0, 100) }); }, ontimeout: () => resolve({ ok: false, msg: '请求超时(15s)' }) }); } catch (e) { resolve({ ok: false, msg: 'GM_xmlhttpRequest 不可用' }); } }); } /* ================================================================ * Arknights 军规风可视化配置面板 * ================================================================ */ const UI_CSS = ` :host { all: initial; } * { box-sizing: border-box; margin: 0; padding: 0; } [hidden] { display: none !important; } /* ===== 默认 = 深色主题 ===== */ .ark { --bg-panel: linear-gradient(180deg, rgba(48,54,62,0.97) 0%, rgba(36,42,50,0.97) 100%); --fab-bg: linear-gradient(180deg, rgba(48,54,62,0.98), rgba(36,42,50,0.98)); --text: #f1f3f5; --muted: #8b919b; --muted-2: #5b6066; --border: rgba(255,255,255,0.22); --border-soft: rgba(255,255,255,0.12); --border-faint: rgba(255,255,255,0.08); --layer: rgba(255,255,255,0.14); --layer-hover: rgba(255,255,255,0.06); --chip-bg: rgba(255,255,255,0.06); --chip-hover: rgba(255,255,255,0.14); --accent-bg: #ffffff; --accent-text: #111213; --accent-hover: #d5d8dc; --danger: #ff4757; --info: #8ec8ff; --corner: rgba(255,255,255,0.85); --watermark: rgba(255,255,255,0.035); --shadow: 0 0 24px rgba(0,0,0,0.55); --thumb: rgba(255,255,255,0.22); --thumb-hover: rgba(255,255,255,0.4); --input-bg: rgba(255,255,255,0.06); font-family: "Source Han Sans CN","Source Han Sans SC","Microsoft YaHei","PingFang SC",sans-serif; font-weight: 700; color: var(--text); letter-spacing: 0; -webkit-font-smoothing: antialiased; } /* ===== 浅色变量 ===== */ .ark.light { --bg-panel: linear-gradient(180deg, rgba(250,251,252,0.97) 0%, rgba(233,237,241,0.97) 100%); --fab-bg: linear-gradient(180deg, rgba(250,251,252,0.98), rgba(234,238,242,0.98)); --text: #111213; --muted: #5b6066; --muted-2: #6b7280; --border: rgba(0,0,0,0.24); --border-soft: rgba(0,0,0,0.12); --border-faint: rgba(0,0,0,0.06); --layer: rgba(0,0,0,0.05); --layer-hover: rgba(0,0,0,0.08); --chip-bg: rgba(0,0,0,0.04); --chip-hover: rgba(0,0,0,0.08); --accent-bg: #111213; --accent-text: #f5f7f9; --accent-hover: #2c3238; --danger: #e0382f; --info: #2f6fbf; --corner: rgba(0,0,0,0.75); --watermark: rgba(0,0,0,0.05); --shadow: 0 0 24px rgba(0,0,0,0.18); --thumb: rgba(0,0,0,0.22); --thumb-hover: rgba(0,0,0,0.45); --input-bg: rgba(255,255,255,0.6); } /* ===== 自动:跟随系统浅色 ===== */ @media (prefers-color-scheme: light) { .ark:not(.light):not(.dark) { --bg-panel: linear-gradient(180deg, rgba(250,251,252,0.97) 0%, rgba(233,237,241,0.97) 100%); --fab-bg: linear-gradient(180deg, rgba(250,251,252,0.98), rgba(234,238,242,0.98)); --text: #111213; --muted: #5b6066; --muted-2: #6b7280; --border: rgba(0,0,0,0.24); --border-soft: rgba(0,0,0,0.12); --border-faint: rgba(0,0,0,0.06); --layer: rgba(0,0,0,0.05); --layer-hover: rgba(0,0,0,0.08); --chip-bg: rgba(0,0,0,0.04); --chip-hover: rgba(0,0,0,0.08); --accent-bg: #111213; --accent-text: #f5f7f9; --accent-hover: #2c3238; --danger: #e0382f; --info: #2f6fbf; --corner: rgba(0,0,0,0.75); --watermark: rgba(0,0,0,0.05); --shadow: 0 0 24px rgba(0,0,0,0.18); --thumb: rgba(0,0,0,0.22); --thumb-hover: rgba(0,0,0,0.45); --input-bg: rgba(255,255,255,0.6); } } .fab { position: fixed; right: 24px; bottom: 24px; z-index: 2147483647; width: 64px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--fab-bg); border: 1px solid var(--border); box-shadow: var(--shadow); cursor: pointer; user-select: none; transition: none; } .fab:hover { border-color: var(--border); opacity: 0.92; } .fab:active { transform: scale(0.92); } .fab.dragging { cursor: grabbing; } .fab .fab-label { font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 12px; letter-spacing: 2px; text-transform: uppercase; color: var(--text); } .fab-corner, .panel-corner { position: absolute; width: 7px; height: 7px; } .fab-corner.tl { top: 0; left: 0; border-top: 2px solid var(--corner); border-left: 2px solid var(--corner); } .fab-corner.tr { top: 0; right: 0; border-top: 2px solid var(--corner); border-right: 2px solid var(--corner); } .fab-corner.br { bottom: 0; right: 0; border-bottom: 2px solid var(--corner); border-right: 2px solid var(--corner); } .fab-corner.bl { bottom: 0; left: 0; border-bottom: 2px solid var(--corner); border-left: 2px solid var(--corner); } .panel { position: fixed; right: 24px; bottom: 72px; z-index: 2147483646; width: 344px; max-height: calc(100vh - 120px); display: flex; flex-direction: column; background: var(--bg-panel); border: 1px solid var(--border); box-shadow: var(--shadow); border-radius: 0; } .panel-corner { border: 1px solid var(--corner); } .panel-corner.tl { top: 0; left: 0; border-top-width: 2px; border-left-width: 2px; } .panel-corner.tr { top: 0; right: 0; border-top-width: 2px; border-right-width: 2px; } .panel-corner.br { bottom: 0; right: 0; border-bottom-width: 2px; border-right-width: 2px; } .panel-corner.bl { bottom: 0; left: 0; border-bottom-width: 2px; border-left-width: 2px; } .watermark { position: absolute; top: 52px; right: 14px; z-index: 0; font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 52px; font-weight: 800; letter-spacing: 0; color: var(--watermark); pointer-events: none; } .header { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; cursor: move; touch-action: none; border-bottom: 1px solid var(--border-soft); } .label-strip { display: inline-block; background: var(--accent-bg); color: var(--accent-text); padding: 3px 10px; font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 12px; letter-spacing: 2px; text-transform: uppercase; } .icon-btn { width: 24px; height: 24px; border: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer; transition: 0.2s; font-size: 12px; line-height: 1; display: flex; align-items: center; justify-content: center; } .icon-btn:hover { background: var(--layer); } .icon-btn:active { transform: scale(0.9); } .tabs { display: flex; border-bottom: 1px solid var(--border-soft); } .tab { flex: 1; height: 36px; background: transparent; border: none; border-right: 1px solid var(--border-faint); color: var(--muted); cursor: pointer; font-family: "Source Han Sans CN","Microsoft YaHei",sans-serif; font-weight: 700; font-size: 12px; letter-spacing: 1px; transition: 0.2s; } .tab:last-child { border-right: none; } .tab:active { transform: scale(0.97); } .tab.active { background: var(--accent-bg); color: var(--accent-text); } .content { flex: 1; overflow-y: auto; min-height: 120px; max-height: 380px; } .content::-webkit-scrollbar { width: 8px; } .content::-webkit-scrollbar-thumb { background: var(--thumb); } .content::-webkit-scrollbar-thumb:hover { background: var(--thumb-hover); } .tab-page { position: relative; z-index: 1; } .page-label { display: flex; justify-content: space-between; align-items: center; padding: 12px 12px 6px; font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--muted); } .page-label.sub { padding-top: 16px; } .page-label .count { font-family: "Bahnschrift",sans-serif; font-size: 11px; color: var(--info); } .tags { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 12px 8px; } .tag { display: inline-flex; align-items: center; gap: 5px; padding: 4px 6px 4px 9px; background: var(--layer); border: 1px solid var(--border); font-size: 12px; max-width: 220px; transition: 0.2s; } .tag:hover { border-color: var(--text); opacity: 0.95; } .tag .tag-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tag .tag-del { flex: none; width: 16px; height: 16px; border: none; background: transparent; color: var(--muted); cursor: pointer; font-size: 11px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: 0.2s; } .tag .tag-del:hover { color: var(--danger); } .tag .tag-del:active { transform: scale(0.85); } .input-row { display: flex; gap: 8px; padding: 4px 12px 14px; } .input-row input { flex: 1; height: 32px; padding: 0 10px; background: var(--input-bg); border: 1px solid var(--border); color: var(--text); outline: none; font-family: "Source Han Sans CN","Microsoft YaHei",sans-serif; font-weight: 700; font-size: 12px; transition: 0.2s; } .input-row input:focus { border-color: var(--text); } .input-row input::placeholder { color: var(--muted-2); font-weight: 400; } .btn-add { height: 32px; padding: 0 14px; background: var(--accent-bg); color: var(--accent-text); border: none; cursor: pointer; font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; transition: 0.2s; } .btn-add:hover { background: var(--accent-hover); } .btn-add:active { transform: scale(0.95); } .level-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; padding: 4px 12px 8px; } .level-chip { height: 32px; display: flex; align-items: center; justify-content: center; background: var(--chip-bg); border: 1px solid var(--border); color: var(--muted); cursor: pointer; font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 12px; letter-spacing: 0.5px; transition: 0.2s; } .level-chip:hover { background: var(--chip-hover); } .level-chip:active { transform: scale(0.93); } .level-chip.active { background: var(--accent-bg); color: var(--accent-text); border-color: var(--accent-bg); } .hint { padding: 0 12px 14px; font-size: 11px; color: var(--muted-2); font-weight: 400; } .ai-field { padding: 4px 12px 8px; } .ai-label { display: block; font-size: 11px; color: var(--muted-2); font-weight: 400; padding: 4px 0; letter-spacing: 1px; } .ai-field input, .ai-field textarea { width: 100%; background: var(--input-bg); border: 1px solid var(--border); color: var(--text); outline: none; padding: 8px 10px; font-family: "Source Han Sans CN","Microsoft YaHei",sans-serif; font-weight: 700; font-size: 12px; transition: 0.2s; resize: vertical; } .ai-field input:focus, .ai-field textarea:focus { border-color: var(--text); } .ai-field input::placeholder, .ai-field textarea::placeholder { color: var(--muted-2); font-weight: 400; } .ai-row { display: flex; align-items: center; gap: 8px; padding: 4px 12px 10px; font-size: 11px; color: var(--muted); } .ai-row input { width: 56px; height: 26px; background: var(--input-bg); border: 1px solid var(--border); color: var(--text); text-align: center; outline: none; font-family: "Bahnschrift",sans-serif; font-size: 12px; transition: 0.2s; } .ai-row input:focus { border-color: var(--text); } .ai-test-row { display: flex; gap: 6px; padding: 0 12px 10px; } .ai-status { padding: 0 12px 10px; font-size: 11px; font-weight: 400; letter-spacing: 1px; } .ai-warn { padding: 8px 12px; margin: 4px 12px 10px; font-size: 11px; font-weight: 400; letter-spacing: 0.5px; color: var(--danger); border: 1px solid var(--danger); background: rgba(255,71,87,0.08); } .ai-history { display: flex; flex-direction: column; gap: 6px; padding: 4px 12px 12px; max-height: 180px; overflow-y: auto; } .ai-history::-webkit-scrollbar { width: 6px; } .ai-history::-webkit-scrollbar-thumb { background: var(--thumb); } .ai-history::-webkit-scrollbar-thumb:hover { background: var(--thumb-hover); } .ai-hist-item { border: 1px solid var(--border-faint); padding: 6px 8px; background: var(--layer); } .ai-hist-text { font-size: 11px; color: var(--text); word-break: break-all; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .ai-hist-meta { font-size: 10px; color: var(--muted-2); margin-top: 3px; font-family: "Source Han Sans CN","Microsoft YaHei",sans-serif; font-weight: 400; } .ai-hist-empty { font-size: 11px; color: var(--muted-2); font-weight: 400; padding: 8px 0; } .preset-grid { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 12px 12px; } .preset-chip { padding: 0 10px; height: 28px; display: inline-flex; align-items: center; gap: 4px; background: var(--chip-bg); border: 1px solid var(--border); color: var(--muted); cursor: pointer; font-family: "Bahnschrift","Segoe UI",sans-serif; font-size: 11px; letter-spacing: 0.5px; transition: 0.2s; } .preset-chip:hover { background: var(--chip-hover); } .preset-chip:active { transform: scale(0.95); } .preset-chip.active { background: var(--accent-bg); color: var(--accent-text); border-color: var(--accent-bg); } .opt-list { display: flex; flex-direction: column; gap: 4px; padding: 4px 12px 12px; } .opt-row { display: flex; justify-content: space-between; align-items: center; height: 40px; padding: 0 10px; border: 1px solid var(--border-faint); cursor: pointer; transition: 0.2s; user-select: none; } .opt-row:hover { background: var(--layer-hover); } .opt-label { font-size: 12px; } .toggle { flex: none; width: 36px; height: 18px; border: 1px solid var(--border); background: var(--layer); position: relative; cursor: pointer; transition: 0.2s; } .toggle .knob { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: var(--muted); transition: 0.2s; } .toggle.on { background: var(--accent-bg); border-color: var(--accent-bg); } .toggle.on .knob { left: 20px; background: var(--accent-text); } .theme-grid { display: flex; gap: 6px; padding: 4px 12px 12px; } .theme-chip { flex: 1; height: 30px; display: flex; align-items: center; justify-content: center; background: var(--chip-bg); border: 1px solid var(--border); color: var(--muted); cursor: pointer; font-family: "Source Han Sans CN","Microsoft YaHei",sans-serif; font-weight: 700; font-size: 12px; letter-spacing: 1px; transition: 0.2s; } .theme-chip:hover { background: var(--chip-hover); } .theme-chip:active { transform: scale(0.95); } .theme-chip.active { background: var(--accent-bg); color: var(--accent-text); border-color: var(--accent-bg); } .backup-actions { display: flex; gap: 6px; padding: 4px 12px 12px; } .backup-actions .btn-reset { flex: 1; height: 30px; font-size: 11px; } .reset-row { padding: 4px 12px 14px; } .btn-reset { width: 100%; height: 34px; background: transparent; border: 1px solid var(--border); color: var(--text); cursor: pointer; font-family: "Source Han Sans CN","Microsoft YaHei",sans-serif; font-weight: 700; font-size: 12px; letter-spacing: 1px; transition: 0.2s; } .btn-reset:hover { color: var(--danger); border-color: var(--danger); } .btn-reset:active { transform: scale(0.95); } .footer { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-top: 1px solid var(--border-soft); } .status { font-size: 11px; color: var(--muted); letter-spacing: 1px; } .ver { font-family: "Bahnschrift",sans-serif; font-size: 10px; color: var(--muted-2); letter-spacing: 1px; } `; const UI_HTML = `
FILTER
`; const TAB_PAGES = { keyword: 'pg-keyword', regex: 'pg-regex', user: 'pg-user', emoji: 'pg-emoji', level: 'pg-level', ai: 'pg-ai', option: 'pg-option' }; const TAGGED_CATS = ['banMap', 'banRegex', 'banUser', 'banUid', 'banEmoji', 'banAt']; const OPT_ITEMS = [ { key: 'enabled', label: '启用评论过滤' }, { key: 'banExpand', label: '屏蔽全部笔记' }, { key: 'logMode', label: '调试 · 控制台日志' }, { key: 'redLine', label: '调试 · 红色标记不隐藏' }, { key: 'fabFollow', label: '悬浮按钮跟随滚动' } ]; let shadow = null; function flashStatus(msg) { const st = shadow.getElementById('status'); st.textContent = msg; clearTimeout(flashStatus._t); flashStatus._t = setTimeout(() => { st.textContent = 'AUTO SAVE'; }, 1200); } function applyFilter() { pendingRegexChecks.length = 0; // 配置变更后丢弃残留正则检测任务,防止旧规则误隐藏 resetFilterMarks(); runCore(); flashStatus('已应用'); } function renderTags(cat) { const box = shadow.getElementById('tags-' + cat); box.innerHTML = ''; for (const v of config[cat]) { const tag = document.createElement('span'); tag.className = 'tag'; const txt = document.createElement('span'); txt.className = 'tag-text'; txt.textContent = v; const del = document.createElement('button'); del.className = 'tag-del'; del.textContent = '✕'; del.title = '移除'; del.dataset.cat = cat; del.dataset.value = v; tag.appendChild(txt); tag.appendChild(del); box.appendChild(tag); } const count = shadow.querySelector(`.count[data-count="${cat}"]`); if (count) count.textContent = String(config[cat].length); } function renderLevels() { const box = shadow.getElementById('level-grid'); box.innerHTML = ''; for (const lv of LEVELS) { const chip = document.createElement('button'); chip.className = 'level-chip' + (config.banLevel.includes(lv) ? ' active' : ''); chip.textContent = lv === 'level_h' ? '6+' : lv.replace('level_', 'Lv.'); chip.title = lv; chip.dataset.level = lv; box.appendChild(chip); } } function renderOptions() { const list = shadow.getElementById('opt-list'); list.innerHTML = ''; for (const o of OPT_ITEMS) { const row = document.createElement('label'); row.className = 'opt-row'; const label = document.createElement('span'); label.className = 'opt-label'; label.textContent = o.label; const tg = document.createElement('span'); tg.className = 'toggle' + (config[o.key] ? ' on' : ''); tg.dataset.opt = o.key; const knob = document.createElement('span'); knob.className = 'knob'; tg.appendChild(knob); row.appendChild(label); row.appendChild(tg); list.appendChild(row); } } // 常用 AI 预设(仅内置接口地址与推荐模型,Key 需用户自行填写;2026-08 核实) const AI_PRESETS = [ { name: 'DeepSeek', url: 'https://api.deepseek.com/chat/completions', model: 'deepseek-v4-flash' }, { name: 'OpenAI', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4.1-mini' }, { name: 'Kimi', url: 'https://api.moonshot.cn/v1/chat/completions', model: 'kimi-k2.6' }, { name: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4.7-flash' }, { name: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen3.7-plus' }, { name: 'SiliconFlow', url: 'https://api.siliconflow.cn/v1/chat/completions', model: 'Qwen/Qwen3-32B' }, { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-3.5-flash' }, { name: 'Ollama', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5' } ]; // 渲染常用预设按钮 function renderAiPresets() { const box = shadow && shadow.getElementById('ai-preset-grid'); if (!box) return; box.innerHTML = ''; for (const p of AI_PRESETS) { const chip = document.createElement('button'); chip.className = 'preset-chip'; chip.dataset.preset = p.name; chip.title = p.url; chip.textContent = p.name; box.appendChild(chip); } } // 应用预设:自动填入 API 地址与模型(保留用户已填的 Key) function applyPreset(name) { const p = AI_PRESETS.find(x => x.name === name); if (!p) return; const urlEl = shadow.getElementById('aiApiUrl'); const modelEl = shadow.getElementById('aiModel'); if (urlEl) urlEl.value = p.url; if (modelEl) modelEl.value = p.model; config.aiApiUrl = p.url; config.aiModel = p.model; saveConfig(); renderAiStatus(); flashStatus('已应用 ' + p.name + ' 预设,请填写 API Key'); } // AI 页开关列表 const AI_OPT_ITEMS = [ { key: 'aiEnabled', label: '启用 AI 语义分析' }, { key: 'aiUsePrompt', label: '使用自定义偏好描述' }, { key: 'aiUseBanlist', label: '结合现有屏蔽词库' } ]; function renderAiOptions() { const list = shadow.getElementById('ai-opt-list'); if (!list) return; list.innerHTML = ''; for (const o of AI_OPT_ITEMS) { const row = document.createElement('label'); row.className = 'opt-row'; const label = document.createElement('span'); label.className = 'opt-label'; label.textContent = o.label; const tg = document.createElement('span'); tg.className = 'toggle' + (config[o.key] ? ' on' : ''); tg.dataset.opt = o.key; const knob = document.createElement('span'); knob.className = 'knob'; tg.appendChild(knob); row.appendChild(label); row.appendChild(tg); list.appendChild(row); } } // 将 AI 配置值写入表单 function syncAiInputs() { const strs = ['aiApiUrl', 'aiApiKey', 'aiModel', 'aiPrompt']; for (const id of strs) { const el = shadow && shadow.getElementById(id); if (el && el.value !== (config[id] || '')) el.value = config[id] || ''; } const bs = shadow && shadow.getElementById('aiBatchSize'); if (bs && bs.value !== String(config.aiBatchSize)) bs.value = config.aiBatchSize; const ms = shadow && shadow.getElementById('aiMinScore'); if (ms && ms.value !== String(config.aiMinScore)) ms.value = config.aiMinScore; } // 从表单读取并保存 AI 配置(含数值范围收敛) function saveAiInputs() { const url = shadow.getElementById('aiApiUrl'); const key = shadow.getElementById('aiApiKey'); const model = shadow.getElementById('aiModel'); const prompt = shadow.getElementById('aiPrompt'); const bs = shadow.getElementById('aiBatchSize'); const ms = shadow.getElementById('aiMinScore'); if (url) config.aiApiUrl = url.value.trim(); if (key) config.aiApiKey = key.value.trim(); if (model) config.aiModel = model.value.trim(); if (prompt) config.aiPrompt = prompt.value; if (bs) { let v = Math.round(+bs.value); if (!Number.isFinite(v) || v < 1) v = 1; if (v > 100) v = 100; config.aiBatchSize = v; bs.value = v; } if (ms) { let v = Math.round(+ms.value); if (!Number.isFinite(v) || v < 0) v = 0; if (v > 100) v = 100; config.aiMinScore = v; ms.value = v; } saveConfig(); renderAiStatus(); flashStatus('已保存'); } // 渲染屏蔽强度选择 function renderAiStrict() { const box = shadow && shadow.getElementById('ai-strict-grid'); if (!box) return; box.innerHTML = ''; for (let k = 0; k <= 3; k++) { const chip = document.createElement('button'); chip.className = 'preset-chip' + (config.aiStrictness === k ? ' active' : ''); chip.dataset.strict = k; chip.textContent = AI_STRICT_LABELS[k]; chip.title = AI_STRICT_DESC[k]; box.appendChild(chip); } } // 设置屏蔽强度并立即重新过滤 function setStrictness(v) { v = +v; if (!(v in AI_STRICT_DESC)) return; config.aiStrictness = v; saveConfig(); renderAiStrict(); applyFilter(); flashStatus('屏蔽强度:' + AI_STRICT_LABELS[v]); } // 渲染 AI 屏蔽记录(最新在前) function renderAiHistory() { const list = shadow && shadow.getElementById('aiHistoryList'); if (!list) return; const count = shadow.getElementById('aiHistCount'); if (count) count.textContent = aiHistory.length + '/' + AI_HISTORY_MAX; list.innerHTML = ''; if (!aiHistory.length) { list.innerHTML = '
暂无 AI 屏蔽记录
'; return; } for (let i = aiHistory.length - 1; i >= 0; i--) { const h = aiHistory[i]; const item = document.createElement('div'); item.className = 'ai-hist-item'; const text = document.createElement('div'); text.className = 'ai-hist-text'; text.textContent = h.text; const meta = document.createElement('div'); meta.className = 'ai-hist-meta'; meta.textContent = (h.reason ? '理由:' + h.reason : 'AI 判定') + (h.score != null ? ' · 置信度 ' + h.score : '') + ' · ' + h.time; item.appendChild(text); item.appendChild(meta); list.appendChild(item); } } // 更新 AI 页统计:累计分析/屏蔽条数 function renderAiStats() { const el = shadow && shadow.getElementById('aiStats'); if (el) el.textContent = '已分析 ' + aiStats.analyzed + ' 条 · AI 屏蔽 ' + aiStats.hidden + ' 条'; } // 更新 AI 页状态提示:是否启用、配置是否完整 function renderAiStatus() { const el = shadow && shadow.getElementById('aiStatus'); if (!el) return; const ready = config.aiEnabled && config.aiApiUrl && config.aiApiKey && config.aiModel; el.textContent = ready ? '● AI 已启用,配置完整' : (config.aiEnabled ? '● AI 已启用,但 API 配置不完整(缺地址/Key/模型)' : '● AI 未启用:请先开启上方开关并配置接口'); el.style.color = ready ? 'var(--info)' : 'var(--danger)'; } function renderAll() { TAGGED_CATS.forEach(renderTags); renderLevels(); renderOptions(); renderAiOptions(); renderAiPresets(); renderAiStrict(); renderAiStatus(); renderAiStats(); renderAiHistory(); syncAiInputs(); } function renderTheme() { const grid = shadow && shadow.getElementById('theme-grid'); if (!grid) return; grid.querySelectorAll('.theme-chip').forEach(c => { c.classList.toggle('active', c.dataset.theme === config.theme); }); } // 应用主题:auto 不加类(走 media 跟随系统),light/dark 强制加类 function applyTheme() { const ark = shadow && shadow.querySelector('.ark'); if (!ark) return; ark.classList.toggle('light', config.theme === 'light'); ark.classList.toggle('dark', config.theme === 'dark'); renderTheme(); } function setTheme(t) { if (t !== 'auto' && t !== 'light' && t !== 'dark') return; config.theme = t; applyTheme(); saveConfig(); flashStatus('已应用'); } // 导出:下载 JSON 备份文件 function downloadConfig() { try { const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'bili_rebuild_config_' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); flashStatus('已下载备份文件'); } catch (e) { flashStatus('导出失败'); } } // 导入:点击选择本地 JSON 文件后读取并应用 function importFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json,text/plain'; input.style.display = 'none'; document.body.appendChild(input); input.addEventListener('change', () => { const file = input.files && input.files[0]; if (!file) { input.remove(); return; } const reader = new FileReader(); reader.onload = () => { importConfig(String(reader.result || '')); }; reader.onerror = () => { flashStatus('读取文件失败'); }; reader.readAsText(file); input.remove(); }); input.click(); } // 导入:解析 JSON 并按白名单字段合并到配置 function importConfig(text) { const value = (text || '').trim(); if (!value) { flashStatus('内容为空'); return; } let obj; try { obj = JSON.parse(value); } catch (e) { flashStatus('JSON 解析失败'); return; } if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { flashStatus('格式不正确'); return; } config = sanitizeConfig(obj); regexBlacklist.clear(); saveConfig(); renderAll(); applyTheme(); applyFilter(); flashStatus('已导入并应用'); } function addItem(cat) { const input = shadow.getElementById('in-' + cat); const value = (input.value || '').trim(); if (!value) return; if (!config[cat].includes(value)) { if (cat === 'banRegex') { // 词库更新策略:正则入库前做危险模式预检 + 耗时基准验证(危险模式跳过基准,防同步卡死) const warns = []; const dangerous = DANGEROUS_REGEX.test(value); if (dangerous) warns.push('存在 ReDoS 风险'); const bench = dangerous ? 0 : testRegexSync(BENCH_TEXT, value); if (bench > 8) warns.push('耗时 ' + Math.round(bench) + 'ms 偏慢'); config[cat].push(value); renderTags(cat); saveConfig(); applyFilter(); flashStatus(warns.length ? ('已添加(' + warns.join(',') + ')') : '已添加'); } else { config[cat].push(value); renderTags(cat); saveConfig(); applyFilter(); flashStatus('已添加'); } } else { flashStatus('已存在'); } input.value = ''; input.focus(); } function removeItem(cat, value) { config[cat] = config[cat].filter(v => v !== value); if (cat === 'banRegex') regexBlacklist.clear(); // 词库变更后重置超时黑名单 renderTags(cat); saveConfig(); applyFilter(); } function toggleLevel(lv) { const arr = config.banLevel; config.banLevel = arr.includes(lv) ? arr.filter(x => x !== lv) : [...arr, lv]; renderLevels(); saveConfig(); applyFilter(); } function toggleOption(key) { if (key === 'fabFollow') { const fab = shadow.getElementById('fab'); const vtop = fab ? Math.round(fab.getBoundingClientRect().top) : null; if (config.fabFollow) { // 跟随屏幕 → 钉在网页:当前视口位置转成文档位置 config.fabDocY = (config.fabTop ?? (vtop ?? 0)) + window.scrollY; } else { // 钉在网页 → 跟随屏幕:当前文档位置转成视口位置 config.fabTop = (config.fabDocY ?? (vtop ?? 0) + window.scrollY) - window.scrollY; } config.fabFollow = !config.fabFollow; renderOptions(); saveConfig(); positionFab(); flashStatus('已应用'); return; } config[key] = !config[key]; renderOptions(); renderAiOptions(); renderAiStatus(); saveConfig(); applyFilter(); } function resetConfig() { config = deepClone(DEFAULT_CONFIG); regexBlacklist.clear(); aiStats = { analyzed: 0, hidden: 0 }; aiHistory.length = 0; saveConfig(); renderAll(); applyTheme(); applyFilter(); flashStatus('已重置'); } // 定位悬浮按钮:开启跟随屏幕=用视口坐标(滚动时不变);关闭钉在网页=文档坐标-滚动偏移 function positionFab() { const fab = shadow && shadow.getElementById('fab'); if (!fab) return; const hasCustom = config.fabX != null && (config.fabTop != null || config.fabDocY != null); if (!hasCustom) return; // 未自定义时保持 CSS 默认右下角 fab.style.right = 'auto'; fab.style.bottom = 'auto'; fab.style.left = config.fabX + 'px'; const vtop = config.fabFollow ? config.fabTop : config.fabDocY - window.scrollY; fab.style.top = vtop + 'px'; } function initUI() { const host = document.createElement('div'); host.id = 'bili-rebuild-host'; shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = `${UI_HTML}`; (document.body || document.documentElement).appendChild(host); const panel = shadow.getElementById('panel'); const fab = shadow.getElementById('fab'); if (!SHOW_FAB) fab.hidden = true; const closeBtn = shadow.getElementById('closeBtn'); const header = shadow.getElementById('header'); const content = shadow.getElementById('content'); // 悬浮按钮:拖拽定位 + 点击开关面板(位移 > 4px 视为拖拽,否则为点击) let fabDrag = false, fabMoved = false, fx0 = 0, fy0 = 0, fdx = 0, fdy = 0; fab.addEventListener('pointerdown', (e) => { fabDrag = true; fabMoved = false; fx0 = e.clientX; fy0 = e.clientY; const r = fab.getBoundingClientRect(); fdx = e.clientX - r.left; fdy = e.clientY - r.top; fab.classList.add('dragging'); fab.setPointerCapture(e.pointerId); }); fab.addEventListener('pointermove', (e) => { if (!fabDrag) return; if (!fabMoved && Math.hypot(e.clientX - fx0, e.clientY - fy0) > 4) fabMoved = true; if (!fabMoved) return; const x = Math.max(0, Math.min(e.clientX - fdx, window.innerWidth - 64)); const y = Math.max(0, Math.min(e.clientY - fdy, window.innerHeight - 40)); fab.style.right = 'auto'; fab.style.bottom = 'auto'; fab.style.left = x + 'px'; fab.style.top = y + 'px'; }); fab.addEventListener('pointerup', () => { if (!fabDrag) return; fabDrag = false; fab.classList.remove('dragging'); if (fabMoved) { const r = fab.getBoundingClientRect(); config.fabX = Math.round(r.left); const vtop = Math.round(r.top); if (config.fabFollow) config.fabTop = vtop; else config.fabDocY = vtop + window.scrollY; saveConfig(); positionFab(); flashStatus('位置已保存'); } else { panel.hidden = !panel.hidden; } }); fab.addEventListener('pointercancel', () => { fabDrag = false; fab.classList.remove('dragging'); }); // 滚动时同步按钮位置(钉在网页模式下随内容移动) window.addEventListener('scroll', positionFab, { passive: true }); closeBtn.addEventListener('click', () => { panel.hidden = true; }); // Tab 切换 shadow.querySelector('.tabs').addEventListener('click', (e) => { const btn = e.target.closest('.tab'); if (!btn) return; shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === btn)); shadow.querySelectorAll('.tab-page').forEach(p => { p.hidden = true; }); shadow.getElementById(TAB_PAGES[btn.dataset.tab]).hidden = false; const focusId = { keyword: 'in-banMap', regex: 'in-banRegex', user: 'in-banUser', emoji: 'in-banEmoji', ai: 'aiApiUrl' }[btn.dataset.tab]; if (focusId) { const f = shadow.getElementById(focusId); if (f) f.focus(); } }); // 面板内点击委托 content.addEventListener('click', (e) => { const del = e.target.closest('.tag-del'); if (del) { removeItem(del.dataset.cat, del.dataset.value); return; } const addBtn = e.target.closest('[data-add]'); if (addBtn) { addItem(addBtn.dataset.add); return; } const chip = e.target.closest('.level-chip'); if (chip) { toggleLevel(chip.dataset.level); return; } const tg = e.target.closest('.toggle'); if (tg) { toggleOption(tg.dataset.opt); return; } const themeChip = e.target.closest('.theme-chip'); if (themeChip) { setTheme(themeChip.dataset.theme); return; } const exportFile = e.target.closest('#exportFileBtn'); if (exportFile) { downloadConfig(); return; } const importFileBtn = e.target.closest('#importFileBtn'); if (importFileBtn) { importFile(); return; } const reset = e.target.closest('#resetBtn'); if (reset) { resetConfig(); return; } const testAi = e.target.closest('#testAiBtn'); if (testAi) { saveAiInputs(); // 先保存表单再测试 flashStatus('测试中…'); testAiConnection().then(r => flashStatus(r.msg)); return; } const preset = e.target.closest('.preset-chip[data-preset]'); if (preset) { applyPreset(preset.dataset.preset); return; } const strict = e.target.closest('.preset-chip[data-strict]'); if (strict) { setStrictness(strict.dataset.strict); return; } const histClear = e.target.closest('#aiHistClearBtn'); if (histClear) { aiHistory.length = 0; renderAiHistory(); flashStatus('已清空屏蔽记录'); return; } }); // 输入框回车添加 / AI 输入回车保存(失焦触发) content.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; const aiInput = e.target.closest('#aiApiUrl, #aiApiKey, #aiModel, #aiPrompt, #aiBatchSize, #aiMinScore'); if (aiInput) { aiInput.blur(); return; } const input = e.target.closest('input[id^="in-"]'); if (input) { addItem(input.id.replace('in-', '')); } }); // AI 表单输入失焦自动保存(blur 不冒泡,需捕获阶段) content.addEventListener('blur', (e) => { const aiInput = e.target.closest('#aiApiUrl, #aiApiKey, #aiModel, #aiPrompt, #aiBatchSize, #aiMinScore'); if (aiInput) saveAiInputs(); }, true); // 拖拽面板(标题栏) let dragging = false, dx0 = 0, dy0 = 0; header.addEventListener('pointerdown', (e) => { if (e.target.closest('.icon-btn')) return; dragging = true; const r = panel.getBoundingClientRect(); dx0 = e.clientX - r.left; dy0 = e.clientY - r.top; header.setPointerCapture(e.pointerId); }); header.addEventListener('pointermove', (e) => { if (!dragging) return; panel.style.left = Math.max(0, Math.min(e.clientX - dx0, window.innerWidth - 90)) + 'px'; panel.style.top = Math.max(0, Math.min(e.clientY - dy0, window.innerHeight - 90)) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); header.addEventListener('pointerup', () => { dragging = false; }); header.addEventListener('pointercancel', () => { dragging = false; }); } // 启动 UI(渲染配置 + 注入面板) function boot() { initUI(); renderAll(); applyTheme(); positionFab(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();