feat: 增强弹幕屏蔽词解析与生效诊断日志 (#453)
* feat: 增强弹幕屏蔽词解析与生效诊断日志 - 重写 BLOCKED_WORDS 解析器:支持 /regex/flags 正则词条与纯文本词条混用,兼容中英文逗号(,/,)及逗号前后空格;正则词条内部的逗号不再被误切分 - 正则词条支持 i/m/s/u 等标志;自动剥离 g/y 标志避免 lastIndex 状态污染 - 非法正则自动降级为字面量匹配并输出警告,不再静默丢弃整条规则 - 新增生效诊断日志:规则解析结果、拦截总数/每条规则命中次数、拦截示例、无命中提示、配置无效警告,便于用户确认屏蔽词是否实际生效 - 更新 README 中 BLOCKED_WORDS 说明与示例 - 新增 test_blocked_words.mjs 覆盖 7 类解析场景(7/7 通过) * fix: 移除未实现的 TITLE_MAPPING_TABLE_URL 配置说明,测试合并至 worker.test.js - 删除 README 中 TITLE_MAPPING_TABLE_URL / TITLE_MAPPING_TABLE_URL_CACHE_MINUTES 两条配置说明(对应功能未包含在本 PR) - test_blocked_words.mjs 的 7 类屏蔽词解析场景合并进 danmu_api/worker.test.js,删除独立测试文件(38/38 通过) --------- Co-authored-by: xlmc <xlmc@users.noreply.github.com>
This commit is contained in:
1 parent
45e47a5436
commit
b5458edd73
3 files changed
+181
-24
No files matched your search
@@ -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|啊这|哇哦)/
|
||||
|
||||
# 注释如下:
|
||||
|
||||
+133
-21
@@ -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 为空),跳过过滤`);
|
||||
}
|
||||
} 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 => {
|
||||
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 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)}`);
|
||||
|
||||
// 过滤列表
|
||||
const filteredDanmus = danmus.filter(item => {
|
||||
return !regexArray.some(regex => regex.test(item.m)); // 针对 `m` 字段进行匹配
|
||||
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);
|
||||
|
||||
@@ -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, dedupeBangumiSearchResults } 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',
|
||||
|
||||
Reference in new issue
Block a user