feat(dandan): 弹弹302关联链接仅分发给已在 SOURCE_ORDER 开启的源 (#489)

关联链接的对应平台只要已接入采集源就会实时拉取,与 SOURCE_ORDER 表达的可选源范围无关。现要求该源已在 SOURCE_ORDER 中开启才分发,未开启的源与已独立选择的合并源一样跳过并记录日志。README、config/.env.example 与 envs.js 中 DANDANPLAY_ACCOUNT 的说明同步为「并把同一请求下发的弹弹关联链接分发给对应平台源实时拉取(需开启对应源)」。

worker.test.js 新增用例:仅开启 bilibili 时只拉取 bilibili、仅开启 tencent 时分发目标随之切换、两个关联源均未开启时不拉取、已开启但被独立选择的合并源覆盖时同样跳过。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-10-02 21:35:44 +08:00
1 parent e8851c5bc5
commit d91079ebc1
5 files changed
+94 -12

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -95,7 +95,7 @@ YOUKU_CONCURRENCY=8
# 弹弹play账号(dandan 源获取弹幕使用)
# 说明:
# - 与密码同时填写后自动开启,无需额外开关
# - 开启后 dandan 源改由 NipaPlay 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹302关联链接分发给已接入的对应平台源实时拉取,最终弹幕为两者合并去重后的结果
# - 开启后 dandan 源改由 NipaPlay 中转弹弹play服务端获取弹幕,并把同一请求下发的弹弹关联链接分发给对应平台源实时拉取(需开启对应源),最终弹幕为两者合并去重后的结果
# - 注意:关联链接指向的平台视频若已下架将无法通过自有链路补取;关联含巴哈姆特平台时需确保能够连通巴哈
# DANDANPLAY_ACCOUNT=
+1 -1
View File
@@ -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服务端是否可用' },
// 匹配配置
+19 -9
View File
@@ -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) {
+72
View File
@@ -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);