From ae368eae186e2ed56784dae7a11e07241bb5c3f3 Mon Sep 17 00:00:00 2001 From: xlmc Date: Fri, 21 Aug 2026 17:39:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E5=B1=8F=E8=94=BD=E8=AF=8D=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E7=94=9F=E6=95=88=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 BLOCKED_WORDS 解析器:支持 /regex/flags 正则词条与纯文本词条混用,兼容中英文逗号(,/,)及逗号前后空格;正则词条内部的逗号不再被误切分 - 正则词条支持 i/m/s/u 等标志;自动剥离 g/y 标志避免 lastIndex 状态污染 - 非法正则自动降级为字面量匹配并输出警告,不再静默丢弃整条规则 - 新增生效诊断日志:规则解析结果、拦截总数/每条规则命中次数、拦截示例、无命中提示、配置无效警告,便于用户确认屏蔽词是否实际生效 - 更新 README 中 BLOCKED_WORDS 说明与示例 - 新增 test_blocked_words.mjs 覆盖 7 类解析场景(7/7 通过) --- README.md | 8 +- danmu_api/utils/danmu-util.js | 152 +++++++++++++++++++++++++++++----- test_blocked_words.mjs | 47 +++++++++++ 3 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 test_blocked_words.mjs diff --git a/README.md b/README.md index 95f8b8c0f..a37bb8c96 100644 --- a/README.md +++ b/README.md @@ -466,11 +466,13 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | ENABLE_ANIME_EPISODE_FILTER | 【可选】控制手动搜索的时候是否根据ANIME_TITLE_FILTER进行剧名过滤以及根据EPISODE_TITLE_FILTER进行集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 | | STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` | | TITLE_TO_CHINESE | 【可选】是否在match自动匹配时将外语标题转换成中文标题,适用于网盘没有刮削的资源,默认值:false(不转换),说明:需配合TMDB_API_KEY使用 | -| TITLE_MAPPING_TABLE | 【可选】剧名映射表,用于自动匹配时替换标题进行搜索,格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华" | +| TITLE_MAPPING_TABLE | 【可选】剧名映射表,用于自动匹配、手动搜索、FongMi、收藏时替换标题进行搜索(对解析出的剧名做全名精确匹配),格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华" | +| TITLE_MAPPING_TABLE_URL | 【可选】远程剧名映射表地址。映射表内容托管在 GitHub 等平台由用户维护(类似共享识别词插件),程序自动拉取并生效,与 `TITLE_MAPPING_TABLE` 合并使用(key 冲突时本地优先)。支持填 GitHub 文件页链接(自动转 raw 直链)、Gist 页面链接、jsDelivr 链接或任意文本直链。内容格式与 `TITLE_MAPPING_TABLE` 相同且更宽松:支持每行一条 `原始标题->映射标题` 规则、单行分号分隔、`#` 或 `//` 注释行、全角箭头(->)等写法 | +| TITLE_MAPPING_TABLE_URL_CACHE_MINUTES | 【可选】远程剧名映射表刷新间隔(分钟),默认为 `60`,设置为 `0` 表示每次请求都检查更新(不建议,会拖慢请求),最大 `1440`。拉取失败时沿用上次内容并在 5 分钟后重试,不影响正常请求 | | AUTO_MATCH_MAPPING_TABLE | 【可选】自动匹配映射表,仅作用于 `POST /api/v2/match`,多条规则用分号分隔。开放映射 `永生 S05E02 -> 永生 S01E58` 会在源第 5 季内按集数递增映射;同标题同季度可配置多个开放规则,后面起始集数的规则会从该集开始覆盖前面的规则,例如 `一念永恒 S01E53 -> 一念永恒 S02E01;一念永恒 S01E107 -> 一念永恒 S03E01`;有限范围 `永生 S05E02~03 -> 永生 S01E58~59` 只映射包含两端的等长范围。支持目标结果优选 `海贼王 S02E01 -> 航海王(1999)【动漫】 S01E62` 和平台优选 `航海王 S01E01 -> 航海王 S01E01 @qiyi`。同一输入优先采用有限范围规则,规则起始集数相同按配置顺序;整体优先级为当前源季手动偏好 > 本映射表 > `TITLE_MAPPING_TABLE` > 普通匹配,`default` 偏好不阻断映射。限定候选不可用时回退同目标标题,映射目标失败时按原始请求重新匹配。普通搜索、收藏缓存和弹幕时间偏移不受影响。 | | TITLE_NOISE_FILTER | 【可选】剧名杂音清理规则,按正则表达式清理搜索与匹配阶段的剧名杂音词(如`百花杀(真彩)`→`百花杀`),默认值如下,设为空值可禁用 | | ANIME_TITLE_SIMPLIFIED | 【可选】是否在搜索时将繁体剧名标题自动转换为简体,适用于繁体标题搜索,默认值:false(不转换),可选值:`true`、`false` | -| BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 | +| BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,多条规则用逗号分隔(中英文逗号均可,逗号前后空格会自动忽略)。每条规则支持两种写法:正则 `/正则/` 或 `/正则/i`(可带 i/m/s/u 等标志),或直接写纯文本词(按字面匹配)。示例如下 | | GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 | | DANMU_LIMIT | 【可选】等间隔采样限制弹幕总数,单位为k,即千:默认 0,表示不限制弹幕数,若改为5,弹幕总数在超过5000的情况下会将弹幕数控制在5000 | | CONVERT_TOP_BOTTOM_TO_SCROLL | 【可选】是否将顶部和底部弹幕转换为浮动弹幕,默认为`false`(不转换),启用后顶部弹幕(ct=5)和底部弹幕(ct=4)会被转换为浮动弹幕(ct=1),可选值:`true`、`false` | @@ -523,6 +525,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 ```regex # BLOCKED_WORDS 示例值 +# 格式:多条规则用逗号分隔(中英文逗号均可);/正则/ 或 /正则/i 为正则规则,直接写的文字按纯文本字面匹配 +# 例如屏蔽纯文本词与正则混合: 剧透,广告,/^测试/,/\d{5,}/ /.{20,}/,/^\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\u4e00-\u9fa5])\1{2,}/,/[0-9]+\.*[0-9]*\s*(w|万)+\s*(\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\d{5,}$/,/^(\d)\1{2,}$/,/^\d{1,4}$/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\d{1,2}[.-]\d{1,2}/,/[@#&$%^*+\|/\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\d]+刷/,/第[一二三四五六七八九十百\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/ # 注释如下: diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index b92410fcf..b7982c65d 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -192,6 +192,91 @@ export function limitDanmusByCount(filteredDanmus, danmuLimit) { return result; } +/** + * 转义正则特殊字符,用于将纯文本屏蔽词转为字面量匹配 + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * 将 BLOCKED_WORDS 字符串切分为词条数组: + * - /pattern/flags 正则词条整体识别,其内部的逗号不作为分隔符 + * - 兼容中文全角逗号(,)与英文逗号(,),以及逗号前后的空格 + * - 识别失败时(如字符类中的裸 / )将 / 作为普通字符处理 + */ +export function splitBlockedWords(raw) { + if (!raw || typeof raw !== 'string') return []; + const normalized = raw.replace(/,/g, ','); + + // 尝试从 start(指向 '/')消费一个完整的 /pattern/flags 词条, + // 成功返回词条结束位置(分隔符处或末尾),失败返回 -1 + const tryConsumeRegexToken = (start) => { + let j = start + 1; + while (j < normalized.length) { + if (normalized[j] === '\\') { j += 2; continue; } + if (normalized[j] === '/') { + // 候选闭合点:其后须为可选 flags(后跟分隔符或结尾)才构成完整词条 + const rest = normalized.slice(j + 1); + const flagMatch = rest.match(/^([a-z]*)(\s*)(?:,|$)/); + if (flagMatch) { + // 仅消费 flags 与空白,分隔符留给主循环处理 + return j + 1 + flagMatch[1].length + flagMatch[2].length; + } + } + j++; + } + return -1; + }; + + const segments = []; + let buf = ''; + let i = 0; + while (i < normalized.length) { + const ch = normalized[i]; + if (ch === '/') { + const end = tryConsumeRegexToken(i); + if (end !== -1) { + buf += normalized.slice(i, end); + i = end; + continue; + } + buf += ch; + i++; + continue; + } + if (ch === ',') { + segments.push(buf); + buf = ''; + i++; + continue; + } + buf += ch; + i++; + } + if (buf.trim() !== '') segments.push(buf); + return segments.map(s => s.trim()).filter(s => s !== ''); +} + +/** + * 将单个屏蔽词条转换为正则对象: + * - /pattern/ 或 /pattern/flags 按正则解析(忽略 g/y 标志,避免 lastIndex 状态影响 test 结果) + * - 纯文本词条按字面量匹配 + * - 非法正则降级为字面量匹配并输出警告日志 + */ +export function parseBlockedWord(segment) { + const match = segment.match(/^\/([\s\S]+)\/([a-z]*)$/); + if (match) { + try { + return new RegExp(match[1], match[2].replace(/[gy]/g, '')); + } catch (e) { + log("warn", `[system] [danmu] 无效的屏蔽词正则(已按字面量处理): ${segment}`, e?.message || e); + return new RegExp(escapeRegExp(segment)); + } + } + return new RegExp(escapeRegExp(segment)); +} + export function convertToDanmakuJson(contents, platform) { let danmus = []; let cidCounter = 1; @@ -308,31 +393,58 @@ export function convertToDanmakuJson(contents, platform) { log("info", `[system] [danmu] [danmu convert] 转换了 ${danmus.length} 条弹幕为${targetLabel}`); } - // 切割字符串成正则表达式数组 - const regexArray = globals.blockedWords.split(/(?<=\/),(?=\/)/).map(str => { - // 去除两端的斜杠并转换为正则对象 - const pattern = str.trim(); - if (pattern.startsWith('/') && pattern.endsWith('/')) { - try { - // 去除两边的 `/` 并转化为正则 - return new RegExp(pattern.slice(1, -1)); - } catch (e) { - log("error", `[system] [danmu] 无效的正则表达式: ${pattern}`, e); - return null; - } + // ===================== + // 屏蔽词过滤(含生效诊断日志) + // ===================== + // 解析屏蔽词:支持 /regex/、/regex/flags 及纯文本词,兼容中英文逗号及空格分隔 + const blockedSegments = splitBlockedWords(globals.blockedWords); + const regexArray = blockedSegments.map(parseBlockedWord); + + // [诊断1] 解析阶段:确认规则是否正确加载 + if (regexArray.length === 0) { + if (globals.blockedWords && globals.blockedWords.trim() !== '') { + log("warn", `[system] [danmu] [blocked-words] ❌ 已配置屏蔽词但未解析出有效规则,本次不会过滤任何弹幕!原始配置: ${JSON.stringify(globals.blockedWords)}`); + } else { + log("info", `[system] [danmu] [blocked-words] 未配置屏蔽词(BLOCKED_WORDS 为空),跳过过滤`); } - return null; // 如果不是有效的正则格式则返回 null - }).filter(regex => regex !== null); // 过滤掉无效的项 - - log("info", `[system] [danmu] 原始屏蔽词字符串: ${globals.blockedWords}`); - const regexArrayToString = array => Array.isArray(array) ? array.map(regex => regex.toString()).join('\n') : String(array); - log("info", `[system] [danmu] 屏蔽词列表: ${regexArrayToString(regexArray)}`); + } else { + log("info", `[system] [danmu] [blocked-words] 规则解析成功: 共 ${regexArray.length} 条 [ ${regexArray.map(r => r.toString()).join(' , ')} ]`); + } - // 过滤列表 + // 过滤列表(统计每条规则命中次数与拦截样本) + const ruleHitCounts = new Array(regexArray.length).fill(0); + const blockedSamples = []; const filteredDanmus = danmus.filter(item => { - return !regexArray.some(regex => regex.test(item.m)); // 针对 `m` 字段进行匹配 + for (let i = 0; i < regexArray.length; i++) { + if (regexArray[i].test(item.m)) { // 针对 `m` 字段进行匹配 + ruleHitCounts[i]++; + if (blockedSamples.length < 3) { + blockedSamples.push(`「${String(item.m).slice(0, 30)}」← ${regexArray[i].toString()}`); + } + return false; + } + } + return true; }); + // [诊断2] 过滤阶段:明确判定屏蔽词是否生效 + const removedCount = danmus.length - filteredDanmus.length; + if (regexArray.length > 0) { + if (removedCount > 0) { + const hitSummary = regexArray + .map((r, i) => ({ rule: r.toString(), count: ruleHitCounts[i] })) + .filter(x => x.count > 0) + .map(x => `${x.rule} ×${x.count}`) + .join(', '); + log("info", `[system] [danmu] [blocked-words] ✅ 屏蔽词已生效: 拦截 ${removedCount}/${danmus.length} 条弹幕${hitSummary ? `,命中明细: ${hitSummary}` : ''}`); + if (blockedSamples.length) { + log("info", `[system] [danmu] [blocked-words] 拦截示例(最多3条): ${blockedSamples.join(' | ')}`); + } + } else { + log("info", `[system] [danmu] [blocked-words] ⚠️ 规则已加载(${regexArray.length} 条)但本集弹幕无命中`); + } + } + // 按n分钟内去重 log("info", `[system] [danmu] 去重分钟数: ${globals.groupMinute}`); const groupedDanmus = groupDanmusByMinute(filteredDanmus, globals.groupMinute, isMultiSource); diff --git a/test_blocked_words.mjs b/test_blocked_words.mjs new file mode 100644 index 000000000..3274c1a51 --- /dev/null +++ b/test_blocked_words.mjs @@ -0,0 +1,47 @@ +// 复现 + 验证 BLOCKED_WORDS 屏蔽词解析 +import { Globals, globals } from './danmu_api/configs/globals.js'; +import { convertToDanmakuJson, splitBlockedWords, parseBlockedWord } from './danmu_api/utils/danmu-util.js'; + +Globals.init({}); // 加载默认配置 (groupMinute=1, danmuLimit=0 ...) + +const sample = [ + { timepoint: '1.00', ct: 1, color: 16777215, content: '前方剧透警告' }, + { timepoint: '2.00', ct: 1, color: 16777215, content: '测试弹幕一' }, + { timepoint: '3.00', ct: 1, color: 16777215, content: 'AD广告内容' }, + { timepoint: '4.00', ct: 1, color: 16777215, content: '正常弹幕' }, +]; + +async function run(label, blockedWords, expectGone, expectKeep = ['正常弹幕']) { + globals.blockedWords = blockedWords; + const out = await convertToDanmakuJson(structuredClone(sample), 'test'); + const texts = out.map(d => d.m); + const stillThere = expectGone.filter(w => texts.some(t => t.includes(w))); + const lost = expectKeep.filter(w => !texts.some(t => t.includes(w))); + const ok = stillThere.length === 0 && lost.length === 0; + console.log(`${ok ? 'PASS' : 'FAIL'} [${label}] 输入=${JSON.stringify(blockedWords)}`); + console.log(` 剩余弹幕: ${JSON.stringify(texts)}`); + if (stillThere.length) console.log(` >> 未屏蔽: ${JSON.stringify(stillThere)}`); + if (lost.length) console.log(` >> 误屏蔽: ${JSON.stringify(lost)}`); + return ok; +} + +const results = []; +results.push(await run('A 标准正则', '/剧透/,/广告/', ['剧透', '广告'])); +results.push(await run('B 纯文本词', '剧透,广告', ['剧透', '广告'])); +results.push(await run('C 全角逗号', '/剧透/,/广告/', ['剧透', '广告'])); +results.push(await run('D 正则带i标志', '/ad/i,/^测试/', ['AD广告', '测试'])); +results.push(await run('E 逗号带空格', '/剧透/, /广告/', ['剧透', '广告'])); +// F. 混合:正则+纯文本+全角逗号+空格 +results.push(await run('F 混合写法', '/^测试/, 剧透 ,/广告/', ['剧透', '广告', '测试'], ['正常弹幕'])); + +// G. README 官方示例兼容性:应解析出 16 个正则且不抛错 +const readmeSample = "/.{20,}/,/^\\d{2,4}[-/.]\\d{1,2}[-/.]\\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\\u4e00-\\u9fa5])\\1{2,}/,/[0-9]+\\.*[0-9]*\\s*(w|万)+\\s*(\\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\\d{5,}$/,/^(\\d)\\1{2,}$/,/^\\d{1,4}$/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\\d{1,2}[.-]\\d{1,2}/,/[@#&$%^*+\\|/\\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\\d]+刷/,/第[一二三四五六七八九十百\\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/"; +const segs = splitBlockedWords(readmeSample); +const regexes = segs.map(parseBlockedWord); +const gOk = segs.length === 16 && regexes.every(r => r instanceof RegExp); +console.log(`${gOk ? 'PASS' : 'FAIL'} [G README官方示例] 解析出 ${segs.length} 条规则(期望16)`); +if (!gOk) segs.forEach((s, i) => console.log(` [${i}] ${s.length > 50 ? s.slice(0, 50) + '...' : s}`)); +results.push(gOk); + +console.log(`\n==== 结果: ${results.filter(Boolean).length}/${results.length} 通过 ====`); +process.exit(results.every(Boolean) ? 0 : 1); From 7d597a6a004aa3fe63250b9e9c9468375bde6fdf Mon Sep 17 00:00:00 2001 From: xlmc Date: Sat, 22 Aug 2026 11:32:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E6=9C=AA=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=20TITLE=5FMAPPING=5FTABLE=5FURL=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=AF=B4=E6=98=8E=EF=BC=8C=E6=B5=8B=E8=AF=95=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E8=87=B3=20worker.test.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 README 中 TITLE_MAPPING_TABLE_URL / TITLE_MAPPING_TABLE_URL_CACHE_MINUTES 两条配置说明(对应功能未包含在本 PR) - test_blocked_words.mjs 的 7 类屏蔽词解析场景合并进 danmu_api/worker.test.js,删除独立测试文件(38/38 通过) --- README.md | 2 -- danmu_api/worker.test.js | 45 +++++++++++++++++++++++++++++++++++++- test_blocked_words.mjs | 47 ---------------------------------------- 3 files changed, 44 insertions(+), 50 deletions(-) delete mode 100644 test_blocked_words.mjs diff --git a/README.md b/README.md index a37bb8c96..a77faed3f 100644 --- a/README.md +++ b/README.md @@ -467,8 +467,6 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` | | TITLE_TO_CHINESE | 【可选】是否在match自动匹配时将外语标题转换成中文标题,适用于网盘没有刮削的资源,默认值:false(不转换),说明:需配合TMDB_API_KEY使用 | | TITLE_MAPPING_TABLE | 【可选】剧名映射表,用于自动匹配、手动搜索、FongMi、收藏时替换标题进行搜索(对解析出的剧名做全名精确匹配),格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华" | -| TITLE_MAPPING_TABLE_URL | 【可选】远程剧名映射表地址。映射表内容托管在 GitHub 等平台由用户维护(类似共享识别词插件),程序自动拉取并生效,与 `TITLE_MAPPING_TABLE` 合并使用(key 冲突时本地优先)。支持填 GitHub 文件页链接(自动转 raw 直链)、Gist 页面链接、jsDelivr 链接或任意文本直链。内容格式与 `TITLE_MAPPING_TABLE` 相同且更宽松:支持每行一条 `原始标题->映射标题` 规则、单行分号分隔、`#` 或 `//` 注释行、全角箭头(->)等写法 | -| TITLE_MAPPING_TABLE_URL_CACHE_MINUTES | 【可选】远程剧名映射表刷新间隔(分钟),默认为 `60`,设置为 `0` 表示每次请求都检查更新(不建议,会拖慢请求),最大 `1440`。拉取失败时沿用上次内容并在 5 分钟后重试,不影响正常请求 | | AUTO_MATCH_MAPPING_TABLE | 【可选】自动匹配映射表,仅作用于 `POST /api/v2/match`,多条规则用分号分隔。开放映射 `永生 S05E02 -> 永生 S01E58` 会在源第 5 季内按集数递增映射;同标题同季度可配置多个开放规则,后面起始集数的规则会从该集开始覆盖前面的规则,例如 `一念永恒 S01E53 -> 一念永恒 S02E01;一念永恒 S01E107 -> 一念永恒 S03E01`;有限范围 `永生 S05E02~03 -> 永生 S01E58~59` 只映射包含两端的等长范围。支持目标结果优选 `海贼王 S02E01 -> 航海王(1999)【动漫】 S01E62` 和平台优选 `航海王 S01E01 -> 航海王 S01E01 @qiyi`。同一输入优先采用有限范围规则,规则起始集数相同按配置顺序;整体优先级为当前源季手动偏好 > 本映射表 > `TITLE_MAPPING_TABLE` > 普通匹配,`default` 偏好不阻断映射。限定候选不可用时回退同目标标题,映射目标失败时按原始请求重新匹配。普通搜索、收藏缓存和弹幕时间偏移不受影响。 | | TITLE_NOISE_FILTER | 【可选】剧名杂音清理规则,按正则表达式清理搜索与匹配阶段的剧名杂音词(如`百花杀(真彩)`→`百花杀`),默认值如下,设为空值可禁用 | | ANIME_TITLE_SIMPLIFIED | 【可选】是否在搜索时将繁体剧名标题自动转换为简体,适用于繁体标题搜索,默认值:false(不转换),可选值:`true`、`false` | diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 575463a12..82ff42f7c 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -47,7 +47,7 @@ import { apitestJsContent } from './ui/js/apitest.js'; import { systemSettingsJsContent } from './ui/js/systemsettings.js'; import { previewJsContent } from './ui/js/preview.js'; import { convertToAsciiSum } from "./utils/codec-util.js"; -import { convertToDanmakuJson, handleDanmusLike } from "./utils/danmu-util.js"; +import { convertToDanmakuJson, handleDanmusLike, splitBlockedWords, parseBlockedWord } from "./utils/danmu-util.js"; import { Segment, SegmentListResponse } from "./models/dandan-model.js" import { initBangumiData, searchBangumiData, clearBangumiDataCache } from "./utils/bangumi-data-util.js"; import { generateNipaplaySignature, parseNipaplayRelatedLinks, resolveNipaplayLink, applyShiftToDanmu } from "./utils/nipaplay-util.js"; @@ -629,6 +629,49 @@ test('worker.js API endpoints', async (t) => { resetSearchState(); }); + await t.test('BLOCKED_WORDS 屏蔽词解析与过滤', async () => { + const baseEnv = { + GROUP_MINUTE: '0', + DANMU_LIMIT: '0', + CONVERT_COLOR: 'default' + }; + const sample = [ + { timepoint: '1.00', ct: 1, color: 16777215, content: '前方剧透警告' }, + { timepoint: '2.00', ct: 1, color: 16777215, content: '测试弹幕一' }, + { timepoint: '3.00', ct: 1, color: 16777215, content: 'AD广告内容' }, + { timepoint: '4.00', ct: 1, color: 16777215, content: '正常弹幕' }, + ]; + + const filterWith = async (blockedWords, expectGone, expectKeep = ['正常弹幕']) => { + Globals.init({ ...baseEnv, BLOCKED_WORDS: blockedWords }); + const out = await convertToDanmakuJson(structuredClone(sample), 'test'); + const texts = out.map(d => d.m); + for (const word of expectGone) { + assert.ok(!texts.some(t => t.includes(word)), `「${word}」应被屏蔽,实际剩余: ${JSON.stringify(texts)}`); + } + for (const word of expectKeep) { + assert.ok(texts.some(t => t.includes(word)), `「${word}」不应被误屏蔽,实际剩余: ${JSON.stringify(texts)}`); + } + }; + + // 标准正则 / 纯文本词 / 全角逗号 / 正则带 i 标志 / 逗号带空格 / 混合写法 + await filterWith('/剧透/,/广告/', ['剧透', '广告']); + await filterWith('剧透,广告', ['剧透', '广告']); + await filterWith('/剧透/,/广告/', ['剧透', '广告']); + await filterWith('/ad/i,/^测试/', ['AD广告', '测试']); + await filterWith('/剧透/, /广告/', ['剧透', '广告']); + await filterWith('/^测试/, 剧透 ,/广告/', ['剧透', '广告', '测试']); + + // README 官方示例兼容性:应解析出 16 个正则且不抛错 + const readmeSample = "/.{20,}/,/^\\d{2,4}[-/.]\\d{1,2}[-/.]\\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\\u4e00-\\u9fa5])\\1{2,}/,/[0-9]+\\.*[0-9]*\\s*(w|万)+\\s*(\\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\\d{5,}$/,/^(\\d)\\1{2,}/,/^\\d{1,4}$/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\\d{1,2}[.-]\\d{1,2}/,/[@#&$%^*+\\|/\\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\\d]+刷/,/第[一二三四五六七八九十百\\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/"; + const segs = splitBlockedWords(readmeSample); + assert.equal(segs.length, 16, `README 官方示例应解析出 16 条规则,实际 ${segs.length}`); + const regexes = segs.map(parseBlockedWord); + assert.ok(regexes.every(r => r instanceof RegExp), '所有词条均应解析为正则'); + + resetSearchState(); + }); + await t.test('Upstash Redis persists favorites without storing search or comment caches', async () => { resetFavoriteState({ UPSTASH_REDIS_REST_URL: 'https://redis.example.com', diff --git a/test_blocked_words.mjs b/test_blocked_words.mjs deleted file mode 100644 index 3274c1a51..000000000 --- a/test_blocked_words.mjs +++ /dev/null @@ -1,47 +0,0 @@ -// 复现 + 验证 BLOCKED_WORDS 屏蔽词解析 -import { Globals, globals } from './danmu_api/configs/globals.js'; -import { convertToDanmakuJson, splitBlockedWords, parseBlockedWord } from './danmu_api/utils/danmu-util.js'; - -Globals.init({}); // 加载默认配置 (groupMinute=1, danmuLimit=0 ...) - -const sample = [ - { timepoint: '1.00', ct: 1, color: 16777215, content: '前方剧透警告' }, - { timepoint: '2.00', ct: 1, color: 16777215, content: '测试弹幕一' }, - { timepoint: '3.00', ct: 1, color: 16777215, content: 'AD广告内容' }, - { timepoint: '4.00', ct: 1, color: 16777215, content: '正常弹幕' }, -]; - -async function run(label, blockedWords, expectGone, expectKeep = ['正常弹幕']) { - globals.blockedWords = blockedWords; - const out = await convertToDanmakuJson(structuredClone(sample), 'test'); - const texts = out.map(d => d.m); - const stillThere = expectGone.filter(w => texts.some(t => t.includes(w))); - const lost = expectKeep.filter(w => !texts.some(t => t.includes(w))); - const ok = stillThere.length === 0 && lost.length === 0; - console.log(`${ok ? 'PASS' : 'FAIL'} [${label}] 输入=${JSON.stringify(blockedWords)}`); - console.log(` 剩余弹幕: ${JSON.stringify(texts)}`); - if (stillThere.length) console.log(` >> 未屏蔽: ${JSON.stringify(stillThere)}`); - if (lost.length) console.log(` >> 误屏蔽: ${JSON.stringify(lost)}`); - return ok; -} - -const results = []; -results.push(await run('A 标准正则', '/剧透/,/广告/', ['剧透', '广告'])); -results.push(await run('B 纯文本词', '剧透,广告', ['剧透', '广告'])); -results.push(await run('C 全角逗号', '/剧透/,/广告/', ['剧透', '广告'])); -results.push(await run('D 正则带i标志', '/ad/i,/^测试/', ['AD广告', '测试'])); -results.push(await run('E 逗号带空格', '/剧透/, /广告/', ['剧透', '广告'])); -// F. 混合:正则+纯文本+全角逗号+空格 -results.push(await run('F 混合写法', '/^测试/, 剧透 ,/广告/', ['剧透', '广告', '测试'], ['正常弹幕'])); - -// G. README 官方示例兼容性:应解析出 16 个正则且不抛错 -const readmeSample = "/.{20,}/,/^\\d{2,4}[-/.]\\d{1,2}[-/.]\\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\\u4e00-\\u9fa5])\\1{2,}/,/[0-9]+\\.*[0-9]*\\s*(w|万)+\\s*(\\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\\d{5,}$/,/^(\\d)\\1{2,}$/,/^\\d{1,4}$/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\\d{1,2}[.-]\\d{1,2}/,/[@#&$%^*+\\|/\\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\\d]+刷/,/第[一二三四五六七八九十百\\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/"; -const segs = splitBlockedWords(readmeSample); -const regexes = segs.map(parseBlockedWord); -const gOk = segs.length === 16 && regexes.every(r => r instanceof RegExp); -console.log(`${gOk ? 'PASS' : 'FAIL'} [G README官方示例] 解析出 ${segs.length} 条规则(期望16)`); -if (!gOk) segs.forEach((s, i) => console.log(` [${i}] ${s.length > 50 ? s.slice(0, 50) + '...' : s}`)); -results.push(gOk); - -console.log(`\n==== 结果: ${results.filter(Boolean).length}/${results.length} 通过 ====`); -process.exit(results.every(Boolean) ? 0 : 1);