diff --git a/README.md b/README.md index e27840b..e28771a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ LogVar 弹幕 API 服务器 --- -一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋帆弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/hf等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。 +一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋帆红弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/hf等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。 本项目仅为个人学习爱好开发,代码开源。如有任何侵权行为,请联系本人删除。 @@ -77,7 +77,7 @@ LogVar 弹幕 API 服务器 - 本地和Docker部署支持实时保存缓存到文件(挂载.cache目录即可) - **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Edgeone 一键部署、Cloudflare 一键部署、Hugging Face Spaces部署和 Docker 一键启动。 - **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime,支持记住集episode,下次自动匹配时会对集进行偏移【实验性】。 -- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔咪狐乐西埋巴Ani播放链接可获取弹幕,如`senplayer`。 +- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔咪狐乐西埋巴Ani红播放链接可获取弹幕,如`senplayer`。 - 支持空格分隔多个链接合并弹幕,例如:`https://www.iqiyi.com/v_xxx.html https://v.qq.com/x/cover/xxx.html` - 支持链接尾部追加时间偏移,例如:`https://www.iqiyi.com/v_xxx.html@-50`(提前50秒)、`https://v.qq.com/x/cover/xxx.html@%11`(百分比缩放) - **弹幕转换功能**:支持通过环境变量配置弹幕转换规则,包括: @@ -140,7 +140,7 @@ LogVar 弹幕 API 服务器 # 构建forward弹幕插件 node build-forward-widget.js # 测试forward弹幕插件 - node danmu_api/forward-widget.test.js + node forward/forward-widget.test.js ``` 5. **测试 API**: @@ -158,6 +158,30 @@ LogVar 弹幕 API 服务器 - `GET http://{ip}:9321/87654321/api/logs` > 注意:TOKEN为默认87654321的情况下,可不带{TOKEN}请求,如`http://{ip}:9321/api/v2/search/anime?keyword=生万物` +### Forward 真机调试 + +在电脑上启动实时日志接收服务: + +```bash +node danmu_api/server.js +``` + +再在另一个终端生成可与正式插件并存的 debug bundle: + +```bash +node build-forward-widget.js --debug +``` + +在 Forward 中安装 `dist/logvar-danmu.debug.js`,将 `debugEndpoint` 配置为带 token 的电脑局域网地址,例如 `http://192.168.1.10:9321/87654321`。不要填写 `127.0.0.1`,它在手机上指向手机自身。 + +真机复现时,handler 开始/结束、参数、结果摘要、`info/warn/error`、直接 `console` 输出,以及所有 HTTP GET/POST 的 URL、状态、耗时和异常会实时显示在服务端终端。相同内容也会以 `[ForwardRemote]` 前缀写入 `/api/logs`: + +```text +GET http://127.0.0.1:9321/87654321/api/logs +``` + +服务端不会保存 trace session,也不提供回放接口。正式 bundle 不包含日志回传代码;Cookie、token 和 API key 会在上传前脱敏。日志回传失败不会影响弹幕主流程。 + ## 使用 Docker 运行 1. **构建 Docker 镜像**: ```bash @@ -351,7 +375,7 @@ LogVar 弹幕 API 服务器 -4. 现已支持手动搜索标题输入爱优腾芒哔咪狐乐西埋巴Ani播放链接获取弹幕。 +4. 现已支持手动搜索标题输入爱优腾芒哔咪狐乐西埋巴Ani红播放链接获取弹幕。 `uz`使用: 1. 弹幕拓展 -> 豆儿弹幕 @@ -410,9 +434,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 | | DOUBAN_COOKIE | 【可选】豆瓣cookie,用于豆瓣相关接口请求,配置后可降低豆瓣接口风控影响,提升搜索/详情获取的稳定性。填写浏览器中已登录豆瓣后的完整 Cookie 字符串即可,格式示例:`bid=xxxx; ll="118282"; ...`。如遇到豆瓣搜索不稳定、返回异常或频繁验证,建议优先补充该变量 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | -| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`douban,360,renren,hanjutv`,表示douban数据排在最前,hanjutv数据排在最后,示例:`douban,renren`:只返回douban数据和renren数据,且douban数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,aiyifan,renren,hanjutv,bahamut,dandan,animeko,custom` | -| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, aiyifan, renren, hanjutv, bahamut, dandan, animeko, custom` | -| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`dandan&bahamut&animeko,renren&hanjutv,renren`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,aiyifan,renren,hanjutv,bahamut,dandan,animeko` | +| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`douban,360,renren,hanjutv`,表示douban数据排在最前,hanjutv数据排在最后,示例:`douban,renren`:只返回douban数据和renren数据,且douban数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,aiyifan,hongguo,renren,hanjutv,bahamut,dandan,animeko,custom` | +| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, aiyifan, hongguo, renren, hanjutv, bahamut, dandan, animeko, custom` | +| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`dandan&bahamut&animeko,renren&hanjutv,renren`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,aiyifan,hongguo,renren,hanjutv,bahamut,dandan,animeko` | | CUSTOM_MERGE_RULES | 【可选】合并映射表,用于自定义源合并行为,默认为空。
格式 1 (合并):`副源剧名/S季数@来源 -> 主源剧名/S季数@来源 \| E副源集数>E主源集数`
格式 2 (阻断):`副源剧名/S季数@来源 × 主源剧名/S季数@来源`
说明:`[/S季数]` 与 `[\|路由规则]` 为可选项,留空则交由程序判断。多个规则用分号隔开,多段路由用逗号分隔。
示例:
1. 常规合并:`天气之子@bilibili -> 天气之子@dandan`
2. 多集路由:`我推的孩子/S01@bahamut -> 我推的孩子/S03@dandan \| E25~E35>E25~E35`
3. 阻断合并:`辉夜大小姐想让我告白?~天才们的恋爱头脑战~(2020)@bilibili × 辉夜大小姐想让我告白~天才们的恋爱头脑战~ OVA(2021)【OVA】@dandan` | | ANIME_TITLE_FILTER | 【可选】剧名过滤规则,用于按正则表达式对剧名进行过滤,适用于过滤一些不需要的剧集,需开启ENABLE_ANIME_EPISODE_FILTER,默认值:空(不过滤),格式:使用 \| 分隔多个关键词,例如:广告\|预告\|无关剧名 | | EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 | @@ -440,6 +464,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | LOG_LEVEL | 【可选】日志级别,默认为`info`,可选值:`error`(仅错误)、`warn`(错误和警告)、`info`(所有日志),生产环境建议使用`warn`,调试时使用`info` | | SEARCH_CACHE_MINUTES | 【可选】搜索结果缓存时间(分钟),默认为`3`,避免短期内重复的不必要API请求,同时保证获取最新的结果列表,可根据需要调整:Vercel/Cloudflare建议`1-5`分钟,Docker可设置`5-30`分钟,设置为`0`表示不缓存 | | COMMENT_CACHE_MINUTES | 【可选】弹幕缓存时间(分钟),默认为`3`,弹幕数据的缓存时间,独立于搜索结果缓存,设置为`0`表示不缓存 | +| HONGGUO_MERGE_ALL_EPISODES | 【可选】红果短剧是否将所有集弹幕按集号合并为一集返回,默认为`false`。启用后每集弹幕时间会累加前面各集时长,并在剧集列表中显示为“全集” | | REMEMBER_LAST_SELECT | 【可选】是否记住手动选择结果,用于match自动匹配时优选上次的选择,默认为`true`,表示记住,请注意,该功能为实验性功能,会记住某个剧上次选择的结果作为下次自动匹配的优选,如不需要,请关闭 | | MAX_LAST_SELECT_MAP | 【可选】最后选择映射缓存大小限制,默认为`100`,lastSelectMap最多保存的条目数,超过限制时删除最早的条目(FIFO),用于存储查询关键字上次选择的animeId,最小值100,最大值1000 | | MAX_ANIMES | 【可选】动漫标题缓存最大数量,默认为`100`,缓存最多保存的anime条目数,超过限制时删除最早的条目(FIFO),最小值100,最大值1000 | @@ -552,6 +577,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | xigua | xigua | | maiduidui| maiduidui | | aiyifan | aiyifan | +| hongguo | hongguo | | renren | renren | | hanjutv | hanjutv | | bahamut | bahamut | @@ -605,6 +631,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 │ │ ├── dandan.js # 弹弹play源 │ │ ├── douban.js # 豆瓣源 │ │ ├── hanjutv.js # 韩剧TV源 +│ │ ├── hongguo.js # 红果短剧源 │ │ ├── iqiyi.js # 爱奇艺源 │ │ ├── kan360.js # 360看源 │ │ ├── leshi.js # 乐视视频源 diff --git a/build-forward-widget.js b/build-forward-widget.js index 5e424a5..eec3d42 100644 --- a/build-forward-widget.js +++ b/build-forward-widget.js @@ -38,6 +38,8 @@ const uiModules = [ ]; let customPolyfillContent = fs.readFileSync('forward/custom-polyfill.js', 'utf8'); +const debugBuild = process.argv.includes('--debug'); +const outputFile = debugBuild ? 'dist/logvar-danmu.debug.js' : 'dist/logvar-danmu.js'; // ForwardWidget runs in a browser-like JS runtime, so Node built-ins must not // leak into the bundle. The server still imports the native implementations. @@ -89,10 +91,11 @@ const forwardRuntimeCompatPlugin = { entryPoints: ['forward/forward-widget.js'], // 新的入口文件 bundle: true, minify: false, // 暂时关闭压缩以便调试 + minifySyntax: true, // 折叠 debug 编译期开关,但保留可读变量名 sourcemap: false, platform: 'neutral', // 改为neutral以避免Node.js特定的全局变量 target: 'es2020', - outfile: 'dist/logvar-danmu.js', + outfile: outputFile, format: 'esm', // 保持ES模块格式 external: ['redis', 'fs', 'path', 'stream/promises', 'node-fetch'], plugins: [ @@ -119,15 +122,18 @@ const forwardRuntimeCompatPlugin = { setup(build) { build.onEnd(async (result) => { if (result.errors.length === 0) { - let outputContent = fs.readFileSync('dist/logvar-danmu.js', 'utf8'); + let outputContent = fs.readFileSync(outputFile, 'utf8'); // 更通用的模式,匹配包含这四个函数名的导出语句 const genericExportPattern = /export\s*{\s*(?:\s*(?:getCommentsById|getDanmuWithSegmentTime|getDetailById|searchDanmu)\s*,?\s*){4}\s*};?/g; outputContent = outputContent.replace(genericExportPattern, ''); // 替换 httpGet 和 httpPost - outputContent = outputContent.replace(/await\s+httpGet/g, 'await Widget.http.get'); - outputContent = outputContent.replace(/await\s+httpPost/g, 'await Widget.http.post'); + const httpGetReplacement = debugBuild ? 'forwardDebugHttpGet' : 'Widget.http.get'; + const httpPostReplacement = debugBuild ? 'forwardDebugHttpPost' : 'Widget.http.post'; + // Replace awaited and promise-style calls while preserving the bundled declarations. + outputContent = outputContent.replace(/(? hongguoSource.getComments(url, 'hongguo', true)); + return extractDurationFromSegments(segmentResult); + } if (!/^https?:\/\//i.test(url)) return 0; try { @@ -319,7 +332,7 @@ async function executeSourceHandlers(resultData, queryTitle, targetAnimesList, r hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom, tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili, migu: animesMigu, sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, maiduidui: animesMaiduidui, - aiyifan: animesAiyifan, animeko: animesAnimeko + aiyifan: animesAiyifan, hongguo: animesHongguo, animeko: animesAnimeko } = resultData; // 仅处理resultData中存在数据的源,避免将undefined传入handleAnimes @@ -399,6 +412,9 @@ async function executeSourceHandlers(resultData, queryTitle, targetAnimesList, r } else if (key === 'aiyifan') { // 处理Aiyifan来源 sourceTasks.push({ key, animes: isolatedAnimes, detailStore: isolatedDetailStore, promise: sourceLogContext.run(key, () => aiyifanSource.handleAnimes(animesAiyifan, queryTitle, isolatedAnimes, isolatedDetailStore, targetSeason)) }); + } else if (key === 'hongguo') { + // 处理红果短剧来源 + sourceTasks.push({ key, animes: isolatedAnimes, detailStore: isolatedDetailStore, promise: sourceLogContext.run(key, () => hongguoSource.handleAnimes(animesHongguo, queryTitle, isolatedAnimes, isolatedDetailStore, targetSeason)) }); } else if (key === 'animeko') { // 处理Animeko来源 sourceTasks.push({ key, animes: isolatedAnimes, detailStore: isolatedDetailStore, promise: sourceLogContext.run(key, () => animekoSource.handleAnimes(animesAnimeko, queryTitle, isolatedAnimes, isolatedDetailStore, targetSeason)) }); @@ -629,6 +645,8 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null platform = "maiduidui"; } else if (queryTitle.includes('.yfsp.tv')) { platform = "aiyifan"; + } else if (isHongguoPlayerUrl(queryTitle)) { + platform = "hongguo"; } else if (/(?:bgm|bangumi)\.(?:tv|lol)\/ep\/|chii\.in\/ep\//.test(queryTitle)) { platform = "animeko"; } else if (queryTitle.includes('ani.gamer.com.tw')) { @@ -646,6 +664,8 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null const m = queryTitle.match(/sn=(\d+)/); extractedId = m ? m[1] : queryTitle; pageTitle = `BahaSn${extractedId}`; + } else if (platform === 'hongguo') { + pageTitle = '红果短剧'; } else { // 将源标识符统一映射到日志标签规范名称 pageTitle = await sourceLogContext.run(toLogSourceName(platform), () => getPageTitle(queryTitle)); @@ -713,6 +733,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null else if (source === "xigua") sourceSearchMap[source] = sourceLogContext.run(toLogSourceName(source), () => xiguaSource.search(queryTitle)); else if (source === "maiduidui") sourceSearchMap[source] = sourceLogContext.run(toLogSourceName(source), () => maiduiduiSource.search(queryTitle)); else if (source === "aiyifan") sourceSearchMap[source] = sourceLogContext.run(toLogSourceName(source), () => aiyifanSource.search(queryTitle)); + else if (source === "hongguo") sourceSearchMap[source] = sourceLogContext.run(toLogSourceName(source), () => hongguoSource.search(queryTitle)); else if (source === "animeko") sourceSearchMap[source] = sourceLogContext.run(toLogSourceName(source), () => animekoSource.search(queryTitle)); } @@ -1506,6 +1527,7 @@ function resolveSourceAndRealId(url) { * @returns {string} */ function detectPlatformFromUrl(url) { + if (String(url).startsWith('hongguo:') || isHongguoPlayerUrl(url)) return 'hongguo'; if (url.includes('.qq.com')) return 'tencent'; if (url.includes('.iqiyi.com')) return 'iqiyi'; if (url.includes('.mgtv.com')) return 'imgo'; @@ -2014,7 +2036,7 @@ async function fetchMergedComments(url, animeTitle, commentId) { log("info", `[Merge] 开始获取 [${sourceTag}] 聚合弹幕...`); // 1. 检查聚合缓存 - const cached = getCommentCache(url); + const cached = getCommentCache(resolveCommentCacheKey(url)); if (cached) { log("info", `[Merge] 命中缓存 [${sourceTag}],返回 ${cached.length} 条`); return cached; @@ -2063,6 +2085,7 @@ async function fetchMergedComments(url, animeTitle, commentId) { else if (sourceName === 'xigua') sourceInstance = xiguaSource; else if (sourceName === 'maiduidui') sourceInstance = maiduiduiSource; else if (sourceName === 'aiyifan') sourceInstance = aiyifanSource; + else if (sourceName === 'hongguo') sourceInstance = hongguoSource; else if (sourceName === 'animeko') sourceInstance = animekoSource; // 如有新增允许的源合并,在此处添加 @@ -2188,7 +2211,8 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu log("info", `[system] [LogVar-API] Fetched comment ID: ${commentId}`); // 检查弹幕缓存 - const cachedComments = getCommentCache(url); + const cacheKey = resolveCommentCacheKey(url); + const cachedComments = getCommentCache(cacheKey); if (cachedComments !== null) { const responseData = buildDanmuResponse( { count: cachedComments.length, comments: cachedComments }, @@ -2252,6 +2276,8 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getComments(commentUrl, plat, segmentFlag)); } else if (url.includes('.yfsp.tv')) { danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getComments(commentUrl, plat, segmentFlag)); + } else if (isHongguoPlayerUrl(commentUrl)) { + danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(commentUrl, 'hongguo', segmentFlag)); } else if (/(?:bgm|bangumi)\.(?:tv|lol)\/ep\/|chii\.in\/ep\//.test(url)) { const bgmMatch = commentUrl.match(/(?:bgm\.tv|bangumi\.tv|bangumi\.lol|chii\.in)\/ep\/(\d+)/); danmus = await sourceLogContext.run('animeko', () => animekoSource.getComments(bgmMatch ? bgmMatch[1] : commentUrl, plat, segmentFlag)); @@ -2275,6 +2301,8 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu danmus = await sourceLogContext.run('custom', () => customSource.getComments(url, plat, segmentFlag)); } else if (plat === "animeko") { danmus = await sourceLogContext.run('animeko', () => animekoSource.getComments(url, plat, segmentFlag)); + } else if (plat === "hongguo") { + danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(url, plat, segmentFlag)); } } @@ -2360,7 +2388,7 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu if (danmus && danmus.comments) danmus = danmus.comments; if (!Array.isArray(danmus)) danmus = []; if (danmus.length > 0) { - setCommentCache(url, danmus); + setCommentCache(cacheKey, danmus); } } @@ -2399,7 +2427,8 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includ let url = videoUrl; const shouldAttachDuration = shouldIncludeVideoDuration(queryFormat, includeDuration); // 检查弹幕缓存 - const cachedComments = getCommentCache(url); + const cacheKey = resolveCommentCacheKey(url); + const cachedComments = getCommentCache(cacheKey); if (cachedComments !== null) { const responseData = buildDanmuResponse({ errorCode: 0, @@ -2442,6 +2471,8 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includ danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getComments(url, "maiduidui", segmentFlag)); } else if (url.includes('.yfsp.tv')) { danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getComments(url, "aiyifan", segmentFlag)); + } else if (isHongguoPlayerUrl(url)) { + danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(url, "hongguo", segmentFlag)); } else { // 如果不是已知平台,尝试第三方弹幕服务器 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; @@ -2454,7 +2485,7 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includ // 缓存弹幕结果 if (danmus.length > 0) { - setCommentCache(url, danmus); + setCommentCache(cacheKey, danmus); } const responseData = buildDanmuResponse({ @@ -2495,7 +2526,8 @@ export async function getSegmentComment(segment, queryFormat) { log("info", `[system] [SegmentComment] Processing segment comment request for URL: ${url}`); // 检查弹幕缓存 - const cachedComments = getCommentCache(url); + const cacheKey = resolveCommentCacheKey(url); + const cachedComments = getCommentCache(cacheKey); if (cachedComments !== null) { const responseData = { errorCode: 0, @@ -2533,6 +2565,8 @@ export async function getSegmentComment(segment, queryFormat) { danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getSegmentComments(segment)); } else if (platform === "aiyifan") { danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getSegmentComments(segment)); + } else if (platform === "hongguo") { + danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getSegmentComments(segment)); } else if (platform === "hanjutv") { danmus = await sourceLogContext.run('hanjutv', () => hanjutvSource.getSegmentComments(segment)); } else if (platform === "bahamut") { @@ -2553,7 +2587,7 @@ export async function getSegmentComment(segment, queryFormat) { // 缓存弹幕结果 if (danmus.length > 0) { - setCommentCache(url, danmus); + setCommentCache(cacheKey, danmus); } const responseData = { diff --git a/danmu_api/apis/forward-trace-api.js b/danmu_api/apis/forward-trace-api.js new file mode 100644 index 0000000..dfe46e5 --- /dev/null +++ b/danmu_api/apis/forward-trace-api.js @@ -0,0 +1,83 @@ +import { jsonResponse } from '../utils/http-util.js'; +import { globals } from '../configs/globals.js'; + +export const FORWARD_TRACE_LIMITS = Object.freeze({ + maxPayloadBytes: 64 * 1024, +}); + +function payloadByteLength(text) { + return new TextEncoder().encode(text).byteLength; +} + +function appendForwardRemoteLog(handler, level, message, timestamp = new Date().toISOString()) { + const normalizedLevel = ['info', 'warn', 'error'].includes(level) ? level : 'info'; + const formattedMessage = `[ForwardRemote][${handler}] ${String(message || '')}`; + globals.logBuffer.push({ timestamp, level: normalizedLevel, message: formattedMessage }); + if (globals.logBuffer.length > globals.MAX_LOGS) globals.logBuffer.shift(); + console[normalizedLevel](formattedMessage); +} + +function formatTraceValue(value) { + try { + return JSON.stringify(value ?? null); + } catch (_) { + return '"[unserializable]"'; + } +} + +function appendForwardTraceEvent(payload, handler) { + const eventType = String(payload.eventType || 'handlerComplete'); + const durationMs = Number(payload.durationMs) || 0; + + if (eventType === 'logBatch') { + const logs = Array.isArray(payload.logs) ? payload.logs : []; + logs.slice(0, 50).forEach((entry) => { + appendForwardRemoteLog( + handler, + entry?.level, + entry?.message, + entry?.timestamp || new Date().toISOString() + ); + }); + return logs.length; + } + + if (eventType === 'handlerStart') { + appendForwardRemoteLog(handler, 'info', `START params=${formatTraceValue(payload.params || {})}`, payload.timestamp); + return 1; + } + + const level = payload.status === 'error' ? 'error' : 'info'; + const detail = payload.status === 'error' + ? `error=${formatTraceValue(payload.error)}` + : `result=${formatTraceValue(payload.result)}`; + appendForwardRemoteLog( + handler, + level, + `COMPLETE status=${payload.status || 'unknown'} duration=${durationMs}ms ${detail}`, + payload.timestamp + ); + return 1; +} + +export async function handleForwardTrace(req) { + const body = await req.text(); + if (payloadByteLength(body) > FORWARD_TRACE_LIMITS.maxPayloadBytes) { + return jsonResponse({ success: false, errorMessage: 'Forward trace payload is too large' }, 413); + } + + let payload; + try { + payload = JSON.parse(body); + } catch (_) { + return jsonResponse({ success: false, errorMessage: 'Invalid JSON payload' }, 400); + } + + const handler = String(payload?.handler || '').trim(); + if (!handler || handler.length > 64) { + return jsonResponse({ success: false, errorMessage: 'handler is required' }, 400); + } + + const logCount = appendForwardTraceEvent(payload, handler); + return jsonResponse({ success: true, logCount }, 202); +} diff --git a/danmu_api/apis/system-api.js b/danmu_api/apis/system-api.js index c06e80a..6a229f5 100644 --- a/danmu_api/apis/system-api.js +++ b/danmu_api/apis/system-api.js @@ -91,7 +91,7 @@ export function handleConfig(hasPermission = false) { originalEnvVars: originalEnvVars, // 系统设置使用原始环境变量(已脱敏) hasAdminToken: hasAdminToken, // 添加admin token配置状态 repository: "https://github.com/huangxd-/danmu_api.git", - description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋帆弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/hf等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。", + description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋帆红弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/hf等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。", notice: "本项目仅为个人学习爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。" }); } diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index e2d2908..610478b 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -12,9 +12,9 @@ export class Envs { static accessedEnvVars = new Map(); static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan']; // vod允许的播放平台 - static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'animeko', 'custom']; // 全部源允许的播放平台 - static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'animeko', 'custom']; // 允许的源 - static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'animeko']; // 允许的源合并 + static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'animeko', 'custom']; // 全部源允许的播放平台 + static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'animeko', 'custom']; // 允许的源 + static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'animeko']; // 允许的源合并 static DEFAULT_AI_MATCH_PROMPT = `你是一个专业的影视匹配专家,你的的任务是根据用户提供的 JSON 数据,从候选动漫列表中匹配最符合条件的动漫及集数。 输入字段说明: @@ -655,6 +655,7 @@ export class Envs { 'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' }, 'DANMU_PUSH_URL': { category: 'danmu', type: 'text', description: '弹幕推送地址,示例 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path= ' }, 'LIKE_SWITCH': { category: 'danmu', type: 'boolean', description: '弹幕点赞数显示开关,默认开启' }, + 'HONGGUO_MERGE_ALL_EPISODES': { category: 'danmu', type: 'boolean', description: '红果短剧合并全集弹幕,默认关闭' }, 'DANMU_OFFSET': { category: 'danmu', type: 'text', sources: this.ALLOWED_SOURCES, description: '弹幕时间偏移配置,格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒,多条用逗号分隔,正数表示弹幕延后(向右),负数表示弹幕提前(向左)。支持百分比模式:在路径或来源末尾追加 %,如 东方/S03/E02@tencent%:11,按公式 原时间 * (视频时长 + 偏移秒数) / 视频时长 缩放全部弹幕时间。示例:overlord/S01:90,re-zero/S02@bilibili:120,re-zero/S02/E03@dandan&bilibili:10,东方/S03/E02@tencent%:11' }, // 缓存配置 @@ -719,6 +720,7 @@ export class Envs { logLevel: this.get('LOG_LEVEL', 'info', 'string'), // 日志级别配置(默认 info,可选值:error, warn, info) searchCacheMinutes: this.get('SEARCH_CACHE_MINUTES', 3, 'number'), // 搜索结果缓存时间配置(分钟,默认 3) commentCacheMinutes: this.get('COMMENT_CACHE_MINUTES', 3, 'number'), // 弹幕缓存时间配置(分钟,默认 3) + hongguoMergeAllEpisodes: this.get('HONGGUO_MERGE_ALL_EPISODES', false, 'boolean'), // 红果短剧是否合并全集弹幕(默认 false) 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/hongguo.js b/danmu_api/sources/hongguo.js new file mode 100644 index 0000000..c286b88 --- /dev/null +++ b/danmu_api/sources/hongguo.js @@ -0,0 +1,1054 @@ +import BaseSource from "./base.js"; +import { globals } from "../configs/globals.js"; +import { log } from "../utils/log-util.js"; +import { httpGet, httpPost, buildQueryString } from "../utils/http-util.js"; +import { + aesCbcEncrypt, + bytesToHex, + bytesToBase64, + convertToAsciiSum, + hexToBytes, + md5, + pkcs7Pad, + sm3Bytes, + stringToUtf8Bytes, +} from "../utils/codec-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { generateValidStartDate } from "../utils/time-util.js"; +import { titleMatches } from "../utils/common-util.js"; +import { SegmentListResponse } from "../models/dandan-model.js"; + +const CLIENT_CONFIG = { + apiHost: "api5-normal-sinfonlineb.fqnovel.com", + baseQuery: { + iid: "4439167111854618", + device_id: "4439167111850522", + channel: "huawei_8662_64", + device_type: "V2284A", + cdid: "6100e9f9-a1a0-4f65-ab47-94f5b41b8efb", + aid: "8662", + app_name: "novelread", + version_code: "72932", + version_name: "7.2.9.32", + device_platform: "android", + ac: "wifi", + os: "android", + ssmix: "a", + device_brand: "vivo", + language: "zh", + os_api: "32", + os_version: "12", + manifest_version_code: "72932", + resolution: "1080*1920", + dpi: "280", + update_version_code: "72932", + host_abi: "arm64-v8a", + dragon_device_type: "phone", + pv_player: "72932", + compliance_status: "0", + need_personal_recommend: "1", + player_so_load: "1", + is_android_pad_screen: "1", + rom_version: "V417IR+release-keys", + }, + sessionHeaders: { + cookie: "", + "x-tt-token": "", + "user-agent": "com.phoenix.read/72932 (Linux; U; Android 12; zh_CN; V2284A; Build/V417IR;tt-ok/3.12.13.20)", + "x-tt-store-region": "cn-zj", + "x-tt-store-region-src": "did", + "passport-sdk-version": "5051452", + "sdk-version": "2", + }, +}; + +const COMMENT_SOURCE = 601; +const SERVER_CHANNEL = 1000; +const COMMENT_WINDOW_MS = 30_000; +const COMMENT_COUNT = 90; +const COMMENT_CONCURRENCY = 30; +const MAX_RETRIES = 3; +const MAX_SEARCH_ITEMS = 20; +const IMAGE_SHRINK = + "W3siaW1hZ2VfdHlwZSI6MywiaW1hZ2Vfd2lkdGgiOjkwMCwic2hyaW5rX3R5cGUiOjN9LHsiaW1h\n" + + "Z2VfdHlwZSI6NCwiaW1hZ2Vfd2lkdGgiOjU0LCJzaHJpbmtfdHlwZSI6NH1d\n"; + +const SIGN_KEY = hexToBytes("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"); +const ARGUS_HEADER = hexToBytes("3ccc"); +const ARGUS_PREFIX = hexToBytes("a6e783ee7001100918"); +const ARGUS_SUFFIX = hexToBytes("567b"); +const ARGUS_XOR_WORD = hexToBytes("d04ffdff"); +const LICENSE_ID = 1611921764; +const METASEC_APP_ID = 3019; +const SDK_VERSION = 135135744; +const COUNTER_VALUE = 1388734; +const SIMON_Z = word64(0x046d678b, 0x3dc94c3a); + +function concatBytes(...parts) { + const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || [])); + const output = new Uint8Array(arrays.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of arrays) { + output.set(part, offset); + offset += part.length; + } + return output; +} + +function randomBytes(length) { + const output = new Uint8Array(length); + if (globalThis.crypto && typeof globalThis.crypto.getRandomValues === "function") { + globalThis.crypto.getRandomValues(output); + return output; + } + for (let i = 0; i < length; i++) output[i] = Math.floor(Math.random() * 256); + return output; +} + +function reverseBytes(bytes) { + const output = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) output[i] = bytes[bytes.length - i - 1]; + return output; +} + +function getHeader(headers, name) { + const expected = String(name).toLowerCase(); + for (const [key, value] of Object.entries(headers || {})) { + if (key.toLowerCase() === expected && value != null) return String(value); + } + return ""; +} + +function getQueryString(url) { + const text = String(url || ""); + const question = text.indexOf("?"); + if (question < 0) return ""; + const hash = text.indexOf("#", question); + return text.slice(question + 1, hash < 0 ? undefined : hash); +} + +function getQueryParam(url, name) { + const expected = String(name); + for (const pair of getQueryString(url).split("&")) { + if (!pair) continue; + const separator = pair.indexOf("="); + const key = separator < 0 ? pair : pair.slice(0, separator); + if (decodeURIComponent(key) === expected) { + return decodeURIComponent(separator < 0 ? "" : pair.slice(separator + 1)); + } + } + return null; +} + +function encodeVarint(value) { + let remaining = Number(value); + if (!Number.isSafeInteger(remaining) || remaining < 0) throw new RangeError("protobuf varint must be a safe non-negative integer"); + const output = []; + while (remaining >= 0x80) { + output.push((remaining % 0x80) | 0x80); + remaining = Math.floor(remaining / 0x80); + } + output.push(remaining); + return new Uint8Array(output); +} + +function pbVarint(field, value) { + return concatBytes(encodeVarint(field * 8), encodeVarint(value)); +} + +function pbBytes(field, value) { + const encoded = value instanceof Uint8Array ? value : stringToUtf8Bytes(String(value)); + return concatBytes(encodeVarint(field * 8 + 2), encodeVarint(encoded.length), encoded); +} + +function word64(lo, hi) { + return { lo: lo >>> 0, hi: hi >>> 0 }; +} + +function xor64(...values) { + let lo = 0; + let hi = 0; + for (const value of values) { + lo ^= value.lo; + hi ^= value.hi; + } + return word64(lo, hi); +} + +function and64(a, b) { + return word64(a.lo & b.lo, a.hi & b.hi); +} + +function not64(value) { + return word64(~value.lo, ~value.hi); +} + +function add64(a, b) { + const lo = (a.lo + b.lo) >>> 0; + return word64(lo, (a.hi + b.hi + (lo < a.lo ? 1 : 0)) >>> 0); +} + +function rol64(value, count) { + const shift = ((count % 64) + 64) % 64; + if (shift === 0) return word64(value.lo, value.hi); + if (shift < 32) { + return word64( + (value.lo << shift) | (value.hi >>> (32 - shift)), + (value.hi << shift) | (value.lo >>> (32 - shift)), + ); + } + if (shift === 32) return word64(value.hi, value.lo); + const rest = shift - 32; + return word64( + (value.hi << rest) | (value.lo >>> (32 - rest)), + (value.lo << rest) | (value.hi >>> (32 - rest)), + ); +} + +function ror64(value, count) { + return rol64(value, 64 - (count % 64)); +} + +function readWord64LE(bytes, offset = 0) { + const lo = (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0; + const hi = (bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24)) >>> 0; + return word64(lo, hi); +} + +function writeWord64LE(output, offset, value) { + for (let i = 0; i < 4; i++) { + output[offset + i] = (value.lo >>> (i * 8)) & 0xff; + output[offset + 4 + i] = (value.hi >>> (i * 8)) & 0xff; + } +} + +function simonZBit(index) { + return word64(index < 32 ? (SIMON_Z.lo >>> index) & 1 : (SIMON_Z.hi >>> (index - 32)) & 1, 0); +} + +function simonRoundKeys(key) { + const words = Array.from({ length: 4 }, (_, index) => readWord64LE(key, index * 8)); + for (let index = 4; index < 72; index++) { + let mixed = xor64(ror64(words[index - 1], 3), words[index - 3]); + mixed = xor64(mixed, ror64(mixed, 1)); + words.push(xor64(not64(words[index - 4]), mixed, simonZBit((index - 4) % 62), word64(3, 0))); + } + return words; +} + +function simonEncrypt(block, roundKeys) { + let left = readWord64LE(block, 0); + let right = readWord64LE(block, 8); + for (const key of roundKeys) { + const oldRight = right; + const nonlinear = and64(rol64(right, 1), rol64(right, 8)); + right = xor64(left, nonlinear, rol64(right, 2), key); + left = oldRight; + } + const output = new Uint8Array(16); + writeWord64LE(output, 0, left); + writeWord64LE(output, 8, right); + return output; +} + +function argusProtobuf(query, stub, timestamp, options) { + const bodyInput = /^[0-9a-f]{32}$/i.test(stub) ? hexToBytes(stub) : new Uint8Array(16); + const randomValue = options.argusRandom == null ? readWord32LE(randomBytes(4)) : options.argusRandom >>> 0; + const signCount = options.argusSignCount == null ? 2 + (randomBytes(1)[0] % 49) * 2 : options.argusSignCount; + const nested = concatBytes( + pbVarint(1, signCount), + pbVarint(2, COUNTER_VALUE), + pbVarint(3, COUNTER_VALUE), + pbVarint(4, COUNTER_VALUE), + ); + return concatBytes( + pbVarint(1, 0x20200929 * 2), + pbVarint(2, 2), + pbVarint(3, randomValue), + pbBytes(4, METASEC_APP_ID), + pbBytes(6, LICENSE_ID), + pbBytes(7, "6.8.1.32"), + pbBytes(8, "v04.07.01-ml-android"), + pbVarint(9, SDK_VERSION), + pbBytes(10, new Uint8Array(8)), + pbVarint(12, timestamp * 2), + pbBytes(13, sm3Bytes(bodyInput).slice(0, 6)), + pbBytes(14, sm3Bytes(stringToUtf8Bytes(query)).slice(0, 6)), + pbBytes(15, nested), + pbBytes(20, "none"), + pbVarint(21, 738), + ); +} + +function readWord32LE(bytes) { + return (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0; +} + +export function createArgus(query, stub, timestamp, options = {}) { + const protobuf = pkcs7Pad(argusProtobuf(query, stub, timestamp, options)); + const simonKey = sm3Bytes(concatBytes(SIGN_KEY, ARGUS_HEADER, ARGUS_SUFFIX, SIGN_KEY)); + const roundKeys = simonRoundKeys(simonKey); + const encrypted = []; + for (let offset = 0; offset < protobuf.length; offset += 16) { + encrypted.push(simonEncrypt(protobuf.slice(offset, offset + 16), roundKeys)); + } + + const xorKey = concatBytes(ARGUS_XOR_WORD, ARGUS_XOR_WORD); + const encoded = concatBytes(xorKey, ...encrypted); + for (let index = 8; index < encoded.length; index++) encoded[index] ^= encoded[index % 8]; + const container = concatBytes(ARGUS_PREFIX, reverseBytes(encoded), ARGUS_SUFFIX); + const key = hexToBytes(md5(SIGN_KEY.slice(0, 16))); + const iv = hexToBytes(md5(SIGN_KEY.slice(16))); + return bytesToBase64(concatBytes(ARGUS_HEADER, aesCbcEncrypt(container, key, iv))); +} + +function ladonRoundKeys(randomPrefix, aid) { + const asciiDigest = stringToUtf8Bytes(md5(concatBytes(randomPrefix, stringToUtf8Bytes(String(aid))))); + const table = new Uint8Array(288); + table.set(asciiDigest); + const queue = Array.from({ length: 4 }, (_, index) => readWord64LE(table, index * 8)); + let left = queue.shift(); + let right = queue.shift(); + for (let index = 0; index < 0x22; index++) { + const mixed = xor64(add64(ror64(right, 8), left), word64(index, 0)); + queue.push(mixed); + left = xor64(mixed, ror64(left, 61)); + writeWord64LE(table, (index + 1) * 8, left); + right = queue.shift(); + } + return Array.from({ length: 0x22 }, (_, index) => readWord64LE(table, index * 8)); +} + +function ladonEncryptBlock(block, roundKeys) { + let left = readWord64LE(block, 0); + let right = readWord64LE(block, 8); + for (const key of roundKeys) { + right = xor64(key, add64(left, ror64(right, 8))); + left = xor64(right, ror64(left, 61)); + } + const output = new Uint8Array(16); + writeWord64LE(output, 0, left); + writeWord64LE(output, 8, right); + return output; +} + +export function createLadon(timestamp, aid, options = {}) { + const randomPrefix = options.ladonRandom == null + ? randomBytes(4) + : new Uint8Array(options.ladonRandom); + if (randomPrefix.length !== 4) throw new RangeError("ladonRandom must be four bytes"); + const data = stringToUtf8Bytes(`${timestamp}-${LICENSE_ID}-${METASEC_APP_ID}`); + const paddedSize = Math.ceil(data.length / 16) * 16; + const padded = new Uint8Array(paddedSize); + padded.set(data); + const padding = 16 - (data.length % 16); + if (data.length + padding <= paddedSize) padded.fill(padding, data.length); + const roundKeys = ladonRoundKeys(randomPrefix, aid); + const ciphertext = []; + for (let offset = 0; offset < padded.length; offset += 16) { + ciphertext.push(ladonEncryptBlock(padded.slice(offset, offset + 16), roundKeys)); + } + return bytesToBase64(concatBytes(randomPrefix, ...ciphertext)); +} + +function md5Prefix(value) { + const bytes = value instanceof Uint8Array ? value : stringToUtf8Bytes(String(value)); + return hexToBytes(md5(bytes)).slice(0, 4); +} + +export function buildGorgonSeed(url, { headers = {}, body = null, khronos } = {}) { + if (!Number.isInteger(khronos) || khronos < 0 || khronos > 0xffffffff) { + throw new RangeError("khronos must be an unsigned 32-bit integer"); + } + const stub = getHeader(headers, "x-ss-stub").trim(); + let bodyPrefix = new Uint8Array(4); + if (/^[0-9a-f]{32}$/i.test(stub)) bodyPrefix = hexToBytes(stub.slice(0, 8)); + else if (body != null) { + const bodyBytes = body instanceof Uint8Array ? body : stringToUtf8Bytes(String(body)); + if (bodyBytes.length > 0) bodyPrefix = md5Prefix(bodyBytes); + } + const cookie = getHeader(headers, "cookie"); + const timestamp = new Uint8Array([ + (khronos >>> 24) & 0xff, + (khronos >>> 16) & 0xff, + (khronos >>> 8) & 0xff, + khronos & 0xff, + ]); + return concatBytes( + md5Prefix(getQueryString(url)), + bodyPrefix, + cookie ? md5Prefix(cookie) : new Uint8Array(4), + new Uint8Array([0x00, 0x01, 0x07, 0x04]), + timestamp, + ); +} + +function reverseBits(value) { + let result = ((value << 1) & 0xaa) | ((value >>> 1) & 0x55); + result = ((result << 2) & 0xcc) | ((result >>> 2) & 0x33); + return ((result << 4) | (result >>> 4)) & 0xff; +} + +export function transformGorgonSeed(seed, { headerByte2, headerByte3, parameter = 0 } = {}) { + const input = seed instanceof Uint8Array ? seed : new Uint8Array(seed || []); + if (input.length !== 20) throw new TypeError("seed must contain 20 bytes"); + const key = new Uint8Array([0x4a, parameter & 0xff, 0x16, headerByte3, 0x47, 0x6c, parameter >>> 8, headerByte2]); + const sbox = Uint8Array.from({ length: 256 }, (_, index) => index); + let j = 0; + for (let i = 0; i < 256; i++) { + j = (j + sbox[i] + key[i & 7]) & 0xff; + sbox[i] = sbox[j]; + } + const payload = new Uint8Array(input); + let i = 0; + j = 0; + for (let offset = 0; offset < payload.length; offset++) { + i = (i + 1) & 0xff; + j = (j + sbox[i]) & 0xff; + sbox[i] = sbox[j]; + payload[offset] ^= sbox[(sbox[i] + sbox[j]) & 0xff]; + } + const lengthMask = (~payload.length) & 0xff; + for (let offset = 0; offset < payload.length; offset++) { + const swappedNibbles = ((payload[offset] << 4) | (payload[offset] >>> 4)) & 0xff; + payload[offset] = reverseBits(swappedNibbles ^ payload[(offset + 1) % payload.length]) ^ lengthMask; + } + return concatBytes(new Uint8Array([0x84, 0x04, headerByte2, headerByte3, parameter & 0xff, parameter >>> 8]), payload); +} + +export function signHongguoRequest(url, options = {}) { + const timestamp = options.timestamp == null + ? (options.khronos == null ? Math.floor(Date.now() / 1000) : options.khronos) + : options.timestamp; + if (!Number.isInteger(timestamp) || timestamp < 0 || timestamp > 0xffffffff) { + throw new RangeError("timestamp must be an unsigned 32-bit integer"); + } + const aid = parseInt(getQueryParam(url, "aid"), 10); + if (!Number.isSafeInteger(aid)) throw new TypeError("signed URL must contain a numeric aid parameter"); + const random = randomBytes(2); + const headerByte2 = options.headerByte2 == null ? ((random[0] % 7) + 1) << 5 : options.headerByte2; + const headerByte3 = options.headerByte3 == null ? 0xe0 | (random[1] & 0x0f) : options.headerByte3; + const headers = options.headers || {}; + const seed = buildGorgonSeed(url, { headers, body: options.body, khronos: timestamp }); + return { + "X-Argus": createArgus(getQueryString(url), getHeader(headers, "x-ss-stub").trim(), timestamp, options), + "X-Gorgon": bytesToHex(transformGorgonSeed(seed, { headerByte2, headerByte3, parameter: options.parameter || 0 })), + "X-Khronos": String(timestamp), + "X-Ladon": createLadon(timestamp, aid, options), + }; +} + +export function createHongguoEpisodeId(seriesId, vid, durationSeconds) { + return `hongguo:v1:${encodeURIComponent(String(seriesId))}:${encodeURIComponent(String(vid))}:${Math.max(0, Math.trunc(Number(durationSeconds) || 0))}`; +} + +export function createHongguoSeriesId(seriesId) { + return `hongguo:series:v1:${encodeURIComponent(String(seriesId))}`; +} + +export function parseHongguoSeriesId(value) { + const rawValue = String(value || "").split("#", 1)[0]; + const match = rawValue.match(/^hongguo:series:v1:([^:]+)$/); + if (!match) throw new Error("无效的红果全集 ID"); + return { seriesId: decodeURIComponent(match[1]) }; +} + +export function isHongguoSeriesId(value) { + try { + parseHongguoSeriesId(value); + return true; + } catch { + return false; + } +} + +export function parseHongguoEpisodeId(value) { + const rawValue = String(value || "").split("#", 1)[0]; + const match = rawValue.match(/^hongguo:v1:([^:]+):([^:]+):(\d+)$/); + if (!match) throw new Error("无效的红果剧集 ID"); + return { + seriesId: decodeURIComponent(match[1]), + vid: decodeURIComponent(match[2]), + duration: Number(match[3]), + }; +} + +export function parseHongguoPlayerUrl(value) { + const normalized = String(value || "") + .trim() + .replace(/@%?-?\d+(?:\.\d+)?$/, ""); + const match = normalized.match( + /^https?:\/\/(?:www\.)?hongguoduanju\.com(?::\d+)?\/player\/(\d+)\/(\d+)\/?(?:\?[^#]*)?(?:#.*)?$/i, + ); + if (!match) throw new Error("无效的红果短剧播放链接"); + return { seriesId: match[1], vid: match[2] }; +} + +export function isHongguoPlayerUrl(value) { + try { + parseHongguoPlayerUrl(value); + return true; + } catch { + return false; + } +} + +function extractImageUrl(value) { + if (typeof value === "string") { + const url = value.trim(); + return url.startsWith("//") ? `https:${url}` : url; + } + if (Array.isArray(value)) { + for (const item of value) { + const url = extractImageUrl(item); + if (url) return url; + } + return ""; + } + if (!value || typeof value !== "object") return ""; + for (const key of ["url", "main_url", "download_url", "url_list", "urlList", "urls"]) { + const url = extractImageUrl(value[key]); + if (url) return url; + } + return ""; +} + +function parseSearchCell(cell) { + const seriesId = cell && (cell.book_id || cell.search_result_id); + if (!seriesId) return null; + const detail = cell.video_detail || {}; + let videoData = cell.video_data; + if (Array.isArray(videoData)) videoData = videoData[0] || {}; + if (!videoData || typeof videoData !== "object") videoData = {}; + const inner = videoData.video_detail || {}; + const episodeCount = detail.episode_cnt || videoData.episode_cnt || inner.episode_cnt || 0; + if (!episodeCount) return null; + const highlighted = cell.search_high_light && cell.search_high_light.title && cell.search_high_light.title.text; + const title = String(highlighted || detail.series_title || videoData.title || inner.series_title || "") + .replace(/<[^>]+>/g, "") + .trim(); + if (!title) return null; + return { + seriesId: String(seriesId), + name: title, + episodeCount: Number(episodeCount) || 0, + score: videoData.score || inner.score || "", + imageUrl: extractImageUrl( + inner.series_cover || videoData.cover || detail.series_cover || cell.series_cover || cell.cover, + ), + }; +} + +function parseDanmuResponse(payload) { + const output = []; + const data = payload && payload.data; + for (const item of (data && data.data_list) || []) { + const comment = item.comment || {}; + const common = comment.common || {}; + const content = common.content || {}; + const expand = comment.expand || {}; + const text = String(content.text || "").trim(); + const offsetMs = Number(expand.offset_time); + if (!text || !Number.isFinite(offsetMs)) continue; + output.push({ + commentId: String(comment.comment_id || ""), + offsetMs: Math.max(0, Math.trunc(offsetMs)), + text, + diggCount: Math.max(0, Math.trunc(Number((comment.stat || {}).digg_count) || 0)), + }); + } + return output; +} + +function dedupeDanmus(items) { + const seen = new Map(); + for (const item of items) { + const key = item.commentId ? `id:${item.commentId}` : `anonymous:${item.offsetMs}:${item.text}`; + seen.set(key, item); + } + return [...seen.values()].sort((a, b) => a.offsetMs - b.offsetMs || a.commentId.localeCompare(b.commentId) || a.text.localeCompare(b.text)); +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + let firstError = null; + const workerCount = Math.min(Math.max(1, concurrency), items.length); + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + try { + results[index] = await mapper(items[index], index); + } catch (error) { + if (!firstError) firstError = error; + } + } + }); + await Promise.all(workers); + if (firstError) throw firstError; + return results; +} + +function getFragmentNumber(value, name) { + const hashIndex = String(value || "").indexOf("#"); + if (hashIndex < 0) return null; + const expected = String(name); + const fragment = String(value).slice(hashIndex + 1); + for (const pair of fragment.split("&")) { + const separator = pair.indexOf("="); + const key = separator < 0 ? pair : pair.slice(0, separator); + if (decodeURIComponent(key) !== expected) continue; + const number = Number(decodeURIComponent(separator < 0 ? "" : pair.slice(separator + 1))); + return Number.isFinite(number) ? number : null; + } + return null; +} + +export default class HongguoSource extends BaseSource { + constructor() { + super(); + this.nextRequestAt = 0; + this.playerEpisodeResolutions = new Map(); + this.activeCommentRequests = 0; + this.commentRequestWaiters = []; + } + + buildUrl(path, extraQuery = {}) { + const query = { ...CLIENT_CONFIG.baseQuery, ...extraQuery, _rticket: String(Date.now()) }; + return `https://${CLIENT_CONFIG.apiHost}${path}?${buildQueryString(query)}`; + } + + async throttle() { + const now = Date.now(); + const scheduled = Math.max(now, this.nextRequestAt); + this.nextRequestAt = scheduled + 600 + Math.floor(Math.random() * 401); + if (scheduled > now) await sleep(scheduled - now); + } + + checkResponse(payload) { + if (!payload || typeof payload !== "object") throw new Error("平台返回了无效响应"); + const code = payload.code; + if (code == null || code === 0) return; + const message = String(payload.message || payload.msg || "未知错误"); + const normalizedMessage = message.toLowerCase(); + const authFailed = [401, 403, 8, 1001].includes(Number(code)) || + ["token", "login", "登录", "未登录", "expire"].some((word) => normalizedMessage.includes(word)); + if (authFailed) throw new Error(`鉴权失败 code=${code}: ${message}`); + throw new Error(`风控或平台异常 code=${code}: ${message}`); + } + + async request(method, path, { body = null, extraQuery = {}, comment = false } = {}) { + let lastError; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const url = this.buildUrl(path, extraQuery); + const headers = {}; + for (const [key, value] of Object.entries(CLIENT_CONFIG.sessionHeaders)) { + if (value) headers[key] = String(value); + } + const payload = body == null ? null : JSON.stringify(body); + headers["content-type"] = "application/json; charset=utf-8"; + if (comment) { + headers["comment-source"] = String(COMMENT_SOURCE); + headers["server-channel"] = String(SERVER_CHANNEL); + headers["x-ss-stub"] = ""; + } else if (payload != null) { + headers["x-ss-stub"] = md5(stringToUtf8Bytes(payload)).toUpperCase(); + } + Object.assign(headers, signHongguoRequest(url, { headers })); + const releaseCommentSlot = comment ? await this.acquireCommentSlot() : null; + if (!comment) await this.throttle(); + let response; + try { + response = method === "GET" + ? await httpGet(url, { headers, timeout: 30000 }) + : await httpPost(url, payload, { headers, timeout: 30000 }); + } finally { + if (releaseCommentSlot) releaseCommentSlot(); + } + const result = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + this.checkResponse(result); + return result; + } catch (error) { + lastError = error; + if (attempt + 1 < MAX_RETRIES) await sleep(1500 * (attempt + 1)); + } + } + throw lastError || new Error("平台请求失败"); + } + + async search(keyword) { + try { + const results = []; + const seen = new Set(); + let offset = 0; + let passback = ""; + let searchId = ""; + for (let page = 0; page < 12 && results.length < MAX_SEARCH_ITEMS; page++) { + const params = { + query: keyword, + tab_name: "feed", + search_source: "1", + offset: String(offset), + count: "0", + use_correct: "true", + }; + if (passback) params.passback = passback; + if (searchId) params.search_id = searchId; + const payload = await this.request("GET", "/reading/bookapi/search/tab/v", { extraQuery: params }); + const tabs = payload.search_tabs || []; + if (!tabs.length) break; + const tab = tabs[0]; + const data = tab.data || []; + for (const cell of data) { + const item = parseSearchCell(cell); + if (!item || seen.has(item.seriesId)) continue; + seen.add(item.seriesId); + results.push(item); + if (results.length >= MAX_SEARCH_ITEMS) break; + } + offset = tab.next_offset == null ? offset : tab.next_offset; + passback = tab.passback || passback; + searchId = tab.search_id || searchId; + if (!tab.has_more || !data.length) break; + } + log("info", `[Hongguo] 搜索找到 ${results.length} 个结果`); + return results; + } catch (error) { + log("error", `[Hongguo] 搜索失败: ${error.message}`); + return []; + } + } + + async getEpisodes(seriesId) { + try { + const body = { + biz_param: { + detail_page_version: 0, + disable_digg_stat: false, + disable_video_relate_book: false, + image_shrink_datas_str: IMAGE_SHRINK, + need_all_video_definition: false, + need_mp4_align: false, + screen_width_px: "900", + source: 7, + use_os_player: false, + use_server_dns: false, + }, + series_id: String(seriesId), + }; + const payload = await this.request("POST", "/novel/player/multi_video_detail/v1/", { body }); + const entry = payload.data && payload.data[String(seriesId)]; + const videoData = (entry && entry.video_data) || {}; + const episodes = (videoData.video_list || []).map((item) => ({ + index: Number(item.vid_index) || 0, + vid: String(item.vid || ""), + title: String(item.episode_title || "").trim().slice(0, 30), + duration: Math.max(0, Number(item.duration) || 0), + commentCount: Math.max(0, Number(item.comment_count) || 0), + imageUrl: extractImageUrl(item.episode_cover || item.cover), + })).filter((item) => item.vid).sort((a, b) => a.index - b.index); + return { + episodes, + imageUrl: extractImageUrl(videoData.series_cover) || + (episodes.find((episode) => episode.imageUrl) || {}).imageUrl || + "", + }; + } catch (error) { + log("error", `[Hongguo] 获取剧集失败: ${error.message}`); + return { episodes: [], imageUrl: "" }; + } + } + + async resolveEpisodeInfo(value) { + if (String(value || "").split("#", 1)[0].startsWith("hongguo:v1:")) { + return parseHongguoEpisodeId(value); + } + + const player = parseHongguoPlayerUrl(value); + const cacheKey = `${player.seriesId}:${player.vid}`; + let resolution = this.playerEpisodeResolutions.get(cacheKey); + if (!resolution) { + resolution = (async () => { + const details = await this.getEpisodes(player.seriesId); + const episode = details.episodes.find((item) => item.vid === player.vid); + if (!episode) { + throw new Error(`播放链接中的 vid ${player.vid} 不属于剧集 ${player.seriesId}`); + } + return { + seriesId: player.seriesId, + vid: player.vid, + duration: episode.duration, + }; + })(); + this.playerEpisodeResolutions.set(cacheKey, resolution); + } + + try { + return await resolution; + } catch (error) { + if (this.playerEpisodeResolutions.get(cacheKey) === resolution) { + this.playerEpisodeResolutions.delete(cacheKey); + } + throw error; + } + } + + createCommentSlotRelease() { + let released = false; + return () => { + if (released) return; + released = true; + const next = this.commentRequestWaiters.shift(); + if (next) { + next(this.createCommentSlotRelease()); + } else { + this.activeCommentRequests--; + } + }; + } + + async acquireCommentSlot() { + if (this.activeCommentRequests < COMMENT_CONCURRENCY) { + this.activeCommentRequests++; + return this.createCommentSlotRelease(); + } + return new Promise((resolve) => this.commentRequestWaiters.push(resolve)); + } + + resolveSeriesId(value) { + if (isHongguoSeriesId(value)) return parseHongguoSeriesId(value).seriesId; + if (String(value || "").split("#", 1)[0].startsWith("hongguo:v1:")) { + return parseHongguoEpisodeId(value).seriesId; + } + return parseHongguoPlayerUrl(value).seriesId; + } + + shouldMergeAllEpisodes(value) { + return isHongguoSeriesId(value) || Boolean(globals.hongguoMergeAllEpisodes); + } + + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { + if (!Array.isArray(sourceAnimes)) return []; + const transformed = []; + const filtered = sourceAnimes.filter((item) => titleMatches(item.name, queryTitle, querySeason)); + for (const anime of filtered) { + const details = await this.getEpisodes(anime.seriesId); + const episodes = details.episodes; + const links = globals.hongguoMergeAllEpisodes && episodes.length + ? [{ + name: "全集", + url: createHongguoSeriesId(anime.seriesId), + title: "【hongguo】 全集", + }] + : episodes.map((episode) => { + const episodeTitle = `第${episode.index}集${episode.title ? ` ${episode.title}` : ""}`; + return { + name: episodeTitle, + url: createHongguoEpisodeId(anime.seriesId, episode.vid, episode.duration), + title: `【hongguo】 ${episodeTitle}`, + }; + }); + if (!links.length) continue; + const item = { + animeId: convertToAsciiSum(anime.seriesId), + bangumiId: String(anime.seriesId), + animeTitle: `${anime.name}【短剧】from hongguo`, + type: "短剧", + typeDescription: "短剧", + imageUrl: anime.imageUrl || details.imageUrl || "", + startDate: generateValidStartDate(""), + episodeCount: links.length, + rating: Number(anime.score) || 0, + isFavorited: true, + source: "hongguo", + }; + transformed.push(item); + addAnime({ ...item, links }, detailStore); + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + } + this.sortAndPushAnimesByYear(transformed, curAnimes); + return transformed; + } + + buildCommentBody(info, startMs, cursor) { + return { + comment_source: COMMENT_SOURCE, + server_channel: SERVER_CHANNEL, + group_id: info.vid, + group_type: 30, + comment_type: 20, + sort: 1, + business_param: { + book_id: info.seriesId, + start_offset_time: Math.max(0, Math.trunc(startMs)), + playlet_item_duration: Math.max(0, Math.trunc(info.duration * 1000)), + need_danmaku_guide_type: [1, 3, 4, 2], + }, + count: COMMENT_COUNT, + cursor: String(cursor || ""), + aid: Number(CLIENT_CONFIG.baseQuery.aid), + compliance_status: 0, + }; + } + + async fetchCommentWindow(info, startMs, cursor = "") { + const path = `/novel/commentapi/comment/list/${encodeURIComponent(info.vid)}/v1/`; + const payload = await this.request("POST", path, { + body: this.buildCommentBody(info, startMs, cursor), + comment: true, + }); + const data = payload.data || {}; + const listInfo = data.common_list_info || {}; + const extra = data.extra || {}; + let nextStart = Number(extra.next_query_danmaku_list_time); + if (!Number.isFinite(nextStart)) nextStart = startMs + COMMENT_WINDOW_MS; + if (nextStart <= startMs) nextStart = startMs + COMMENT_WINDOW_MS; + return { + comments: parseDanmuResponse(payload), + nextStart, + cursor: String(listInfo.cursor || ""), + hasMore: Boolean(listInfo.has_more), + }; + } + + async getDanmuForEpisode(info) { + const durationMs = info.duration * 1000; + const comments = []; + const seenMarkers = new Set(); + let startMs = 0; + let cursor = ""; + const maxRequests = Math.max(1, Math.ceil(durationMs / COMMENT_WINDOW_MS) + 5); + for (let requestIndex = 0; requestIndex < maxRequests; requestIndex++) { + const marker = `${startMs}:${cursor}`; + if (seenMarkers.has(marker)) break; + seenMarkers.add(marker); + const page = await this.fetchCommentWindow(info, startMs, cursor); + comments.push(...page.comments); + if (durationMs && page.nextStart >= durationMs) break; + if (!page.hasMore && !durationMs) break; + startMs = page.nextStart; + cursor = page.cursor; + } + return comments; + } + + async getSeriesDanmu(seriesId) { + const details = await this.getEpisodes(seriesId); + let cumulativeMs = 0; + const episodeTasks = details.episodes.map((episode) => { + const task = { episode, offsetMs: cumulativeMs }; + cumulativeMs += Math.max(0, Number(episode.duration) || 0) * 1000; + return task; + }); + const groupedComments = await mapWithConcurrency(episodeTasks, COMMENT_CONCURRENCY, async ({ episode, offsetMs }) => { + const episodeComments = await this.getDanmuForEpisode({ + seriesId: String(seriesId), + vid: episode.vid, + duration: episode.duration, + }); + return episodeComments.map((comment) => ({ + ...comment, + offsetMs: comment.offsetMs + offsetMs, + })); + }); + const comments = []; + for (const episodeComments of groupedComments) { + comments.push(...episodeComments); + } + return dedupeDanmus(comments); + } + + async getEpisodeDanmu(id) { + try { + if (this.shouldMergeAllEpisodes(id)) { + return await this.getSeriesDanmu(this.resolveSeriesId(id)); + } + const info = await this.resolveEpisodeInfo(id); + return dedupeDanmus(await this.getDanmuForEpisode(info)); + } catch (error) { + log("error", `[Hongguo] 获取弹幕失败: ${error.message}`); + return []; + } + } + + async getEpisodeDanmuSegments(id) { + try { + if (this.shouldMergeAllEpisodes(id)) { + return await this.getSeriesDanmuSegments(this.resolveSeriesId(id)); + } + const info = await this.resolveEpisodeInfo(id); + const episodeId = createHongguoEpisodeId(info.seriesId, info.vid, info.duration); + const segmentList = []; + for (let start = 0; start < info.duration; start += COMMENT_WINDOW_MS / 1000) { + segmentList.push({ + type: "hongguo", + segment_start: start, + segment_end: Math.min(start + COMMENT_WINDOW_MS / 1000, info.duration), + url: `${episodeId}#segment=${start}`, + }); + } + return new SegmentListResponse({ type: "hongguo", segmentList, duration: info.duration }); + } catch (error) { + log("error", `[Hongguo] 创建弹幕分段失败: ${error.message}`); + return new SegmentListResponse({ type: "hongguo", segmentList: [] }); + } + } + + async getSeriesDanmuSegments(seriesId) { + const details = await this.getEpisodes(seriesId); + const segmentList = []; + let cumulativeSeconds = 0; + for (const episode of details.episodes) { + const duration = Math.max(0, Number(episode.duration) || 0); + const episodeId = createHongguoEpisodeId(seriesId, episode.vid, duration); + for (let start = 0; start < duration; start += COMMENT_WINDOW_MS / 1000) { + const end = Math.min(start + COMMENT_WINDOW_MS / 1000, duration); + segmentList.push({ + type: "hongguo", + segment_start: cumulativeSeconds + start, + segment_end: cumulativeSeconds + end, + url: `${episodeId}#segment=${start}&offset=${cumulativeSeconds}`, + }); + } + cumulativeSeconds += duration; + } + return new SegmentListResponse({ type: "hongguo", segmentList, duration: cumulativeSeconds }); + } + + async getEpisodeSegmentDanmu(segment) { + try { + const info = await this.resolveEpisodeInfo(segment.url); + const globalOffsetSeconds = Math.max(0, getFragmentNumber(segment.url, "offset") || 0); + const fragmentStart = getFragmentNumber(segment.url, "segment"); + const localStartSeconds = Math.max(0, fragmentStart == null + ? Number(segment.segment_start) - globalOffsetSeconds + : fragmentStart); + const segmentDurationSeconds = Math.max(0, Number(segment.segment_end) - Number(segment.segment_start)); + const localEndSeconds = Math.min(info.duration, localStartSeconds + segmentDurationSeconds); + const startMs = localStartSeconds * 1000; + const endMs = Math.max(startMs, localEndSeconds * 1000); + const page = await this.fetchCommentWindow(info, startMs, ""); + const offsetMs = globalOffsetSeconds * 1000; + return dedupeDanmus(page.comments + .filter((item) => item.offsetMs >= startMs && item.offsetMs < endMs) + .map((item) => ({ ...item, offsetMs: item.offsetMs + offsetMs }))); + } catch (error) { + log("error", `[Hongguo] 获取分段弹幕失败: ${error.message}`); + return []; + } + } + + formatComments(comments) { + return comments.map((comment) => ({ + p: `${(comment.offsetMs / 1000).toFixed(2)},1,16777215,[hongguo]`, + m: comment.text, + t: comment.offsetMs / 1000, + like: comment.diggCount, + })); + } +} diff --git a/danmu_api/ui/js/logview.js b/danmu_api/ui/js/logview.js index 6ff55b6..b8191a3 100644 --- a/danmu_api/ui/js/logview.js +++ b/danmu_api/ui/js/logview.js @@ -115,7 +115,7 @@ function createFilterContainer() { const tagGroupOrder = [ ['system', 'ai'], ['utils', 'cache', 'merge'], - ['360kan', 'aiyifan', 'animeko', 'bahamut', 'bilibili', 'custom', 'dandan', 'douban', 'hanjutv', 'iqiyi', 'leshi', 'maiduidui', 'mango', 'migu', 'other', 'renren', 'sohu', 'tencent', 'tmdb', 'vod', 'xigua', 'youku'], + ['360kan', 'aiyifan', 'animeko', 'bahamut', 'bilibili', 'custom', 'dandan', 'douban', 'hanjutv', 'hongguo', 'iqiyi', 'leshi', 'maiduidui', 'mango', 'migu', 'other', 'renren', 'sohu', 'tencent', 'tmdb', 'vod', 'xigua', 'youku'], ]; const tagOrderMap = {}; tagGroupOrder.forEach((group, gi) => group.forEach((tag, ti) => tagOrderMap[tag] = gi * 1000 + ti)); diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js index bd267f2..c2d1712 100644 --- a/danmu_api/ui/template.js +++ b/danmu_api/ui/template.js @@ -399,7 +399,7 @@ export const HTML_TEMPLATE = /* html */ `