feat: 弹弹play源支持经 NipaPlay 302关联链接获取弹幕,并支持 NIPAPLAY_REPLACE_DANDAN 替代原生弹幕开关 (#439)

* feat: 弹弹play源支持经 NipaPlay 302关联链接获取弹幕,并支持 NIPAPLAY_REPLACE_DANDAN 替代原生弹幕开关

当 dandanplay 原生弹幕为空(新番上线缓存空窗期)时,dandan 源借助 NipaPlay 授权的
302关联接口,经 302 重定向获取用户在弹弹play客户端实际绑定的跨平台播放链接,实时拉取
bilibili/bahamut/iqiyi/youku/tencent/mango 的弹幕作为兜底。

新增 NIPAPLAY_REPLACE_DANDAN 开关(默认关闭,经 envs 暴露、前端可配置):开启后 dandan
源以 nipaplay 关联弹幕替代弹弹原生弹幕,跳过原本的弹弹 HTTP 弹幕接口请求(不做并行,避免请求量
放大);当关联链接为空时回退弹弹自身弹幕作为安全网(因采用项目链路获取弹幕,会丢失弹弹
自身平台弹幕,多平台中巴哈需确保可连通)。

实现要点:
- 关联接口凭证由 NipaPlay 授权下发,参数经运行时派生与重组还原,源码不出现可读明文(禁止滥用);
- 签名生成、302 关联链接的解析与请求抽取至 nipaplay-util.js,dandan.js 仅负责触发与分发;
- 复用各平台既有源实例拉取弹幕,对各源零侵入;
- 兜底弹幕按真实来源标注平台标识(如 bilibili1/qq/qiyi),与手动解析链接输出一致;
- 同平台多个链接串行、间隔 1 秒请求,避免短时间内同平台并发触发风控;
- 已独立选择的合并源按源名与平台标识双重比对跳过,避免重复获取;对跳过的合并源输出日志,使合并源冲突防范可见;
- 关联接口响应仅含重定向(无弹幕体,单次线上响应约数百字节),弹幕数据来自后续按平台
  链接实时拉取,对既有请求路径无额外放大。

* test: 增加 nipaplay 弹蛋302关联工具函数单元测试(注释态)

覆盖 parseNipaplayRelatedLinks、resolveNipaplayLink、applyShiftToDanmu、generateNipaplaySignature;按仓库既有约定以注释形式提交,解除注释即可经 node --test 运行。

* fix: 移除 NIPAPLAY_REPLACE_DANDAN 配置说明反引号并改用换行

envs.js 与 .env.example 的 NIPAPLAY_REPLACE_DANDAN 说明原使用反引号包裹 false/true 与三条注意事项,前端当前无法渲染反引号。改为去掉反引号,并参照既有配置(如 USE_BANGUMI_DATA)使用换行分隔默认值、可选值与注意事项,提升前端展示可读性。

* docs: 调整 README 致谢中 NipaPlay-Reload 弹弹302关联链接描述

去掉致谢行中重复的 NipaPlay-Reload 字样,并将授权改为请求授权以准确描述链接授权性质。

* docs: README 致谢补充弹弹play开放平台链接

在 NipaPlay-Reload 提供的 302关联链接请求授权描述中,为弹弹play开放平台补充官方文档链接,与上方 danmaku-anywhere 行风格一致。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-08-15 07:57:46 +08:00
1 parent 791bf6fb5e
commit 62fd0afddf
8 files changed
+348 -21

No files matched your search

+4
View File
@@ -506,6 +506,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目录获得最佳体验,云部署使用时会将数据缓存至临时内存中如果体验不佳请关闭) |
| NIPAPLAY_REPLACE_DANDAN | 【可选】 [NipaPlay](https://github.com/AimesSoft/NipaPlay-Reload) 弹弹302关联弹幕替代开关(用于 dandan 源),默认为`false`(关闭,使用弹弹原生弹幕),可选值:`true`、`false`。开启后 dandan 源以 nipaplay 弹弹302关联弹幕替代弹弹原生弹幕,因使用的是项目链路获取弹幕所以`1.会丢失弹弹平台弹幕` `2.无法获取下架视频` `3.如果关联中有巴哈姆特平台需要确保能够连通巴哈` |
```regex
# EPISODE_TITLE_FILTER 默认值
@@ -720,6 +721,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ ├── log-util.js # 日志工具
│ ├── merge-util.js # 源合并处理工具
│ ├── migu-util.js # 咪咕工具
│ ├── nipaplay-util.js # NipaPlay 弹弹302关联链接工具
│ ├── offset-util.js # 弹幕偏移工具
│ ├── redis-util.js # redis工具
│ ├── server-listen-util.js # IPv4/IPv6 双栈监听与 IPv4 回退工具
@@ -791,6 +793,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
### 特别感谢
- 开源项目 [danmaku-anywhere](https://github.com/Mr-Quin/danmaku-anywhere) 提供的[弹弹play开放平台](https://doc.dandanplay.com/open/)接口
- 开源项目 [NipaPlay-Reload](https://github.com/AimesSoft/NipaPlay-Reload) 提供的[弹弹play开放平台](https://doc.dandanplay.com/open/)302关联链接请求授权
- 开源项目 [animeko](https://github.com/open-ani/animeko) 提供的弹幕API
- 开源项目 [bangumi-data](https://github.com/bangumi-data/bangumi-data) 提供的平台动画元数据
+12
View File
@@ -85,6 +85,18 @@ VOD_REQUEST_TIMEOUT=10000
# 默认值:8
YOUKU_CONCURRENCY=8
# ==================== NipaPlay 弹弹302关联弹幕配置 ====================
# NipaPlay 弹弹302关联弹幕替代开关(用于 dandan 源)
# 默认值:false(关闭,使用弹弹原生弹幕),可选值:true、false
# 说明:
# - 开启后 dandan 源以 nipaplay 弹弹302关联弹幕替代弹弹原生弹幕
# - 因使用的是项目链路获取弹幕所以:
# 1.会丢失弹弹平台弹幕
# 2.无法获取下架视频
# 3.如果关联中有巴哈姆特平台需要确保能够连通巴哈
# NIPAPLAY_REPLACE_DANDAN=false
# ==================== 源排序配置 ====================
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, sohu, leshi, xigua, maiduidui, aiyifan, hongguo, dandan, bahamut, animeko, custom)
+2
View File
@@ -637,6 +637,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 },
'NIPAPLAY_REPLACE_DANDAN': { category: 'source', type: 'boolean', description: 'NipaPlay 弹弹302关联弹幕替代开关(用于 dandan 源)。\n默认为 false(关闭,使用弹弹原生弹幕),可选值:true、false。\n开启后 dandan 源以 nipaplay 弹弹302关联弹幕替代弹弹原生弹幕,因使用的是项目链路获取弹幕所以:\n1.会丢失弹弹平台弹幕\n2.无法获取下架视频\n3.如果关联中有巴哈姆特平台需要确保能够连通巴哈' },
// 匹配配置
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置,可以配置自动匹配时的优选平台。\n当配置合并平台的时候,可以指定期望的合并源,\n示例:一个结果返回了"dandan&bilibili1&animeko"和"youku"时,\n当配置"youku"时返回"youku" \n当配置"dandan&animeko"时返回"dandan&bilibili1&animeko"' },
@@ -735,6 +736,7 @@ export class Envs {
commentCacheMinutes: this.get('COMMENT_CACHE_MINUTES', 3, 'number'), // 弹幕缓存时间配置(分钟,默认 3)
commentCacheMinCount: this.get('COMMENT_CACHE_MIN_COUNT', 100, 'number'), // 弹幕缓存最少条数,低于该值时忽略缓存(默认 100,0 表示关闭)
hongguoMergeAllEpisodes: this.get('HONGGUO_MERGE_ALL_EPISODES', false, 'boolean'), // 红果短剧是否合并全集弹幕(默认 false)
nipaplayReplaceDandan: this.get('NIPAPLAY_REPLACE_DANDAN', false, 'boolean'), // NipaPlay 弹弹302关联弹幕替代开关,开启后 dandan 源以 nipaplay 弹弹302关联弹幕替代弹弹原生弹幕
convertTopBottomToScroll: this.get('CONVERT_TOP_BOTTOM_TO_SCROLL', false, 'boolean'), // 顶部/底部弹幕转换为浮动弹幕配置(默认 false,禁用转换)
convertColor: this.get('CONVERT_COLOR', 'default', 'string'), // 弹幕转换颜色配置,支持 default、white、color(默认 default,禁用转换)
colorPool: this.get('COLOR_POOL', '16777215,16777215,16777215,16777215,16777215,16777215,16777215,16777215,16744319,16752762,16774799,9498256,8388564,8900346,14204888,16758465', 'string'), // 自定义颜色池,CONVERT_COLOR为color时生效
+121 -17
View File
@@ -2,6 +2,7 @@ import BaseSource from './base.js';
import { globals } from '../configs/globals.js';
import { log } from "../utils/log-util.js";
import { httpGet } from "../utils/http-util.js";
import { fetchNipaplayRelatedLinks, resolveNipaplayLink, applyShiftToDanmu } from '../utils/nipaplay-util.js';
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
import { SegmentListResponse } from '../models/dandan-model.js';
import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js";
@@ -23,6 +24,99 @@ const bahamutSource = new BahamutSource();
const DandanUserAgent = `LogVar Danmu API/${globals.version}`
// 源标识 → 平台标识映射,与核心路由一致(见 ALLOWED_PLATFORMS:bilibili1/qq/qiyi/imgo 等),
// 使 弹弹302关联弹幕经 convertToDanmakuJson 输出正确的 [平台] 标签,而非 [dandan] 或源名。
const SOURCE_TO_PLATFORM = {
bilibili: 'bilibili1',
bahamut: 'bahamut',
iqiyi: 'qiyi',
youku: 'youku',
tencent: 'qq',
imgo: 'imgo',
};
// 汇总跨平台实时弹幕,复用核心路由同款链接解析(gamer→sn)与各源既有 formatComments,
// 使每源入参与核心路由一致;按平台标识跳过已独立选择的合并源以免重复;每条弹幕按真实来源
// 平台打 _sourceLabel,且同平台多个链接串行、间隔 1 秒请求,与手动解析链接防风控一致。
export async function getRelatedDanmuViaNipaplay(episodeId, coveredSources) {
const links = await fetchNipaplayRelatedLinks(episodeId);
if (!links) return [];
const summary = Object.entries(links)
.filter(([, arr]) => arr && arr.length)
.map(([p, arr]) => `${SOURCE_TO_PLATFORM[p] || p}×${arr.length}`)
.join(', ');
if (summary) log("info", `[dandan] nipaplay 弹弹302关联兜底提取到弹弹302关联链接: ${summary}`);
const sourceMap = {
bilibili: bilibiliSource,
bahamut: bahamutSource,
iqiyi: iqiyiSource,
youku: youkuSource,
tencent: tencentSource,
imgo: mangoSource,
};
// 收集待拉取任务(按平台标识分组以便同平台串行),已独立选择的合并源跳过。
const pending = [];
const skipped = [];
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);
continue;
}
const sourceInstance = sourceMap[platform];
if (!sourceInstance) continue;
for (const { url, shift } of linksOfPlatform) {
const { source, realId } = resolveNipaplayLink(url);
if (source !== platform) {
log("info", `[dandan] nipaplay 弹弹302关联链接平台解析不一致,声明 ${platform} 实得 ${source},跳过: ${url}`);
continue;
}
pending.push({
platformLabel,
run: () => sourceInstance.getEpisodeDanmu(realId, [])
.then((raw) => sourceInstance.formatComments(raw || []).map((d) => applyShiftToDanmu({ ...d, _sourceLabel: platformLabel }, shift)))
.catch((e) => { log("error", `[dandan] nipaplay 弹弹302关联拉取 ${platformLabel} 失败: ${e.message}`); return []; }),
});
}
}
if (skipped.length) log("info", `[dandan] nipaplay 弹弹302关联兜底跳过已合并源(避免重复拉取): ${skipped.join(', ')}`);
// 同平台串行、间隔 1 秒,不同平台并行(防风控,同 PR #388 手动解析链接)。
const groups = new Map();
for (const task of pending) {
if (!groups.has(task.platformLabel)) groups.set(task.platformLabel, []);
groups.get(task.platformLabel).push(task.run);
}
const results = await Promise.all(Array.from(groups.values()).map(async (runs) => {
const items = [];
for (let i = 0; i < runs.length; i++) {
// 每个任务返回单链接弹幕数组,展开后 items 为本平台串行汇总,便于最终 flat 拉平为单条弹幕。
items.push(...await runs[i]());
if (i < runs.length - 1) await new Promise((r) => setTimeout(r, 1000));
}
return items;
}));
return results.flat().filter(Boolean);
}
// 请求弹弹play原生弹幕;失败时返回空数组,使后续 nipaplay 弹弹302关联兜底或安全网回退不受阻断。
async function fetchDandanComments(id) {
try {
const resp = await httpGet(`https://api.danmaku.weeblify.app/ddp/v1?path=%2Fv2%2Fcomment%2F${id}%3Ffrom%3D0%26withRelated%3Dtrue%26chConvert%3D0`, {
headers: {
"Content-Type": "application/json",
"User-Agent": DandanUserAgent,
},
retries: 1,
});
if (resp && resp.data && resp.data.comments) return resp.data.comments;
return [];
} catch (e) {
log("error", `[dandan] dandan base comments error: ${e.message}`);
return [];
}
}
// =====================
// 获取弹弹play弹幕
// =====================
@@ -542,27 +636,37 @@ export default class DandanSource extends BaseSource {
// 接收 mergedSources 参数,包含所有参与合并的具体源链接信息,用于避免重复获取
async getEpisodeDanmu(id, mergedSources = []) {
let allDanmus = [];
const stats = {}; // 统计各源弹幕数量
const coveredSources = new Set((mergedSources || []).map((m) => {
const src = typeof m === 'string' ? m.split(':')[0] : m?.logicalSource;
return src || '';
}).filter(Boolean));
try {
// 获取 dandan 弹幕
const dandanPromise = httpGet(`https://api.danmaku.weeblify.app/ddp/v1?path=%2Fv2%2Fcomment%2F${id}%3Ffrom%3D0%26withRelated%3Dtrue%26chConvert%3D0`, {
headers: {
"Content-Type": "application/json",
"User-Agent": DandanUserAgent,
},
retries: 1,
}).catch(e => { log('error', `[dandan] dandan base comments error: ${e.message}`); return null; });
const resp = await dandanPromise;
if (resp && resp.data && resp.data.comments) {
allDanmus = resp.data.comments;
stats['dandan'] = allDanmus.length;
if (globals.nipaplayReplaceDandan) {
// NIPAPLAY_REPLACE_DANDAN 开启:以 nipaplay 弹弹302关联弹幕替代弹弹原生弹幕,避免两者并行请求;
// 弹弹302关联链接为空时回退弹弹自身弹幕作为安全网。
const related = await getRelatedDanmuViaNipaplay(id, coveredSources);
if (related.length > 0) {
allDanmus = related;
log("info", `[dandan] NIPAPLAY_REPLACE_DANDAN 启用,nipaplay 弹弹302关联弹幕替代弹弹原生弹幕(${related.length} 条)`);
} else {
log("info", '[dandan] NIPAPLAY_REPLACE_DANDAN 弹弹302关联链接为空,回退弹弹自身弹幕');
allDanmus = await fetchDandanComments(id);
}
} else {
stats['dandan'] = 0;
// 默认:先取弹弹原生弹幕,为空时再借 nipaplayv1 弹弹302关联链接兜底拉取跨平台实时弹幕。
allDanmus = await fetchDandanComments(id);
if (allDanmus.length === 0) {
log("info", '[dandan] dandan 原生弹幕为空,触发 NipaPlay 弹弹302关联兜底');
const related = await getRelatedDanmuViaNipaplay(id, coveredSources);
if (related.length > 0) {
allDanmus = related;
log("info", `[dandan] nipaplay 弹弹302关联兜底补充 ${related.length} 条跨平台弹幕`);
} else {
log("info", '[dandan] nipaplay 弹弹302关联兜底未获取到跨平台弹幕');
}
}
}
} catch (error) {
log("error", "[dandan] getEpisodeDanmu error:", {
message: error.message,
+2 -2
View File
@@ -802,7 +802,7 @@ export function stringToUtf8Bytes(str) {
}
// 修改后的 aesDecryptBase64
function aesDecryptBase64(cipherB64, keyStr) {
export function aesDecryptBase64(cipherB64, keyStr) {
try {
const cipherBytes = base64ToBytes(cipherB64);
const keyBytes = stringToUtf8Bytes(keyStr);
@@ -878,7 +878,7 @@ export function bytesToBase64(bytes) {
// ===================== SHA256 算法 =====================
// 纯 JS SHA256,返回字节数组
function sha256(ascii) {
export function sha256(ascii) {
function rightRotate(n, x) { return (x >>> n) | (x << (32 - n)); }
let maxWord = Math.pow(2, 32);
+6 -2
View File
@@ -99,6 +99,8 @@ export async function httpGet(url, options = {}) {
// 从 options 中获取重试次数,默认为 0
const maxRetries = parseInt(options.retries || '0', 10) || 0;
// GET 与 POST 行为保持一致:默认跟随重定向,allow_redirects 为 false 时禁止(用于截获 302 Location)
const allow_redirects = options.allow_redirects !== false;
// 提取允许放行的特定状态码白名单
const validStatusCodes = Array.isArray(options.validStatusCodes) ? options.validStatusCodes : [];
let lastError;
@@ -140,7 +142,8 @@ export async function httpGet(url, options = {}) {
...options.headers,
},
signal: controller.signal,
agent: nodeFetchAgent
agent: nodeFetchAgent,
redirect: allow_redirects ? 'follow' : 'manual'
});
} else {
// 现代浏览器环境
@@ -149,7 +152,8 @@ export async function httpGet(url, options = {}) {
headers: {
...options.headers,
},
signal: controller.signal
signal: controller.signal,
redirect: allow_redirects ? 'follow' : 'manual'
});
}
+151
View File
@@ -0,0 +1,151 @@
import { aesDecryptBase64, sha256, bytesToBase64, utf8BytesToString } from './codec-util.js';
import { log } from './log-util.js';
import { httpGet } from './http-util.js';
import { globals } from '../configs/globals.js';
// nipaplayv1 弹弹302关联链接凭证:NipaPlay 授权本项目的 dandan 源在 0 弹幕时,
// 借此向 api.dandanplay.net 换取跨平台(b站/巴哈)绑定链接并自行实时拉取。
// 凭证参数经运行时派生与重组还原,源码不出现可读明文(禁止滥用)。
const _keyMask = new Uint8Array([237, 67, 45, 109, 89, 4, 82, 107, 109, 211, 243, 208, 85, 182, 233, 79]);
const _keyXored = new Uint8Array([163, 42, 93, 12, 127, 72, 61, 12, 59, 178, 129, 246, 103, 84, 105, 196]);
const NIPAPLAY_AES_KEY = utf8BytesToString(new Uint8Array(_keyXored.map((b, i) => b ^ _keyMask[i])));
// 片段,运行期重组为完整值(禁止滥用)。
const _appIdFrag = ['wBQ0gL26', 'oPlbCiLn', 'f37+sQ=='];
const NIPAPLAY_APP_ID_CIPHERTEXT = _appIdFrag.join('');
// 片段,运行期重组为完整值(禁止滥用)。
const _secretFrag = ['9CB0Qo6tW1', 'CfUDx3jCtV', 'rUat/EMK+x', 'voco1Y2MF8', 'YQUuM14JDN', '1/wWqIRHP', 'H/buF'];
const NIPAPLAY_APP_SECRET_CIPHERTEXT = _secretFrag.join('');
export const NIPAPLAY_APP_ID = aesDecryptBase64(NIPAPLAY_APP_ID_CIPHERTEXT, NIPAPLAY_AES_KEY);
export const NIPAPLAY_APP_SECRET = aesDecryptBase64(NIPAPLAY_APP_SECRET_CIPHERTEXT, NIPAPLAY_AES_KEY);
// 由 nipaplayv1 凭证生成 dandanplay 开放接口所需的 X-Signature
export function generateNipaplaySignature(appId, timestamp, apiPath, appSecret) {
return bytesToBase64(sha256(`${appId}${timestamp}${apiPath}${appSecret}`));
}
// 域名到内部源标识的映射,覆盖 dandanplay 允许绑定的全部平台,
// 使解析对未出现在 nipaplayv1 返回中的平台亦具备识别能力而不致静默丢失;
// 各平台均已接入对应采集源,分发阶段直接复用既有源拉取弹幕。
const RELATED_PLATFORM_BY_HOST = {
'bilibili.com': 'bilibili',
'b23.tv': 'bilibili',
'gamer.com.tw': 'bahamut',
'iqiyi.com': 'iqiyi',
'youku.com': 'youku',
'qq.com': 'tencent',
'mgtv.com': 'imgo',
};
// 从 302 Location 解析 nipaplay 弹弹302关联链接:读取 urls(| 分隔)与 shift(, 分隔)两个参数,
// 按各 URL 主机名映射到内部源标识并还原时间偏移;平台标识由主机名推导,不依赖 302 自带的 related 字段。
// 返回的链接取决于用户在弹弹play客户端实际绑定的平台,可能覆盖 b站/巴哈/爱奇艺/
// 优酷/腾讯/芒果(Imgo) 中任意组合,上述全部平台均已接入对应采集源。
export function parseNipaplayRelatedLinks(location) {
const result = { bilibili: [], bahamut: [], iqiyi: [], youku: [], tencent: [], imgo: [] };
if (!location || typeof location !== 'string') return result;
let parsed;
try {
parsed = new URL(location);
} catch {
return result;
}
const urlsParam = parsed.searchParams.get('urls');
if (!urlsParam) return result;
const urls = urlsParam.split('|').map((entry) => entry.trim()).filter(Boolean);
const shifts = (parsed.searchParams.get('shift') || '')
.split(',').map((value) => { const n = Number(value); return Number.isFinite(n) ? n : 0; });
for (let i = 0; i < urls.length; i++) {
let host = '';
try { host = new URL(urls[i]).host; } catch { host = ''; }
const hostKey = Object.keys(RELATED_PLATFORM_BY_HOST)
.find((key) => host.endsWith(key)) || null;
if (!hostKey) {
log('info', `[nipaplay] 弹弹302关联链接含未支持平台,跳过: ${urls[i]}`);
continue;
}
const platform = RELATED_PLATFORM_BY_HOST[hostKey];
const shift = shifts[i] || 0;
if (platform === 'bilibili') {
const bMatch = urls[i].match(/bilibili\.com\/video\/(BV[0-9A-Za-z]+)/i);
const pMatch = urls[i].match(/[?&]p=(\d+)/);
const clean = bMatch
? `https://www.bilibili.com/video/${bMatch[1]}` + (pMatch ? `?p=${pMatch[1]}` : '')
: urls[i];
result.bilibili.push({ url: clean, shift });
continue;
}
result[platform].push({ url: urls[i], shift });
}
return result;
}
// 发起带签名的 nipaplayv1 弹弹302关联请求并截获 302 重定向地址;凭证、签名与解析均来自本模块,
// 使 dandan.js 只需消费解析后的弹弹302关联链接而无需感知 nipaplay 协议细节。
const NIPAPLAY_USER_AGENT = `LogVar Danmu API/${globals.version}`;
export async function fetchNipaplayRelatedLinks(episodeId) {
if (!NIPAPLAY_APP_ID || !NIPAPLAY_APP_SECRET) {
log('info', '[nipaplay] 凭证未就绪,跳过弹弹302关联兜底');
return null;
}
const timestamp = Math.round(Date.now() / 1000);
const apiPath = `/api/v2/comment/${episodeId}`;
const signature = generateNipaplaySignature(NIPAPLAY_APP_ID, timestamp, apiPath, NIPAPLAY_APP_SECRET);
const url = `https://api.dandanplay.net${apiPath}?withRelated=true&chConvert=0`;
try {
const resp = await httpGet(url, {
headers: {
'Accept': 'application/json',
'User-Agent': NIPAPLAY_USER_AGENT,
'X-AppId': NIPAPLAY_APP_ID,
'X-Timestamp': String(timestamp),
'X-Signature': signature,
},
allow_redirects: false,
validStatusCodes: [302],
retries: 1,
});
if (resp.status !== 302 || !resp.headers.location) {
log('info', `[nipaplay] 弹弹302关联链接未返回 302 (status=${resp.status})`);
return null;
}
return parseNipaplayRelatedLinks(resp.headers.location);
} catch (error) {
log('error', `[nipaplay] 弹弹302关联链接请求失败: ${error.message}`);
return null;
}
}
// 解析 nipaplay 弹弹302关联链接为 {source, realId}:平台识别复用 RELATED_PLATFORM_BY_HOST 的同一映射(与 parseNipaplayRelatedLinks 的 host 推导一致),使解析与分发两处对平台域名的识别保持一致(含无 www 前缀的裸域名与 b站短链 b23.tv)。
// 平台确定后按各源 getEpisodeDanmu 入参契约还原 realId(如 bahamut 提取 sn 数字、其余平台直接传递完整 URL),使弹弹302关联兜底与各源入参契约保持一致。
export function resolveNipaplayLink(url) {
let host = '';
try { host = new URL(url).host; } catch { host = ''; }
const hostKey = Object.keys(RELATED_PLATFORM_BY_HOST)
.find((key) => host.endsWith(key)) || null;
const platform = hostKey ? RELATED_PLATFORM_BY_HOST[hostKey] : null;
if (platform === 'bahamut') {
const snMatch = url.match(/sn=(\d+)/);
return { source: 'bahamut', realId: snMatch ? snMatch[1] : url };
}
if (!platform) return { source: null, realId: url };
return { source: platform, realId: url };
}
// 对已完成格式化的弹幕应用弹弹302关联链接附带的时间偏移:校正 p 的首字段(时间)与 t 字段
export function applyShiftToDanmu(danmu, shift = 0) {
if (!danmu || typeof danmu !== 'object') return danmu;
const next = { ...danmu };
if (typeof next.p === 'string') {
const parts = next.p.split(',');
const time = parseFloat(parts[0]);
if (!isNaN(time)) parts[0] = (time + shift).toFixed(2);
next.p = parts.join(',');
}
if (typeof next.t === 'number') next.t += shift;
next.isRealTimePulled = true;
return next;
}
+50
View File
@@ -50,6 +50,7 @@ import { convertToAsciiSum } from "./utils/codec-util.js";
import { convertToDanmakuJson, handleDanmusLike } 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";
// Mock Request class for testing
class MockRequest {
@@ -2727,3 +2728,52 @@ test('worker.js API endpoints', async (t) => {
// }
// });
// });
// test('nipaplay 弹弹302关联工具函数', async (t) => {
//
// // generateNipaplaySignature:相同入参确定性产出,输出为 sha256 的 base64(44 字符)
// const sig1 = generateNipaplaySignature('app', '1700000000', '/api/v2/comment/1', 'secret');
// const sig2 = generateNipaplaySignature('app', '1700000000', '/api/v2/comment/1', 'secret');
// assert.strictEqual(sig1, sig2, '相同入参签名一致');
// assert.strictEqual(sig1.length, 44, 'sha256 base64 长度为 44');
// const sig3 = generateNipaplaySignature('app', '1700000001', '/api/v2/comment/1', 'secret');
// assert.notStrictEqual(sig1, sig3, 'timestamp 不同签名不同');
//
// // parseNipaplayRelatedLinks:解析 urls(|)与 shift(,),按主机名映射到内部源并还原时间偏移
// const location = 'https://x.test/redirect?urls=https://www.bilibili.com/video/BV1xx|https://ani.gamer.com.tw/animeVideo.php?sn=12345&shift=0,30';
// const parsed = parseNipaplayRelatedLinks(location);
// assert.strictEqual(parsed.bilibili.length, 1, 'bilibili 链接被解析');
// assert.strictEqual(parsed.bilibili[0].url, 'https://www.bilibili.com/video/BV1xx', 'bilibili 仅保留 BV 主体');
// assert.strictEqual(parsed.bilibili[0].shift, 0, 'bilibili shift 为 0');
// assert.strictEqual(parsed.bahamut.length, 1, 'bahamut 链接被解析');
// assert.strictEqual(parsed.bahamut[0].url, 'https://ani.gamer.com.tw/animeVideo.php?sn=12345', 'bahamut 保留原始 URL');
// assert.strictEqual(parsed.bahamut[0].shift, 30, 'bahamut shift 为 30');
// assert.strictEqual(parsed.iqiyi.length, 0, '未提供平台为空');
// for (const k of ['bilibili', 'bahamut', 'iqiyi', 'youku', 'tencent', 'imgo']) {
// assert.deepStrictEqual(parseNipaplayRelatedLinks('')[k], [], `空字符串入参 ${k} 为空数组`);
// assert.deepStrictEqual(parseNipaplayRelatedLinks(null)[k], [], `空入参 ${k} 为空数组`);
// }
//
// // resolveNipaplayLink:主机名到源路由,bahamut 提取 sn
// assert.deepStrictEqual(resolveNipaplayLink('https://ani.gamer.com.tw/animeVideo.php?sn=999'), { source: 'bahamut', realId: '999' });
// assert.deepStrictEqual(resolveNipaplayLink('https://v.qq.com/x/cover/abc.html'), { source: 'tencent', realId: 'https://v.qq.com/x/cover/abc.html' });
// assert.deepStrictEqual(resolveNipaplayLink('https://www.bilibili.com/video/BVxyz'), { source: 'bilibili', realId: 'https://www.bilibili.com/video/BVxyz' });
// assert.deepStrictEqual(resolveNipaplayLink('https://bilibili.com/video/BVxyz'), { source: 'bilibili', realId: 'https://bilibili.com/video/BVxyz' }, '无 www 前缀的裸域名同样归入 bilibili');
// assert.deepStrictEqual(resolveNipaplayLink('https://b23.tv/BVxyz'), { source: 'bilibili', realId: 'https://b23.tv/BVxyz' }, 'b站短链 b23.tv 经统一映射归入 bilibili');
// assert.deepStrictEqual(resolveNipaplayLink('https://unknown.example/x'), { source: null, realId: 'https://unknown.example/x' });
//
// // parse 与 resolve 对 b23.tv 的识别保持一致:均归入 bilibili
// const b23Location = 'https://x.test/redirect?urls=https://b23.tv/BV1xx&shift=0';
// const b23Parsed = parseNipaplayRelatedLinks(b23Location);
// assert.strictEqual(b23Parsed.bilibili.length, 1, 'b23.tv 链接经 parse 归入 bilibili');
// assert.deepStrictEqual(resolveNipaplayLink(b23Parsed.bilibili[0].url), { source: 'bilibili', realId: b23Parsed.bilibili[0].url }, 'parse 与 resolve 对 b23.tv 的源识别一致');
//
// // applyShiftToDanmu:校正时间偏移并标记实时拉取,不污染原对象
// const src = { p: '12.34,1,25,16777215,0', t: 12.34 };
// const shifted = applyShiftToDanmu(src, 5);
// assert.strictEqual(shifted.p, '17.34,1,25,16777215,0', 'p 时间字段加偏移');
// assert.strictEqual(shifted.t, 17.34, 't 加偏移');
// assert.strictEqual(shifted.isRealTimePulled, true, '标记为实时拉取');
// assert.strictEqual(src.p, '12.34,1,25,16777215,0', '原对象未被修改');
// assert.strictEqual(applyShiftToDanmu(null, 5), null, '空对象直接返回');
// });