diff --git a/README.md b/README.md index 95f8b8c0f..a77faed3f 100644 --- a/README.md +++ b/README.md @@ -466,11 +466,11 @@ 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、收藏时替换标题进行搜索(对解析出的剧名做全名精确匹配),格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华" | | 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 +523,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/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',