您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在发表新话题、日志、吐槽时进行敏感词检测,集成在菜单中,支持自动从API更新及手动管理敏感词列表,可配置API接口和模型。
当前为
// ==UserScript== // @name bangumi 敏感词检测 (AI自动更新) // @description 在发表新话题、日志、吐槽时进行敏感词检测,集成在菜单中,支持自动从API更新及手动管理敏感词列表,可配置API接口和模型。 // @version 0.8.5 // @author Sedoruee // @license Sedoruee // @include /^https?://(bgm\.tv|chii\.in|bangumi\.tv)/* // @grant GM_xmlHttpRequest // @grant GM.xmlHttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant window.open // @namespace https://greasyfork.org/users/1383632 // ==/UserScript== (function() { let OPENAI_API_KEY; let OPENAI_API_ENDPOINT; let OPENAI_API_MODEL; let API_CALL_FREQUENCY_DAYS_SETTING; let ENABLE_SENSITIVE_CHECK; const SENSITIVE_WORDS_SOURCE_URL = "https://bgm.tv/group/topic/349681"; let Sensitive_words = []; const scriptLogs = []; const MAX_LOG_ENTRIES = 200; const MAX_CONTENT_LENGTH_FOR_API = 9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999; // 增大最大内容长度,因为现在是发送整个页面的文本。 const MAX_ITERATIONS = 5; // 设置最大迭代次数,防止无限循环 const DEFAULT_SENSITIVE_WORDS = [ "白粉", "香艳", "习近平", "服务中心", "李克强", "支那", "前列腺", "办证", "辦證", "毕业证", "畢業證", "冰毒", "安乐死", "腾讯", "隐形眼镜", "聊天记录", "枪", "电动车", "医院", "烟草", "早泄", "精神病", "毒枭", "春节", "当场死亡", "步枪", "步槍", "春药", "春藥", "大发", "大發", "大麻", "代开", "代開", "迷药", "代考", "贷款", "貸款", "发票", "發票", "海洛因", "妓女", "可卡因", "批发", "批發", "皮肤病", "皮膚病", "嫖娼", "窃听器", "竊听器", "上门服务", "上門服务", "商铺", "商鋪", "手枪", "手槍", "铁枪", "鐵枪", "钢枪", "鋼枪", "特殊服务", "特殊服務", "騰訊", "罂粟", "牛皮癣", "甲状腺", "假钞", "香烟", "香煙", "学位证", "學位證", "摇头丸", "搖頭丸", "援交", "找小姐", "找小妹", "作弊", "v信", "医疗政策", "迷魂药", "迷情粉", "迷藥", "麻醉药", "肛门", "麻果", "麻古", "假币", "私人侦探", "提现", "借腹生子", "代孕", "客服电话", "刻章", "套牌车", "麻将机", "走私" ].sort((a, b) => a.localeCompare(b, 'zh-CN')); function logScriptMessage(message, type = 'info') { const timestamp = new Date().toLocaleTimeString(); scriptLogs.push({ timestamp, message, type }); if (scriptLogs.length > MAX_LOG_ENTRIES) { scriptLogs.shift(); } } async function fetchTopicContent(url) { logScriptMessage(`尝试获取话题内容: ${url}`, "info"); return new Promise((resolve) => { const xhrMethod = (typeof GM !== 'undefined' && GM.xmlHttpRequest) || (typeof GM_xmlHttpRequest !== 'undefined' && GM_xmlHttpRequest); if (!xhrMethod) { logScriptMessage("GM_xmlHttpRequest不可用,无法获取话题内容。", "error"); return resolve(""); } xhrMethod({ method: "GET", url: url, onload: function(response) { logScriptMessage(`获取话题内容请求响应状态: ${response.status}`, "debug"); logScriptMessage(`获取话题内容原始响应文本(部分): ${response.responseText.substring(0, 500)}... (总长度: ${response.responseText.length})`, "debug"); if (response.status === 200) { try { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); let allContent = doc.body ? doc.body.textContent : ''; // --- 精细化预处理,移除固定非楼层内容 --- // 移除头部导航和用户信息等固定区域 const headerPattern = /(Bangumi 番组计划.*?短信\s+\|\s*设置\s+\|\s*登出)/s; allContent = allContent.replace(headerPattern, ''); // 移除话题标题上方的分类链接、标题本身及发布者信息等 // 匹配 "全部 动画 书籍 游戏 音乐 三次元 人物 用户脚本 · 样式 · 插件 » 讨论[脚本]敏感词检测" const topicHeaderInfoPattern = /(全部\s+动画\s+书籍\s+游戏\s+音乐\s+三次元\s+人物\s+用户脚本\s+·\s+样式\s+·\s+插件\s+»\s+讨论\[脚本\]敏感词检测)/s; allContent = allContent.replace(topicHeaderInfoPattern, ''); // 移除话题标题和发布者/时间等元数据 (如果未被topicHeaderInfoPattern覆盖) allContent = allContent.replace(/(话题:\s*敏感词检测\s*发帖人:\s*.*?时间:\s*\d{4}-\d{2}-\d{2}\s*\d{2}:\d{2})/s, ''); // 移除脚本变量定义,如 var totHistory = ... const scriptVarPattern = /var\s+totHistory\s+=\s+'.*?';\s*var\s+COLLAPSE_REPLIES\s+=\s*true;/s; allContent = allContent.replace(scriptVarPattern, ''); // 移除底部版权信息、联系方式等 const footerPattern = /(Contact|帮助|关于我们|使用条款|隐私政策|©\s*\d{4}\s*Bangumi|粤ICP备.*?$)/s; allContent = allContent.replace(footerPattern, ''); // 移除回复之间的固定连接文字(回复、贴贴、绝交、报告疑虑等)以及回复编号、时间、用户名、UID // 模式大致为:#数字 - YYYY-MM-DD HH:MM (回复) (贴贴(数字)) (绝交) (报告疑虑) 用户名 (用户ID) // 这一步是为了让AI更专注于用户回复内容本身 const replyMetaPattern = /#\d+\s*-\s*\d{4}-\d{1,2}-\d{1,2}\s+\d{2}:\d{2}(?:\s+回复)?(?:\s+贴贴\s*\(\d+\))?(?:\s+绝交)?(?:\s+报告疑虑)?(?:\s+.*?\s*\(uid:\s*\d+\))?/g; allContent = allContent.replace(replyMetaPattern, '\n'); // 移除其他可能出现在文本中的UI元素或固定文本 allContent = allContent.replace(/(收藏话题|新吐槽|写评论|条评论|赞|踩|举报|主题|分类|标签|加载更多回复)/g, ''); // 移除分页链接: « 上一页 1 2 3 ... N 下一页 » allContent = allContent.replace(/(«\s*上一页)?(?:\s*\d+)?(?:\s*\d+\s*)*(\s*\.\.\.)?(\s*\d+)?(?:\s*下一页\s*»)?/g, ''); // 将所有连续的空白符(包括换行、制表符等)替换为单个空格,并去除首尾空白 allContent = allContent.replace(/\s+/g, ' ').trim(); const finalContent = allContent; if (finalContent) { logScriptMessage(`成功获取并预处理网页文本内容。内容摘要: ${finalContent.substring(0, Math.min(finalContent.length, 100))}... (总长度: ${finalContent.length})`, "info"); resolve(finalContent); } else { logScriptMessage("从网页获取并预处理后的文本内容为空。", "warning"); resolve(""); } } catch (e) { logScriptMessage(`解析话题HTML内容失败: ${e.message}`, "error"); resolve(""); } } else { logScriptMessage(`获取话题内容失败: ${response.status} ${response.statusText}`, "error"); resolve(""); } }, onerror: function(response) { logScriptMessage(`获取话题内容请求失败: ${response.status} ${response.statusText}`, "error"); resolve(""); }, ontimeout: function() { logScriptMessage("获取话题内容请求超时。", "error"); resolve(""); } }); }); } // 封装迭代获取敏感词的函数 async function performIterativeSensitiveWordFetch() { let allNewWordsCollected = []; // 存储所有迭代中发现的新词 let stopConditionMet = false; // 控制循环停止的条件 let iteration = 0; const fullTopicContent = await fetchTopicContent(SENSITIVE_WORDS_SOURCE_URL); if (!fullTopicContent) { displayPanelMessage("无法获取有效的话题内容,API更新中止。", "error"); logScriptMessage("无法获取有效的话题内容,API更新中止。", "error"); return []; } while (!stopConditionMet && iteration < MAX_ITERATIONS) { iteration++; logScriptMessage(`开始第 ${iteration} 轮敏感词API检测...`, "info"); // 在发送给API之前,对内容进行截断,确保不超过API限制 const contentToSendToAPI = fullTopicContent.substring(0, MAX_CONTENT_LENGTH_FOR_API); // 告知AI已知的敏感词,让它返回"新的" const knownWordsPrompt = Sensitive_words.length > 0 ? `Here is the list of sensitive words you are already aware of, please do not include them in your response: ${Sensitive_words.join(', ')}.` : 'You are starting with no known sensitive words.'; const conversationContext = ` Below is the content from a forum topic page '${SENSITIVE_WORDS_SOURCE_URL}'. The full page content for analysis (trimmed to ${MAX_CONTENT_LENGTH_FOR_API} characters) is: ${contentToSendToAPI} ${knownWordsPrompt} Your task is to identify and list *all* any *new* words or short phrases that are considered "sensitive" or could lead to censorship/moderation on a public forum in this context. Provide all new words in a single response if possible. Format your response strictly as "{ADD:{word1,word2,word3,...}}" if you find any new sensitive words. If no new sensitive words are found or the list is complete based on the context, respond with "{ADD:{null}}". Do not include any other text, explanations, or formatting besides the specified format. `; const requestBody = JSON.stringify({ model: OPENAI_API_MODEL, messages: [ { role: "system", content: "You are a helpful assistant specialized in identifying sensitive words for online forums based on context." }, { role: "user", content: conversationContext } ], max_tokens: 200, // max_tokens 限制的是AI生成部分的长度,而不是输入prompt的长度 temperature: 0.1 }); logScriptMessage(`发送到API的请求体 (第 ${iteration} 轮): ${requestBody}`, "debug"); const xhrMethod = (typeof GM !== 'undefined' && GM.xmlHttpRequest) || (typeof GM_xmlHttpRequest !== 'undefined' && GM_xmlHttpRequest); if (!xhrMethod) { logScriptMessage("GM_xmlHttpRequest不可用,无法进行迭代获取。", "error"); stopConditionMet = true; break; } const response = await new Promise(resolve => { xhrMethod({ method: "POST", url: OPENAI_API_ENDPOINT, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${OPENAI_API_KEY}` }, data: requestBody, timeout: 30000, onload: function(res) { resolve(res); }, onerror: function(res) { resolve(res); }, ontimeout: function() { resolve({ status: 0, statusText: "Timeout", responseText: "" }); } }); }); logScriptMessage(`从API收到的原始响应 (第 ${iteration} 轮): ${response.responseText}`, "debug"); let currentFetchNewWords = []; let currentIterationSuccessfulResponse = false; // 标记当前迭代是否收到了有效API响应 (不一定是找到了新词) try { const data = JSON.parse(response.responseText); if (data.choices && data.choices.length > 0 && data.choices[0].message) { const content = data.choices[0].message.content.trim(); const match = content.match(/\{ADD:\{(.*?)\}\}/); if (match && match[1]) { const wordsString = match[1]; if (wordsString.toLowerCase() === 'null' || wordsString === '') { logScriptMessage(`API返回{ADD:{null}}或空,第 ${iteration} 轮未识别到新词。`, "info"); stopConditionMet = true; // 达到停止条件 } else { currentFetchNewWords = wordsString.split(',').map(w => w.trim()).filter(w => w.length > 0); logScriptMessage(`API检测: 第 ${iteration} 轮发现 ${currentFetchNewWords.length} 个新敏感词。`, "success"); const addedCount = updateSensitiveWords(currentFetchNewWords); // 更新全局Sensitive_words并获取实际添加数量 if (addedCount === 0) { // 如果没有新的词被添加到全局列表,说明AI返回的词都已经存在 logScriptMessage(`API返回的词已全部存在于当前敏感词列表中,视为已获取所有新词。`, "info"); stopConditionMet = true; } else { allNewWordsCollected = allNewWordsCollected.concat(currentFetchNewWords.filter(word => !allNewWordsCollected.includes(word))); // 将本次发现的独特新词加入总收集列表 } } currentIterationSuccessfulResponse = true; // 成功解析了API的预期格式响应 } else { displayPanelMessage("API返回格式异常。", "warning"); logScriptMessage(`API响应格式异常 (第 ${iteration} 轮),预期'{ADD:{...}}',得到: ${content}`, "warning"); stopConditionMet = true; // 格式异常也停止 } } else if (data.error) { displayPanelMessage(`API错误 (第 ${iteration} 轮): ${data.error.message}`, "error"); logScriptMessage(`API错误响应 (第 ${iteration} 轮): ${data.error.message}`, "error"); stopConditionMet = true; // 遇到错误停止 } else { displayPanelMessage("API返回数据结构异常。", "warning"); logScriptMessage(`API响应结构异常或无choices (第 ${iteration} 轮): ${JSON.stringify(data)}`, "warning"); stopConditionMet = true; // 结构异常停止 } } catch (e) { displayPanelMessage("API响应解析失败。", "error"); logScriptMessage(`解析API响应失败或数据异常 (第 ${iteration} 轮): ${e.message},响应文本: ${response.responseText}`, "error"); stopConditionMet = true; // 解析错误停止 } // 每次成功接收到API响应后保存名单 (无论是否找到新词) // 避免在出现错误(如网络问题、API Key错误)时覆盖已有的词库或更新时间 if (currentIterationSuccessfulResponse || (response.status === 200 && !data.error)) { // 检查是否为成功的HTTP响应或成功解析了JSON await GM.setValue('bangumi_sensitive_words_list', JSON.stringify(Sensitive_words)); await GM.setValue('bangumi_sensitive_words_last_update', Date.now().toString()); logScriptMessage(`敏感词列表已通过第 ${iteration} 轮API更新并保存到存储。`, "success"); } } // 修复bug:确保无论何种结束条件,都会显示最终状态信息 if (allNewWordsCollected.length > 0) { displayPanelMessage(`迭代API检测完成。共发现并添加 ${allNewWordsCollected.length} 个新敏感词。`, "success"); } else if (stopConditionMet && iteration <= MAX_ITERATIONS) { // 这覆盖了 {ADD:{null}} 的情况,表示在达到最大迭代次数之前就确定没有新词 displayPanelMessage("迭代API检测完成。未发现新的敏感词。", "info"); } else if (iteration === MAX_ITERATIONS && !stopConditionMet) { displayPanelMessage(`迭代API检测达到最大次数 (${MAX_ITERATIONS} 轮),可能仍有未发现的词。`, "warning"); } else { // 这是为了捕获其他异常情况,例如 API 错误但没有显式在上面被处理,或者其他意外中止 displayPanelMessage("API检测过程中止或未完成,请检查日志。", "error"); } return allNewWordsCollected; // 返回本次所有发现的新词 } function updateSensitiveWords(newWords) { if (!newWords || newWords.length === 0) { logScriptMessage("没有新的唯一词汇需要添加到列表。", "info"); return 0; // 返回0表示没有新词被添加 } const currentSet = new Set(Sensitive_words); let addedCount = 0; newWords.forEach(word => { if (!currentSet.has(word)) { Sensitive_words.push(word); currentSet.add(word); addedCount++; } }); if (addedCount > 0) { logScriptMessage(`已添加${addedCount}个新的唯一敏感词到列表。`, "info"); Sensitive_words.sort(); } else { logScriptMessage("所有获取到的词汇已存在于敏感词列表。", "info"); } return addedCount; // 返回实际添加的词数量 } async function initializeSensitiveWords() { OPENAI_API_KEY = await GM.getValue('openai_api_key', "YOUR_OPENAI_API_KEY_HERE"); OPENAI_API_ENDPOINT = await GM.getValue('openai_api_endpoint', "https://api.openai.com/v1/chat/completions"); OPENAI_API_MODEL = await GM.getValue('openai_api_model', "gpt-3.5-turbo"); API_CALL_FREQUENCY_DAYS_SETTING = await GM.getValue('api_call_frequency_days', 30); ENABLE_SENSITIVE_CHECK = await GM.getValue('enable_sensitive_check', true); const lastUpdateStr = await GM.getValue('bangumi_sensitive_words_last_update', null); const storedWordsJson = await GM.getValue('bangumi_sensitive_words_list', null); let needsApiUpdate = true; if (storedWordsJson) { try { const storedWords = JSON.parse(storedWordsJson); if (Array.isArray(storedWords) && storedWords.length > 0) { Sensitive_words = storedWords; logScriptMessage("从存储加载敏感词列表。", "info"); } else { logScriptMessage("存储的敏感词为空或无效,使用默认列表。", "warning"); Sensitive_words = [...DEFAULT_SENSITIVE_WORDS]; } } catch (e) { logScriptMessage("解析存储的敏感词时出错: " + e.message, "error"); Sensitive_words = [...DEFAULT_SENSITIVE_WORDS]; } } else { Sensitive_words = [...DEFAULT_SENSITIVE_WORDS]; logScriptMessage("存储中未找到敏感词,使用默认列表。", "info"); } if (lastUpdateStr) { const lastUpdateTimestamp = parseInt(lastUpdateStr, 10); const now = Date.now(); const timeDiffDays = (now - lastUpdateTimestamp) / (1000 * 60 * 60 * 24); if (timeDiffDays < API_CALL_FREQUENCY_DAYS_SETTING) { needsApiUpdate = false; logScriptMessage(`敏感词列表最新(${timeDiffDays.toFixed(1)}天前)。无需自动API调用。`, "info"); } } if (needsApiUpdate) { logScriptMessage("触发敏感词自动API更新。", "info"); await performIterativeSensitiveWordFetch(); // performIterativeSensitiveWordFetch 内部会负责保存列表和更新时间 logScriptMessage("敏感词列表初始化更新流程完成。", "info"); } logScriptMessage("当前活跃敏感词列表大小: " + Sensitive_words.length, "info"); } function sensitive_check(obj){ obj.on('blur keyup input', function() { if (!ENABLE_SENSITIVE_CHECK) return; // 创建当前敏感词列表的副本,避免在循环中修改原列表导致迭代问题 const currentSensitiveWords = [...Sensitive_words]; currentSensitiveWords.forEach( (el) => { let patt = new RegExp(el,"g"); let text = obj.val(); if(patt.exec(text)){ if (confirm("发现敏感词:" + el + ",是否替换?")){ let r = prompt("敏感词:" + el + ",替换为:"); if (r !== null) { // 用户可能点击取消 obj.val(text.replace(el,r)); logScriptMessage(`敏感词 "${el}" 已被用户替换。`, "info"); } } else { // 用户选择不替换,且通常意味着不希望此词再次触发,从活跃列表中移除 const index = Sensitive_words.indexOf(el); if (index > -1) { Sensitive_words.splice(index, 1); logScriptMessage(`敏感词 "${el}" 已被用户暂时从活跃列表中移除。`, "info"); } } } }); }); } GM_addStyle(` #sensitive-words-panel-trigger { position: fixed; top: 100px; right: 0; background-color: #f0f0f0; border: 1px solid #ccc; border-right: none; padding: 5px 10px; cursor: pointer; z-index: 9999; font-size: 12px; border-radius: 5px 0 0 5px; opacity: 0.7; transition: opacity 0.2s; } #sensitive-words-panel-trigger:hover { opacity: 1; } #sensitive-words-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400px; max-width: 90%; max-height: 80%; background-color: #fff; border: 1px solid #ccc; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 10000; display: none; flex-direction: column; padding: 20px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 14px; overflow-y: auto; } #sensitive-words-panel h3 { margin-top: 0; margin-bottom: 15px; color: #333; text-align: center; } #sensitive-words-panel label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; } #sensitive-words-panel input[type="text"], #sensitive-words-panel input[type="number"], #sensitive-words-panel textarea { width: calc(100% - 16px); padding: 8px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; } #sensitive-words-panel input[type="checkbox"] { margin-right: 5px; } #sensitive-words-panel .setting-item { display: flex; align-items: center; margin-bottom: 15px; } #sensitive-words-panel .setting-item label { margin-bottom: 0; margin-left: 5px; font-weight: normal; } #sensitive-words-panel textarea { min-height: 150px; resize: vertical; } #sensitive-words-panel button { padding: 10px 15px; margin-right: 10px; background-color: #007bff; color: white; border: none; border-radius: 4 dappx; cursor: pointer; font-size: 14px; transition: background-color 0.2s; } #sensitive-words-panel button.secondary-btn { background-color: #6c757d; } #sensitive-words-panel button:hover { background-color: #0056b3; } #sensitive-words-panel button.secondary-btn:hover { background-color: #5a6268; } #sensitive-words-panel button:last-child { margin-right: 0; } #sensitive-words-panel .button-group { display: flex; justify-content: flex-end; margin-top: 15px; } #sensitive-words-panel .button-group-left { display: flex; justify-content: flex-start; margin-top: 15px; } #sensitive-words-panel .panel-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid #eee; } #sensitive-words-panel .close-button { position: absolute; top: 10px; right: 15px; font-size: 20px; cursor: pointer; color: #888; } #sensitive-words-panel .close-button:hover { color: #333; } #sensitive-words-panel #panel-message { margin-top: 10px; padding: 8px; border-radius: 4px; font-size: 12px; text-align: center; display: none; } #sensitive-words-panel #panel-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } #sensitive-words-panel #panel-message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } #sensitive-words-panel #panel-message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } #panel-log-section { display: flex; flex-direction: column; overflow-y: auto; max-height: 400px; border: 1px solid #eee; padding: 10px; border-radius: 4px; margin-top: 15px; } #panel-log-section h4 { margin-top: 0; margin-bottom: 10px; color: #333; } #panel-log-content div { padding: 5px 0; border-bottom: 1px dashed #eee; font-size: 12px; word-break: break-word; } #panel-log-content div:last-child { border-bottom: none; } #panel-log-content .log-info { color: #333; } #panel-log-content .log-success { color: #155724; } #panel-log-content .log-warning { color: #856404; } #panel-log-content .log-error { color: #721c24; } #panel-log-content .log-debug { color: #888; } .dialog_menu > ul > li > a.sensitive-word-settings-link { padding: 5px 10px; display: block; color: #333; text-decoration: none; transition: background-color 0.2s; } .dialog_menu > ul > li > a.sensitive-word-settings-link:hover { background-color: #f0f0f0; } `); let panelDiv; let panelMainContentDiv; let panelLogSectionDiv; let panelLogContentDiv; let panelTextArea; let panelLastUpdateSpan; let panelApiKeyInput; let panelApiEndpointInput; let panelApiModelInput; let panelApiFrequencyInput; let panelEnableCheckInput; let panelMessageDiv; let panelShowLogButton; function createPanel() { panelDiv = document.createElement('div'); panelDiv.id = 'sensitive-words-panel'; panelDiv.innerHTML = ` <span class="close-button" id="panel-close-button">×</span> <h3>敏感词设置与管理</h3> <div id="panel-message"></div> <div id="panel-main-content"> <label for="panel-api-key">OpenAI API Key:</label> <input type="text" id="panel-api-key" placeholder="sk-..." autocomplete="off"> <label for="panel-api-endpoint">OpenAI API Endpoint:</label> <input type="text" id="panel-api-endpoint" placeholder="https://api.openai.com/v1/chat/completions"> <label for="panel-api-model">OpenAI API Model:</label> <input type="text" id="panel-api-model" placeholder="gpt-3.5-turbo"> <label for="panel-api-frequency">API 更新频率 (天):</label> <input type="number" id="panel-api-frequency" min="1"> <div class="setting-item"> <input type="checkbox" id="panel-enable-check"> <label for="panel-enable-check">启用敏感词检测</label> </div> <label for="panel-words-textarea">敏感词列表 (每行一个词):</label> <textarea id="panel-words-textarea"></textarea> <p>最近更新日期: <span id="panel-last-update">N/A</span></p> </div> <div id="panel-log-section" style="display: none;"> <h4>脚本日志</h4> <div id="panel-log-content"></div> </div> <div class="panel-footer"> <div class="button-group-left"> <button id="report-sensitive-words-button" class="secondary-btn">报告敏感词</button> <button id="show-log-button" class="secondary-btn">日志</button> </div> <div class="button-group"> <button id="refresh-api-button">更新提示词</button> <button id="save-manual-button">保存所有设置</button> </div> </div> `; document.body.appendChild(panelDiv); panelMainContentDiv = document.getElementById('panel-main-content'); panelLogSectionDiv = document.getElementById('panel-log-section'); panelLogContentDiv = document.getElementById('panel-log-content'); panelTextArea = document.getElementById('panel-words-textarea'); panelLastUpdateSpan = document.getElementById('panel-last-update'); panelApiKeyInput = document.getElementById('panel-api-key'); panelApiEndpointInput = document.getElementById('panel-api-endpoint'); panelApiModelInput = document.getElementById('panel-api-model'); panelApiFrequencyInput = document.getElementById('panel-api-frequency'); panelEnableCheckInput = document.getElementById('panel-enable-check'); panelMessageDiv = document.getElementById('panel-message'); panelShowLogButton = document.getElementById('show-log-button'); document.getElementById('panel-close-button').addEventListener('click', closePanel); document.getElementById('refresh-api-button').addEventListener('click', handleApiRefresh); document.getElementById('save-manual-button').addEventListener('click', handleManualSave); document.getElementById('report-sensitive-words-button').addEventListener('click', () => { window.open(SENSITIVE_WORDS_SOURCE_URL, '_blank'); logScriptMessage("用户点击报告敏感词按钮,打开来源URL。", "info"); }); panelApiFrequencyInput.addEventListener('input', () => displayPanelMessage('', '')); panelEnableCheckInput.addEventListener('change', () => displayPanelMessage('', '')); panelShowLogButton.addEventListener('click', toggleLogView); const triggerButton = document.createElement('div'); triggerButton.id = 'sensitive-words-panel-trigger'; triggerButton.textContent = '敏感词设置'; triggerButton.addEventListener('click', openPanel); document.body.appendChild(triggerButton); } async function openPanel() { panelDiv.style.display = 'flex'; panelTextArea.value = Sensitive_words.join('\n'); panelApiKeyInput.value = await GM.getValue('openai_api_key', ''); panelApiEndpointInput.value = await GM.getValue('openai_api_endpoint', "https://api.openai.com/v1/chat/completions"); panelApiModelInput.value = await GM.getValue('openai_api_model', "gpt-3.5-turbo"); panelApiFrequencyInput.value = await GM.getValue('api_call_frequency_days', 30); panelEnableCheckInput.checked = await GM.getValue('enable_sensitive_check', true); const lastUpdateStr = await GM.getValue('bangumi_sensitive_words_last_update', null); if (lastUpdateStr) { panelLastUpdateSpan.textContent = new Date(parseInt(lastUpdateStr, 10)).toLocaleString(); } else { panelLastUpdateSpan.textContent = 'N/A'; } displayPanelMessage('', ''); showMainContent(); } function closePanel() { panelDiv.style.display = 'none'; logScriptMessage("面板已关闭。", "info"); } function displayPanelMessage(message, type) { panelMessageDiv.textContent = message; panelMessageDiv.className = `panel-message ${type}`; panelMessageDiv.style.display = message ? 'block' : 'none'; // 不再重复记录displayPanelMessage的信息到logScriptMessage,避免重复 // logScriptMessage(message, type); } async function handleApiRefresh() { displayPanelMessage("正在通过API刷新敏感词...", "warning"); const key = panelApiKeyInput.value.trim(); const endpoint = panelApiEndpointInput.value.trim(); const model = panelApiModelInput.value.trim(); const frequency = parseInt(panelApiFrequencyInput.value.trim(), 10); const enableCheck = panelEnableCheckInput.checked; if (!key) { displayPanelMessage("错误: API Key 不能为空。", "error"); return; } if (!endpoint) { displayPanelMessage("错误: API Endpoint 不能为空。", "error"); return; } if (!model) { displayPanelMessage("错误: API Model 不能为空。", "error"); return; } if (isNaN(frequency) || frequency < 1) { displayPanelMessage("错误: 更新频率必须是大于等于1的整数。", "error"); return; } OPENAI_API_KEY = key; OPENAI_API_ENDPOINT = endpoint; OPENAI_API_MODEL = model; API_CALL_FREQUENCY_DAYS_SETTING = frequency; ENABLE_SENSITIVE_CHECK = enableCheck; await GM.setValue('openai_api_key', OPENAI_API_KEY); await GM.setValue('openai_api_endpoint', OPENAI_API_ENDPOINT); await GM.setValue('openai_api_model', OPENAI_API_MODEL); await GM.setValue('api_call_frequency_days', API_CALL_FREQUENCY_DAYS_SETTING); await GM.setValue('enable_sensitive_check', ENABLE_SENSITIVE_CHECK); await performIterativeSensitiveWordFetch(); panelTextArea.value = Sensitive_words.join('\n'); const lastUpdateStr = await GM.getValue('bangumi_sensitive_words_last_update', null); if (lastUpdateStr) { panelLastUpdateSpan.textContent = new Date(parseInt(lastUpdateStr, 10)).toLocaleString(); } else { panelLastUpdateSpan.textContent = 'N/A'; } // 最终的面板消息在 performIterativeSensitiveWordFetch 内部处理 } async function handleManualSave() { const wordsText = panelTextArea.value.trim(); let newWords = []; if (wordsText) { newWords = wordsText.split('\n').map(w => w.trim()).filter(w => w.length > 0); newWords = [...new Set(newWords)]; // 去重 newWords.sort(); } Sensitive_words = newWords; await GM.setValue('bangumi_sensitive_words_list', JSON.stringify(Sensitive_words)); await GM.setValue('bangumi_sensitive_words_last_update', Date.now().toString()); // 手动保存也更新时间戳 const key = panelApiKeyInput.value.trim(); const endpoint = panelApiEndpointInput.value.trim(); const model = panelApiModelInput.value.trim(); const frequency = parseInt(panelApiFrequencyInput.value.trim(), 10); const enableCheck = panelEnableCheckInput.checked; // 保存所有面板设置 OPENAI_API_KEY = key || "YOUR_OPENAI_API_KEY_HERE"; OPENAI_API_ENDPOINT = endpoint || "https://api.openai.com/v1/chat/completions"; OPENAI_API_MODEL = model || "gpt-3.5-turbo"; API_CALL_FREQUENCY_DAYS_SETTING = (!isNaN(frequency) && frequency >= 1) ? frequency : 30; // 确保合法性 ENABLE_SENSITIVE_CHECK = enableCheck; await GM.setValue('openai_api_key', OPENAI_API_KEY); await GM.setValue('openai_api_endpoint', OPENAI_API_ENDPOINT); await GM.setValue('openai_api_model', OPENAI_API_MODEL); await GM.setValue('api_call_frequency_days', API_CALL_FREQUENCY_DAYS_SETTING); await GM.setValue('enable_sensitive_check', ENABLE_SENSITIVE_CHECK); panelLastUpdateSpan.textContent = new Date(Date.now()).toLocaleString(); panelTextArea.value = Sensitive_words.join('\n'); displayPanelMessage("所有设置和敏感词已保存。", "success"); logScriptMessage("用户手动保存所有设置和敏感词。", "info"); } function updateLogDisplay() { panelLogContentDiv.innerHTML = ''; scriptLogs.forEach(entry => { const logEntryDiv = document.createElement('div'); logEntryDiv.className = `log-${entry.type}`; logEntryDiv.innerHTML = `<strong>[${entry.timestamp}]</strong> ${entry.message}`; panelLogContentDiv.appendChild(logEntryDiv); }); panelLogContentDiv.scrollTop = panelLogContentDiv.scrollHeight; } function showMainContent() { panelMainContentDiv.style.display = 'block'; panelLogSectionDiv.style.display = 'none'; panelShowLogButton.textContent = '日志'; logScriptMessage("面板切换到设置视图。", "debug"); } function showLogContent() { panelMainContentDiv.style.display = 'none'; panelLogSectionDiv.style.display = 'flex'; panelShowLogButton.textContent = '设置'; updateLogDisplay(); logScriptMessage("面板切换到日志视图。", "debug"); } function toggleLogView() { if (panelLogSectionDiv.style.display === 'none') { showLogContent(); } else { showMainContent(); } } function injectMenuItem(menuUl) { if (!menuUl) return; if (menuUl.querySelector('a.sensitive-word-settings-link')) { return; } const newLi = document.createElement('li'); const newA = document.createElement('a'); newA.href = "javascript:void(0)"; newA.className = "sensitive-word-settings-link"; newA.innerHTML = '◇ 敏感词设置'; newA.addEventListener('click', (event) => { event.preventDefault(); openPanel(); }); newLi.appendChild(newA); menuUl.appendChild(newLi); logScriptMessage("敏感词设置链接已注入菜单。", "info"); } const observerConfig = { childList: true, subtree: true }; const menuObserver = new MutationObserver((mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { const menuContainer = node.matches('.dialog_menu') ? node : node.querySelector('.dialog_menu'); if (menuContainer) { const menuUl = menuContainer.querySelector('ul'); if (menuUl) { injectMenuItem(menuUl); } } } }); } } }); menuObserver.observe(document.body, observerConfig); (async function() { createPanel(); await initializeSensitiveWords(); // 仅在检测功能开启时,才绑定事件监听器 if (ENABLE_SENSITIVE_CHECK) { // 新话题/编辑话题页 if(location.href.match(/new_topic|topic\/\d+\/edit/)){ logScriptMessage("检测到话题编辑页面,绑定敏感词检测。", "info"); sensitive_check($("#title")); sensitive_check($("#content")); } // 新日志/编辑日志页 if(location.href.match(/blog\/create|blog\/\d+\/edit/)){ logScriptMessage("检测到日志编辑页面,绑定敏感词检测。", "info"); sensitive_check($("#title")); sensitive_check($("#tpc_content")); } // 条目页面(吐槽、评论) if(location.href.match(/subject\/\d+/)){ logScriptMessage("检测到条目页面,绑定敏感词检测。", "info"); // 仅在存在这些元素时绑定 if ($("#title").length) sensitive_check($("#title")); // 部分条目页可能没有title输入框 if ($("#content").length) sensitive_check($("#content")); // 评论框 if ($("#comment").length) sensitive_check($("#comment")); // 旧版评论框或吐槽 } } else { logScriptMessage("敏感词检测功能当前处于关闭状态。", "info"); } })(); })(); (function() { 'use strict'; // 正则表达式 // g: 全局匹配(查找所有出现) // i: 忽略大小写 const TARGET_REGEX = /[Ss]一串/g; const REPLACEMENT_STRING = 's君'; // --------------------------------------------------------------------- // 辅助函数:处理输入框(input/textarea)中的替换并尝试保留光标位置 // --------------------------------------------------------------------- function replaceAndPreserveCursor(inputElement) { const originalValue = inputElement.value; const selectionStart = inputElement.selectionStart; const selectionEnd = inputElement.selectionEnd; let newValue = ""; let lastIndex = 0; let newSelectionStart = selectionStart; let newSelectionEnd = selectionEnd; let match; const tempRegex = new RegExp(TARGET_REGEX); // 使用临时正则,避免修改全局正则的 lastIndex while ((match = tempRegex.exec(originalValue)) !== null) { const matchedString = match[0]; // 实际匹配到的字符串 const startIndex = match.index; const endIndex = tempRegex.lastIndex; // next search starts here // 拼接匹配项之前的内容 newValue += originalValue.substring(lastIndex, startIndex); // 拼接替换后的字符串 newValue += REPLACEMENT_STRING; // 计算长度差异 const lengthDifference = REPLACEMENT_STRING.length - matchedString.length; // 如果光标在当前匹配项之后,需要调整光标位置 if (startIndex < selectionStart) { newSelectionStart += lengthDifference; newSelectionEnd += lengthDifference; } else if (startIndex <= selectionStart && selectionStart < endIndex) { // 如果光标在匹配项内部,将其移动到替换后的字符串的末尾 newSelectionStart = startIndex + REPLACEMENT_STRING.length; newSelectionEnd = startIndex + REPLACEMENT_STRING.length; } lastIndex = endIndex; } // 拼接所有匹配项之后的内容 newValue += originalValue.substring(lastIndex); // 只有在内容发生变化时才更新值,避免不必要的DOM操作和事件触发 if (originalValue !== newValue) { inputElement.value = newValue; // 恢复调整后的光标位置 inputElement.setSelectionRange(newSelectionStart, newSelectionEnd); } } // 辅助函数:处理 contenteditable 元素中的替换(光标保留较复杂,此处可能重置) function replaceContentEditable(element) { // 对于 contenteditable,直接替换 innerHTML 可能导致光标位置重置 // 但能保留大部分HTML结构。更高级的光标保留需要使用 Range 和 Selection API。 const originalHTML = element.innerHTML; const newHTML = originalHTML.replace(TARGET_REGEX, REPLACEMENT_STRING); if (originalHTML !== newHTML) { element.innerHTML = newHTML; // 注意:这里光标可能会被重置到元素的开头或结尾。 // 如果需要精确的光标控制,contenteditable 的处理会复杂得多。 } } // 函数:遍历并替换文本节点中的内容 function replaceTextNodes(rootElement = document.body) { // 如果 rootElement 不存在或不是元素节点,则不处理 if (!rootElement || rootElement.nodeType !== Node.ELEMENT_NODE) { return; } const walker = document.createTreeWalker( rootElement, NodeFilter.SHOW_TEXT, null, // No custom filter, accept all text nodes false ); let node; const textNodesToModify = []; while ((node = walker.nextNode())) { // 排除 script, style 标签内的文本,以及可编辑元素(这些由输入监听器处理) if (node.parentNode && ( node.parentNode.tagName === 'SCRIPT' || node.parentNode.tagName === 'STYLE' || node.parentNode.isContentEditable || (node.parentNode.tagName === 'INPUT' && (node.parentNode.type === 'text' || node.parentNode.type === 'search' || node.parentNode.type === 'email' || node.parentNode.type === 'url')) || // 排除 input node.parentNode.tagName === 'TEXTAREA' // 排除 textarea )) { continue; } // 检查文本内容是否包含目标字符串 if (node.nodeValue && node.nodeValue.match(TARGET_REGEX)) { textNodesToModify.push(node); } } // 批量修改文本节点,避免在遍历时修改DOM导致walker失效 textNodesToModify.forEach(node => { node.nodeValue = node.nodeValue.replace(TARGET_REGEX, REPLACEMENT_STRING); }); } // 函数:设置输入框和 contenteditable 元素的实时监听 // 使用 WeakSet 存储已监听的元素,防止重复添加事件监听器 const monitoredElements = new WeakSet(); function setupInputMonitoringForElement(element) { if (!element || monitoredElements.has(element)) { return; // 已经监听过此元素或元素无效 } monitoredElements.add(element); if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { // 对 input 和 textarea 使用 'input' 事件进行实时检测 element.addEventListener('input', (event) => replaceAndPreserveCursor(event.target)); // 对已存在的输入框内容进行首次检查 if (element.value && element.value.match(TARGET_REGEX)) { replaceAndPreserveCursor(element); } } else if (element.isContentEditable) { // 对 contenteditable 元素使用 'input' 事件 element.addEventListener('input', (event) => replaceContentEditable(event.target)); // 对已存在的 contenteditable 内容进行首次检查 if (element.textContent && element.textContent.match(TARGET_REGEX)) { // 使用 textContent 进行快速检查 replaceContentEditable(element); // 实际替换使用 innerHTML } } } function setupAllInputMonitoring() { // 查找当前页面上所有的 input[type="text"], input[type="search"], input[type="email"], input[type="url"], textarea, 和 contenteditable 元素 const editableElements = document.querySelectorAll( 'input[type="text"], input[type="search"], input[type="email"], input[type="url"], textarea, [contenteditable="true"]' ); editableElements.forEach(setupInputMonitoringForElement); } function setupMutationObserver() { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.TEXT_NODE) { // 检查新添加的文本节点 if (node.parentNode && !(node.parentNode.tagName === 'SCRIPT' || node.parentNode.tagName === 'STYLE' || node.parentNode.isContentEditable)) { if (node.nodeValue && node.nodeValue.match(TARGET_REGEX)) { node.nodeValue = node.nodeValue.replace(TARGET_REGEX, REPLACEMENT_STRING); } } } else if (node.nodeType === Node.ELEMENT_NODE) { // 检查新添加的元素及其子元素中的文本节点 replaceTextNodes(node); // 检查新添加的元素中是否有可编辑元素 const editableElementsInNewNode = node.querySelectorAll( 'input[type="text"], input[type="search"], input[type="email"], input[type="url"], textarea, [contenteditable="true"]' ); editableElementsInNewNode.forEach(setupInputMonitoringForElement); // 如果新添加的元素本身就是可编辑元素 if (node.matches('input[type="text"], input[type="search"], input[type="email"], input[type="url"], textarea, [contenteditable="true"]')) { setupInputMonitoringForElement(node); } } }); } }); }); // 观察整个文档的 body 元素,配置为观察子节点的添加/删除,以及所有子树的变动 // 确保 body 存在,因为脚本可能在 DOMContentLoaded 之前执行 (如果 run-at 是 document-start) if (document.body) { observer.observe(document.body, { childList: true, subtree: true }); } else { // 如果 body 还没加载,等它加载后再观察 window.addEventListener('load', () => observer.observe(document.body, { childList: true, subtree: true })); } } function initializeScript() { // 1. 扫描并替换页面上已有的文本内容 replaceTextNodes(); // 2. 设置输入框和 contenteditable 元素的实时监听 setupAllInputMonitoring(); // 3. 启动 MutationObserver 监听未来 DOM 变化 setupMutationObserver(); } // 油猴脚本通常在 DOMContentLoaded 之后运行,但为了确保 body 存在, // 使用 document_idle 或 DOMContentLoaded 都可以。 // 如果 `@run-at document-idle`,则不需要额外的 DOMContentLoaded 监听,但保险起见可以留着。 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeScript); } else { initializeScript(); } })();