From d91079ebc10948873c6fb868490d1fe2ad7d19d8 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:35:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(dandan):=20=E5=BC=B9=E5=BC=B9302=E5=85=B3?= =?UTF-8?q?=E8=81=94=E9=93=BE=E6=8E=A5=E4=BB=85=E5=88=86=E5=8F=91=E7=BB=99?= =?UTF-8?q?=E5=B7=B2=E5=9C=A8=20SOURCE=5FORDER=20=E5=BC=80=E5=90=AF?= =?UTF-8?q?=E7=9A=84=E6=BA=90=20(#489)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关联链接的对应平台只要已接入采集源就会实时拉取,与 SOURCE_ORDER 表达的可选源范围无关。现要求该源已在 SOURCE_ORDER 中开启才分发,未开启的源与已独立选择的合并源一样跳过并记录日志。README、config/.env.example 与 envs.js 中 DANDANPLAY_ACCOUNT 的说明同步为「并把同一请求下发的弹弹关联链接分发给对应平台源实时拉取(需开启对应源)」。 worker.test.js 新增用例:仅开启 bilibili 时只拉取 bilibili、仅开启 tencent 时分发目标随之切换、两个关联源均未开启时不拉取、已开启但被独立选择的合并源覆盖时同样跳过。 --- README.md | 2 +- config/.env.example | 2 +- danmu_api/configs/envs.js | 2 +- danmu_api/sources/dandan.js | 28 ++++++++++----- danmu_api/worker.test.js | 72 +++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bec4746..6a332a6 100644 --- a/README.md +++ b/README.md @@ -513,7 +513,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | AI_API_KEY | 【可选】AI服务的API密钥,用于身份验证,默认为空,需手动填写 | | AI_MATCH_PROMPT | 【可选】AI匹配提示词,用于自定义AI匹配行为,不填提供默认提示词,提示词如下 | | USE_BANGUMI_DATA | 【可选】[Bangumi Data](https://github.com/bangumi-data/bangumi-data) 加速匹配开关,默认值:`false`(关闭),开启后将动画元数据缓存至本地或内存中给源调用,提升动画源的检索与匹配速度并解锁隐藏/区域番剧(本地和Docker部署使用时请先挂载.cache目录获得最佳体验,云部署使用时会将数据缓存至临时内存中如果体验不佳请关闭) | -| DANDANPLAY_ACCOUNT | 【可选】弹弹play账号(dandan 源获取弹幕使用)。与密码同时填写后自动开启:[NipaPlay](https://github.com/AimesSoft/NipaPlay-Reload) 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹302关联链接分发给已接入的对应平台源实时拉取,最终弹幕为两者合并去重后的结果。注意:关联链接指向的平台视频若已下架将无法通过自有链路补取;关联含巴哈姆特平台时需确保能够连通巴哈 | +| DANDANPLAY_ACCOUNT | 【可选】弹弹play账号(dandan 源获取弹幕使用)。与密码同时填写后自动开启:[NipaPlay](https://github.com/AimesSoft/NipaPlay-Reload) 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹关联链接分发给对应平台源实时拉取(需开启对应源),最终弹幕为两者合并去重后的结果。注意:关联链接指向的平台视频若已下架将无法通过自有链路补取;关联含巴哈姆特平台时需确保能够连通巴哈 | | DANDANPLAY_PASSWORD | 【可选】弹弹play密码(dandan 源获取弹幕使用),与账号同时填写后自动开启,UI 配置页提供连通性测试 | ```regex diff --git a/config/.env.example b/config/.env.example index c211c17..5252704 100644 --- a/config/.env.example +++ b/config/.env.example @@ -95,7 +95,7 @@ YOUKU_CONCURRENCY=8 # 弹弹play账号(dandan 源获取弹幕使用) # 说明: # - 与密码同时填写后自动开启,无需额外开关 -# - 开启后 dandan 源改由 NipaPlay 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹302关联链接分发给已接入的对应平台源实时拉取,最终弹幕为两者合并去重后的结果 +# - 开启后 dandan 源改由 NipaPlay 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹关联链接分发给对应平台源实时拉取(需开启对应源),最终弹幕为两者合并去重后的结果 # - 注意:关联链接指向的平台视频若已下架将无法通过自有链路补取;关联含巴哈姆特平台时需确保能够连通巴哈 # DANDANPLAY_ACCOUNT= diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 25a83c9..31d2f70 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -712,7 +712,7 @@ export class Envs { 'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' }, 'DOUBAN_COOKIE': { category: 'source', type: 'text', description: '豆瓣Cookie' }, 'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 }, - 'DANDANPLAY_ACCOUNT': { category: 'source', type: 'text', description: '弹弹play账号(dandan 源获取弹幕使用)。\n与密码同时填写后自动开启,无需额外开关。\n开启后 dandan 源改由 NipaPlay 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹302关联链接分发给已接入的对应平台源实时拉取:\n最终弹幕为 NipaPlay 中转弹弹play服务端弹幕与自有链路弹幕合并去重后的结果。\n注意:关联链接指向的平台视频若已下架将无法通过自有链路补取;关联含巴哈姆特平台时需确保能够连通巴哈' }, + 'DANDANPLAY_ACCOUNT': { category: 'source', type: 'text', description: '弹弹play账号(dandan 源获取弹幕使用)。\n与密码同时填写后自动开启,无需额外开关。\n开启后 dandan 源改由 NipaPlay 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹关联链接分发给对应平台源实时拉取(需开启对应源):\n最终弹幕为 NipaPlay 中转弹弹play服务端弹幕与自有链路弹幕合并去重后的结果。\n注意:关联链接指向的平台视频若已下架将无法通过自有链路补取;关联含巴哈姆特平台时需确保能够连通巴哈' }, 'DANDANPLAY_PASSWORD': { category: 'source', type: 'text', description: '弹弹play密码(dandan 源获取弹幕使用)。\n点击编辑界面的测试连通性按钮可验证账号与 NipaPlay 中转弹弹play服务端是否可用' }, // 匹配配置 diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index b245cd0..c9839ec 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -546,7 +546,8 @@ export default class DandanSource extends BaseSource { try { // 配置弹弹play账号后经 NipaPlay 中转弹弹play服务端取弹幕,并把同一请求下发的弹弹302关联链接分发给 - // 对应平台源实时拉取,两部分由去重阶段按来源合并;未配置账号或 NipaPlay 中转弹弹play服务端不可用时回退弹弹原生弹幕。 + // 已在 SOURCE_ORDER 开启的对应平台源实时拉取,两部分由去重阶段按来源合并; + // 未配置账号或 NipaPlay 中转弹弹play服务端不可用时回退弹弹原生弹幕。 const nipaplay = await fetchNipaplayDanmaku(id); if (!nipaplay) return await fetchDandanComments(id); @@ -618,13 +619,15 @@ const SOURCE_TO_PLATFORM = { }; // 汇总跨平台实时弹幕,复用核心路由同款链接解析(gamer→sn)与各源既有 formatComments, -// 使每源入参与核心路由一致;按平台标识跳过已独立选择的合并源以免重复;每条弹幕标记实时拉取 -// 来源,供 convertToDanmakuJson 组装 [来源&平台] 标签,并让去重阶段按真实来源统计重复弹幕; +// 使每源入参与核心路由一致;仅分发已在 SOURCE_ORDER 开启且未被已独立选择的合并源覆盖的平台; +// 每条弹幕标记实时拉取来源,供 convertToDanmakuJson 组装 [来源&平台] 标签,并让去重阶段按真实来源统计重复弹幕; // 同平台多个链接串行、间隔 1 秒请求,与手动解析链接防风控一致。 async function getRelatedDanmuViaNipaplay(links, coveredSources) { if (!links) return []; + // 关联链接的源需已在 SOURCE_ORDER 中开启,与搜索可选源保持一致 + const enabledSources = new Set(globals.sourceOrderArr); const summary = Object.entries(links) - .filter(([, arr]) => arr && arr.length) + .filter(([p, arr]) => arr && arr.length && enabledSources.has(p)) .map(([p, arr]) => `${SOURCE_TO_PLATFORM[p] || p}×${arr.length}`) .join(', '); if (summary) log("info", `[dandan] 弹弹302关联链接分发目标: ${summary}`); @@ -636,14 +639,20 @@ async function getRelatedDanmuViaNipaplay(links, coveredSources) { tencent: tencentSource, imgo: mangoSource, }; - // 收集待拉取任务(按平台标识分组以便同平台串行),已独立选择的合并源跳过。 + // 收集待拉取任务(按平台标识分组以便同平台串行),未在 SOURCE_ORDER 开启的源与已独立选择的合并源跳过。 const pending = []; - const skipped = []; + const skippedDisabled = []; + const skippedCovered = []; for (const [platform, linksOfPlatform] of Object.entries(links)) { if (!linksOfPlatform || linksOfPlatform.length === 0) continue; const platformLabel = SOURCE_TO_PLATFORM[platform]; - if (!platformLabel || coveredSources.has(platform) || coveredSources.has(platformLabel)) { - if (platformLabel) skipped.push(platformLabel); + if (!platformLabel) continue; + if (!enabledSources.has(platform)) { + skippedDisabled.push(platformLabel); + continue; + } + if (coveredSources.has(platform) || coveredSources.has(platformLabel)) { + skippedCovered.push(platformLabel); continue; } const sourceInstance = sourceMap[platform]; @@ -662,7 +671,8 @@ async function getRelatedDanmuViaNipaplay(links, coveredSources) { }); } } - if (skipped.length) log("info", `[dandan] 弹弹302关联分发跳过已合并源(避免重复拉取): ${skipped.join(', ')}`); + if (skippedCovered.length) log("info", `[dandan] 弹弹302关联分发跳过已合并源(避免重复拉取): ${skippedCovered.join(', ')}`); + if (skippedDisabled.length) log("info", `[dandan] 弹弹302关联分发跳过未在 SOURCE_ORDER 开启的源: ${skippedDisabled.join(', ')}`); // 同平台串行、间隔 1 秒,不同平台并行(防风控)。 const groups = new Map(); for (const task of pending) { diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index f043ec3..252e034 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -3406,6 +3406,78 @@ test('dandan formatComments 按实时拉取标记区分处理', () => { assert.strictEqual(dandan.formatComments([native])[0].p, '12.34,1,25,a16777215,0', '原生弹幕执行颜色转换'); }); +test('dandan 关联链接分发仅限已在 SOURCE_ORDER 开启的源', async () => { + const location = 'https://x.test/redirect?urls=https://www.bilibili.com/video/BV1xx|https://v.qq.com/x/cover/abc.html&shift=0,0'; + const originalOrder = Globals.envs.sourceOrderArr; + const originalAccount = Globals.envs.dandanplayAccount; + const originalPassword = Globals.envs.dandanplayPassword; + const originalBilibiliGet = BilibiliSource.prototype.getEpisodeDanmu; + const originalBilibiliFormat = BilibiliSource.prototype.formatComments; + const originalTencentGet = TencentSource.prototype.getEpisodeDanmu; + const originalTencentFormat = TencentSource.prototype.formatComments; + const pulled = []; + + // 网关登录应答、评论接口回传 302 关联链接、原生弹幕地址应答 + const gatewayFetch = async (url) => { + const target = String(url); + if (target.endsWith('/api/v2/login')) { + return mockJsonResponse({ success: true, token: 'mock-token', tokenExpireTime: '2099-01-01T00:00:00Z' }); + } + if (target.includes('/api/v2/comment/')) { + return { ok: false, status: 302, url: target, headers: new Headers({ location }), text: async () => '' }; + } + return mockJsonResponse({ comments: [] }); + }; + + try { + Globals.envs.dandanplayAccount = 'account@example.com'; + Globals.envs.dandanplayPassword = 'password'; + BilibiliSource.prototype.getEpisodeDanmu = async () => { + pulled.push('bilibili'); + return [{ cid: 1, p: '1.00,1,25,16777215,0', t: 1, m: '来自B站' }]; + }; + BilibiliSource.prototype.formatComments = (list) => list; + TencentSource.prototype.getEpisodeDanmu = async () => { pulled.push('tencent'); return []; }; + TencentSource.prototype.formatComments = (list) => list; + + await withMockFetch(gatewayFetch, async () => { + // 仅开启 bilibili:只拉取 bilibili,跳过未开启的 tencent + Globals.envs.sourceOrderArr = ['bilibili']; + const onlyBilibili = await new DandanSource().getEpisodeDanmu('ep-1'); + assert.deepStrictEqual(pulled, ['bilibili'], '仅分发已开启的源'); + assert.strictEqual(onlyBilibili.length, 1, '分发结果来自已开启的源'); + assert.strictEqual(onlyBilibili[0].realTimeSource, 'bilibili1', '标记实时拉取来源'); + + // 仅开启 tencent:分发目标随之切换 + pulled.length = 0; + Globals.envs.sourceOrderArr = ['tencent']; + await new DandanSource().getEpisodeDanmu('ep-2'); + assert.deepStrictEqual(pulled, ['tencent'], '开启源变化后分发目标随之切换'); + + // 两个关联源都未开启:全部跳过 + pulled.length = 0; + Globals.envs.sourceOrderArr = ['douban']; + const none = await new DandanSource().getEpisodeDanmu('ep-3'); + assert.deepStrictEqual(pulled, [], '未开启关联源时不拉取'); + assert.deepStrictEqual(none, [], '未开启关联源时无关联弹幕'); + + // 源已开启但已被独立选择的合并源覆盖:同样跳过,避免重复拉取 + pulled.length = 0; + Globals.envs.sourceOrderArr = ['bilibili', 'tencent']; + await new DandanSource().getEpisodeDanmu('ep-4', ['bilibili:123']); + assert.deepStrictEqual(pulled, ['tencent'], '已开启但被合并源覆盖的平台仍跳过'); + }); + } finally { + Globals.envs.sourceOrderArr = originalOrder; + Globals.envs.dandanplayAccount = originalAccount; + Globals.envs.dandanplayPassword = originalPassword; + BilibiliSource.prototype.getEpisodeDanmu = originalBilibiliGet; + BilibiliSource.prototype.formatComments = originalBilibiliFormat; + TencentSource.prototype.getEpisodeDanmu = originalTencentGet; + TencentSource.prototype.formatComments = originalTencentFormat; + } +}); + test('fongmi-api season aware scoring', () => { // 季号提取: SxxExx / 第x季 / Season N / 2x05; 综艺日期与纯集数不误判 assert.equal(extractFongmiSeasonNumber('人生切割术 S02E05'), 2);