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:
1 parent
791bf6fb5e
commit
62fd0afddf
8 files changed
+348
-21
No files matched your search
@@ -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) 提供的平台动画元数据
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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, '空对象直接返回');
|
||||
// });
|
||||
Reference in new issue
Block a user