diff --git a/README.md b/README.md index a8281a9..187e271 100644 --- a/README.md +++ b/README.md @@ -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) 提供的平台动画元数据 diff --git a/config/.env.example b/config/.env.example index f0182b3..32cb808 100644 --- a/config/.env.example +++ b/config/.env.example @@ -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) diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index fda6010..0ed2d48 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -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时生效 diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index 83c3ae9..c2b62a8 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -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, diff --git a/danmu_api/utils/codec-util.js b/danmu_api/utils/codec-util.js index 7d50697..934063f 100644 --- a/danmu_api/utils/codec-util.js +++ b/danmu_api/utils/codec-util.js @@ -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); diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index bb294d0..31f4055 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -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' }); } diff --git a/danmu_api/utils/nipaplay-util.js b/danmu_api/utils/nipaplay-util.js new file mode 100644 index 0000000..fd4bb83 --- /dev/null +++ b/danmu_api/utils/nipaplay-util.js @@ -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; +} + diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index c634b1f..185020b 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -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, '空对象直接返回'); +// });