Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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|啊这|哇哦)/

# 注释如下:
Expand Down
152 changes: 132 additions & 20 deletions danmu_api/utils/danmu-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 44 additions & 1 deletion danmu_api/worker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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',
Expand Down