新增红果短剧源 (#394)

* feat: 新增红果短剧源支持

- 在 README 和配置示例中添加红果短剧(hongguo)源,扩展弹幕获取来源
- 更新 SOURCE_ORDER、PLATFORM_ORDER、MERGE_SOURCE_PAIRS 等配置项的可选值
- 新增 hongguo.js 源文件,完善项目文档和目录结构说明

* fix: 支持通过红果播放链接获取弹幕

* feat: 添加红果短剧全集合并选项,支持按集号顺序合并所有集弹幕

- 新增环境变量 `HONGGUO_MERGE_ALL_EPISODES`,默认为 false,启用后将红果短剧所有集的弹幕按剧集顺序合并为一集返回,每集弹幕时间累加前面各集时长,剧集列表显示为“全集”
- 修改缓存键生成逻辑,当启用合并时在缓存键后追加标识,避免与未合并缓存冲突
- 新增 `parseHongguoSeriesId`、`isHongguoSeriesId` 等工具函数用于识别红果系列 ID
- 更新 README 和 `.env.example` 文档说明新配置项

* test: 增强 forward-widget 测试流程的健壮性与剧集匹配逻辑

- 注释掉 storage 模拟中的调试日志,减少测试输出干扰
- 增加进程退出码设置,测试失败时正确返回非零状态
- 新增搜索结果为空时的异常抛出,避免后续空引用
- 优化剧集选择:优先按集数精确匹配,回退到数组索引,增加匹配失败提示
- 新增弹幕分片选择:优先选择包含 450 秒左右的分片,否则取中间时间点
- 增加对分片列表和弹幕列表为空时的异常检查,确保链路完整性
- 调整输出日志,明确显示所选剧集、分片和请求时间点,便于调试

* test: 更新 forward-widget 测试用例参数

- 调整测试数据中的 episode、sourceOrder 和 title 值
- 新增 hongguoMergeAllEpisodes 配置项用于测试
- 更新测试场景以覆盖新逻辑

* feat(forward): 支持debug构建并添加真机远程日志回传

- 新增 `--debug` 构建参数,生成 `logvar-danmu.debug.js` 供真机调试使用
- debug 版本将 `httpGet/httpPost` 替换为带日志回传的调试实现
- 实时回传 handler 执行、请求详情、耗时、异常及 console 输出到服务端终端和 `/api/logs`
- 敏感信息(Cookie、token、API key)在回传前自动脱敏,且回传失败不影响主流程
- 更新 README,补充 Forward 真机调试步骤和使用说明
This commit is contained in:
huangxd- authored and GitHub committed 2026-07-26 20:56:32 +08:00
1 parent 1bb163e77d
commit c4d2951eb5
17 files changed
+2169 -273

No files matched your search

+34 -7
View File
@@ -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 服务器
<img src="https://i.mji.rip/2025/09/14/80aa5205d49a767447f61938f2dada20.jpeg" style="width:400px" />
<img src="https://i.mji.rip/2025/09/14/9fdf945fb247994518042691f60d7849.jpeg" style="width:400px" />
<img src="https://i.mji.rip/2025/09/14/dbacc0cf9c8a839f16b8960de1f38f11.jpeg" style="width:400px" />
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 | 【可选】合并映射表,用于自定义源合并行为,默认为空。<br>格式 1 (合并):`副源剧名/S季数@来源 -> 主源剧名/S季数@来源 \| E副源集数>E主源集数`<br>格式 2 (阻断):`副源剧名/S季数@来源 × 主源剧名/S季数@来源`<br>说明:`[/S季数]` 与 `[\|路由规则]` 为可选项,留空则交由程序判断。多个规则用分号隔开,多段路由用逗号分隔。<br>示例:<br>1. 常规合并:`天气之子@bilibili -> 天气之子@dandan`<br>2. 多集路由:`我推的孩子/S01@bahamut -> 我推的孩子/S03@dandan \| E25~E35>E25~E35`<br>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 # 乐视视频源
+14 -7
View File
@@ -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(/(?<!function\s)\bhttpGet\s*\(/g, `${httpGetReplacement}(`);
outputContent = outputContent.replace(/(?<!function\s)\bhttpPost\s*\(/g, `${httpPostReplacement}(`);
// 删除本地redis相关
outputContent = outputContent.replace(/.*setLocalRedisKey.*\n?/g, '\n');
@@ -137,14 +143,15 @@ const forwardRuntimeCompatPlugin = {
outputContent = outputContent.replace(/.*bangumi-data-util\.js.*\n?/g, '');
// 保存修改后的内容
fs.writeFileSync('dist/logvar-danmu.js', outputContent);
fs.writeFileSync(outputFile, outputContent);
}
});
}
}
],
define: {
'widgetVersion': `"${Globals.VERSION}"`
'widgetVersion': `"${Globals.VERSION}"`,
'globalThis.__FORWARD_WIDGET_DEBUG__': debugBuild ? 'true' : 'false'
},
banner: {
js: customPolyfillContent
@@ -152,7 +159,7 @@ const forwardRuntimeCompatPlugin = {
logLevel: 'info'
});
console.log('Forward widget bundle created successfully!');
console.log(`Forward widget ${debugBuild ? 'debug ' : ''}bundle created successfully: ${outputFile}`);
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
+8 -5
View File
@@ -77,9 +77,9 @@ YOUKU_CONCURRENCY=8
# ==================== 源排序配置 ====================
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, aiyifan, animeko, custom)
# 默认值:douban,360,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, migu, sohu , leshi, xigua, maiduidui, aiyifan等)
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源、乐视源、西瓜源、埋堆堆源,请手动添加:SOURCE_ORDER=douban,360,tencent,youku,iqiyi,imgo,bilibili,migu,renren,hanjutv,sohu,leshi,xigua,maiduidui,aiyifan
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, aiyifan, hongguo, animeko, custom)
# 默认值:douban,360,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, migu, sohu, leshi, xigua, maiduidui, aiyifan, hongguo 等)
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源、乐视源、西瓜源、埋堆堆源、爱壹帆源、红果源,请手动添加:SOURCE_ORDER=douban,360,tencent,youku,iqiyi,imgo,bilibili,migu,renren,hanjutv,sohu,leshi,xigua,maiduidui,aiyifan,hongguo
# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=douban,360,renren,hanjutv,bahamut
# 如需使用弹弹play源,请手动添加:SOURCE_ORDER=douban,360,renren,hanjutv,dandan
# 如需使用TMDB源,请手动添加:SOURCE_ORDER=douban,360,tmdb,renren,hanjutv
@@ -89,12 +89,12 @@ SOURCE_ORDER=douban,360,renren,hanjutv
# 平台优先级排序(用于自动匹配)
# 默认值:空(自动匹配)
# 可选值:qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, aiyifan, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
# 可选值:qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, aiyifan, hongguo, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
# 说明:平台排序配置,可以配置自动匹配时的优选平台,当配置合并平台的时候可以指定期望的合并源
# PLATFORM_ORDER=
# ==================== 源合并配置 ====================
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, aiyifan, animeko)
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, aiyifan, hongguo, animeko)
# 默认值:空
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环
# 格式:源字段&源字段&源字段,源字段
@@ -309,6 +309,9 @@ SEARCH_CACHE_MINUTES=3
# 设置为 0 表示不缓存
COMMENT_CACHE_MINUTES=3
# 红果短剧是否将所有集弹幕按剧集顺序合并为一集,默认关闭
HONGGUO_MERGE_ALL_EPISODES=false
# 是否记住手动选择结果
# 默认值:true
# 说明:是否记住手动选择结果,用于match自动匹配时优选上次的选择
+42 -8
View File
@@ -39,6 +39,7 @@ import LeshiSource from "../sources/leshi.js";
import XiguaSource from "../sources/xigua.js";
import MaiduiduiSource from "../sources/maiduidui.js";
import AiyifanSource from "../sources/aiyifan.js";
import HongguoSource, { isHongguoPlayerUrl } from "../sources/hongguo.js";
import AnimekoSource from "../sources/animeko.js";
import OtherSource from "../sources/other.js";
import { Anime, AnimeMatch, Episodes, Bangumi } from "../models/dandan-model.js";
@@ -65,6 +66,7 @@ const leshiSource = new LeshiSource();
const xiguaSource = new XiguaSource();
const maiduiduiSource = new MaiduiduiSource();
const aiyifanSource = new AiyifanSource();
const hongguoSource = new HongguoSource();
const animekoSource = new AnimekoSource();
const otherSource = new OtherSource();
const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource, miguSource);
@@ -73,6 +75,13 @@ const tmdbSource = new TmdbSource(doubanSource);
// 用于聚合请求的去重Map
const PENDING_DANMAKU_REQUESTS = new Map();
function resolveCommentCacheKey(url) {
const value = String(url || "");
if (!globals.hongguoMergeAllEpisodes) return url;
const containsHongguo = value.includes("hongguo:") || /https?:\/\/(?:www\.)?hongguoduanju\.com(?::\d+)?\/player\//i.test(value);
return containsHongguo ? `${value}::hongguo-all-episodes` : url;
}
function normalizeDurationValue(rawValue) {
const duration = Number(rawValue || 0);
if (!Number.isFinite(duration) || duration <= 0) return 0;
@@ -108,6 +117,10 @@ function extractDurationFromSegments(segmentResult) {
}
async function resolveUrlDuration(url) {
if (String(url || '').startsWith('hongguo:') || isHongguoPlayerUrl(url)) {
const segmentResult = await sourceLogContext.run('hongguo', () => 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 = {
+83
View File
@@ -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);
}
+1 -1
View File
@@ -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]。"
});
}
+5 -3
View File
@@ -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时生效
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -399,7 +399,7 @@ export const HTML_TEMPLATE = /* html */ `
<!-- 项目声明 -->
<footer class="footer">
<p class="footer-text">
一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋帆弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/hf等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋帆红弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/hf等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
</p>
<p class="footer-text">本项目仅为个人学习爱好开发,代码开源。如有任何侵权行为,请联系本人删除。</p>
<p class="footer-text">本项目完全免费,不收取任何费用,请勿上当受骗。</p>
+202 -1
View File
@@ -46,7 +46,16 @@ export function md5(message) {
return utf8;
}
message = toUtf8(message);
if (message instanceof Uint8Array) {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < message.length; offset += chunkSize) {
binary += String.fromCharCode(...message.subarray(offset, offset + chunkSize));
}
message = binary;
} else {
message = toUtf8(message);
}
function rotateLeft(lValue, iShiftBits) {
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
@@ -202,6 +211,126 @@ export function md5(message) {
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
}
export function hexToBytes(hex) {
const normalized = String(hex || "").replace(/\s+/g, "");
if (normalized.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(normalized)) {
throw new TypeError("hex input must contain an even number of hexadecimal characters");
}
return Uint8Array.from({ length: normalized.length / 2 }, (_, index) =>
parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
);
}
export function bytesToHex(bytes) {
return Array.from(bytes || [], (value) => value.toString(16).padStart(2, "0")).join("");
}
export function pkcs7Pad(bytes, blockSize = 16) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (!Number.isInteger(blockSize) || blockSize < 1 || blockSize > 255) {
throw new RangeError("PKCS#7 block size must be an integer between 1 and 255");
}
const padding = blockSize - (input.length % blockSize);
const output = new Uint8Array(input.length + padding);
output.set(input);
output.fill(padding, input.length);
return output;
}
// SM3 digest used by ByteDance's Argus request signature.
export function sm3Bytes(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input || []);
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64;
const padded = new Uint8Array(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
const bitLengthLow = (bytes.length << 3) >>> 0;
const bitLengthHigh = Math.floor(bytes.length / 0x20000000) >>> 0;
for (let i = 0; i < 4; i++) {
padded[paddedLength - 8 + i] = (bitLengthHigh >>> ((3 - i) * 8)) & 0xff;
padded[paddedLength - 4 + i] = (bitLengthLow >>> ((3 - i) * 8)) & 0xff;
}
const rotateLeft = (value, count) => {
const shift = count & 31;
return shift === 0 ? value >>> 0 : ((value << shift) | (value >>> (32 - shift))) >>> 0;
};
const p0 = (x) => (x ^ rotateLeft(x, 9) ^ rotateLeft(x, 17)) >>> 0;
const p1 = (x) => (x ^ rotateLeft(x, 15) ^ rotateLeft(x, 23)) >>> 0;
const state = new Uint32Array([
0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600,
0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e
]);
for (let offset = 0; offset < padded.length; offset += 64) {
const w = new Uint32Array(68);
const wPrime = new Uint32Array(64);
for (let i = 0; i < 16; i++) {
const p = offset + i * 4;
w[i] = (
(padded[p] << 24) |
(padded[p + 1] << 16) |
(padded[p + 2] << 8) |
padded[p + 3]
) >>> 0;
}
for (let i = 16; i < 68; i++) {
w[i] = (
p1(w[i - 16] ^ w[i - 9] ^ rotateLeft(w[i - 3], 15)) ^
rotateLeft(w[i - 13], 7) ^
w[i - 6]
) >>> 0;
}
for (let i = 0; i < 64; i++) wPrime[i] = (w[i] ^ w[i + 4]) >>> 0;
let a = state[0];
let b = state[1];
let c = state[2];
let d = state[3];
let e = state[4];
let f = state[5];
let g = state[6];
let h = state[7];
for (let i = 0; i < 64; i++) {
const constant = i < 16 ? 0x79cc4519 : 0x7a879d8a;
const ss1 = rotateLeft((rotateLeft(a, 12) + e + rotateLeft(constant, i)) >>> 0, 7);
const ss2 = (ss1 ^ rotateLeft(a, 12)) >>> 0;
const ff = i < 16 ? (a ^ b ^ c) : ((a & b) | (a & c) | (b & c));
const gg = i < 16 ? (e ^ f ^ g) : ((e & f) | (~e & g));
const tt1 = (ff + d + ss2 + wPrime[i]) >>> 0;
const tt2 = (gg + h + ss1 + w[i]) >>> 0;
d = c;
c = rotateLeft(b, 9);
b = a;
a = tt1;
h = g;
g = rotateLeft(f, 19);
f = e;
e = p0(tt2);
}
state[0] ^= a;
state[1] ^= b;
state[2] ^= c;
state[3] ^= d;
state[4] ^= e;
state[5] ^= f;
state[6] ^= g;
state[7] ^= h;
}
const output = new Uint8Array(32);
for (let i = 0; i < state.length; i++) {
output[i * 4] = state[i] >>> 24;
output[i * 4 + 1] = state[i] >>> 16;
output[i * 4 + 2] = state[i] >>> 8;
output[i * 4 + 3] = state[i];
}
return output;
}
export function parseDanmakuBase64(base64) {
const bytes = base64ToBytes(base64);
const elems = [];
@@ -418,6 +547,78 @@ function invMixColumns(state){
return out;
}
function shiftRows(state) {
const out = new Uint8Array(16);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) out[r + 4 * c] = state[r + 4 * ((c + r) % 4)];
}
return out;
}
function mixColumns(state) {
function mul(a, b) {
let p = 0;
for (let i = 0; i < 8; i++) {
if (b & 1) p ^= a;
const hi = a & 0x80;
a = (a << 1) & 0xff;
if (hi) a ^= 0x1b;
b >>>= 1;
}
return p;
}
const out = new Uint8Array(16);
for (let c = 0; c < 4; c++) {
const i = c * 4;
const a = state[i];
const b = state[i + 1];
const d = state[i + 2];
const e = state[i + 3];
out[i] = mul(a, 2) ^ mul(b, 3) ^ d ^ e;
out[i + 1] = a ^ mul(b, 2) ^ mul(d, 3) ^ e;
out[i + 2] = a ^ b ^ mul(d, 2) ^ mul(e, 3);
out[i + 3] = mul(a, 3) ^ b ^ d ^ mul(e, 2);
}
return out;
}
function aesEncryptBlock(input, expandedKey) {
let state = addRoundKey(new Uint8Array(input), expandedKey.slice(0, 4));
for (let round = 1; round < 10; round++) {
state = subWord(state);
state = shiftRows(state);
state = mixColumns(state);
state = addRoundKey(state, expandedKey.slice(round * 4, (round + 1) * 4));
}
state = subWord(state);
state = shiftRows(state);
return addRoundKey(state, expandedKey.slice(40, 44));
}
// AES-128-CBC encryption with PKCS#7 padding, implemented without WebCrypto.
export function aesCbcEncrypt(input, key, iv) {
const plainBytes = input instanceof Uint8Array ? input : new Uint8Array(input || []);
const keyBytes = key instanceof Uint8Array ? key : new Uint8Array(key || []);
const ivBytes = iv instanceof Uint8Array ? iv : new Uint8Array(iv || []);
if (keyBytes.length !== 16 || ivBytes.length !== 16) {
throw new RangeError("AES-128-CBC key and IV must both be 16 bytes");
}
const padded = pkcs7Pad(plainBytes);
const expandedKey = keyExpansion(keyBytes);
const output = new Uint8Array(padded.length);
let previous = new Uint8Array(ivBytes);
for (let offset = 0; offset < padded.length; offset += 16) {
const block = padded.slice(offset, offset + 16);
for (let i = 0; i < 16; i++) block[i] ^= previous[i];
previous = aesEncryptBlock(block, expandedKey);
output.set(previous, offset);
}
return output;
}
// ====================== ECB 模式解密 ======================
function aesDecryptECB(cipherBytes, keyBytes){
const w = keyExpansion(keyBytes);
+2 -67
View File
@@ -1,4 +1,4 @@
import { md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes, invSubBytes, subWord, keyExpansion, invShiftRows } from "./codec-util.js";
import { aesCbcEncrypt, md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes, invSubBytes, keyExpansion, invShiftRows } from "./codec-util.js";
function normalizePositiveTimestamp(value, fallbackValue = Date.now()) {
const ts = Number(value);
@@ -57,15 +57,6 @@ function xorBytes(a, b) {
return out;
}
function pkcs7Pad(bytes, blockSize = 16) {
const remain = bytes.length % blockSize;
const padSize = remain === 0 ? blockSize : blockSize - remain;
const result = new Uint8Array(bytes.length + padSize);
result.set(bytes, 0);
result.fill(padSize, bytes.length);
return result;
}
function stripControlChars(text) {
return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
}
@@ -80,16 +71,6 @@ function addRoundKey(state, w, round) {
return out;
}
function shiftRows(state) {
const out = new Uint8Array(16);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
out[r + 4 * c] = state[r + 4 * ((c + r) % 4)];
}
}
return out;
}
function gfMul(a, b) {
let p = 0;
let aa = a;
@@ -104,18 +85,6 @@ function gfMul(a, b) {
return p;
}
function mixColumns(state) {
const out = new Uint8Array(16);
for (let c = 0; c < 4; c++) {
const col = state.slice(4 * c, 4 * c + 4);
out[4 * c + 0] = gfMul(col[0], 0x02) ^ gfMul(col[1], 0x03) ^ col[2] ^ col[3];
out[4 * c + 1] = col[0] ^ gfMul(col[1], 0x02) ^ gfMul(col[2], 0x03) ^ col[3];
out[4 * c + 2] = col[0] ^ col[1] ^ gfMul(col[2], 0x02) ^ gfMul(col[3], 0x03);
out[4 * c + 3] = gfMul(col[0], 0x03) ^ col[1] ^ col[2] ^ gfMul(col[3], 0x02);
}
return out;
}
function invMixColumns(state) {
const out = new Uint8Array(16);
for (let c = 0; c < 4; c++) {
@@ -128,23 +97,6 @@ function invMixColumns(state) {
return out;
}
function aesEncryptBlock(input, w) {
let state = new Uint8Array(input);
state = addRoundKey(state, w, 0);
for (let round = 1; round <= 9; round++) {
state = subWord(state);
state = shiftRows(state);
state = mixColumns(state);
state = addRoundKey(state, w, round);
}
state = subWord(state);
state = shiftRows(state);
state = addRoundKey(state, w, 10);
return state;
}
function aesDecryptBlock(input, w) {
let state = new Uint8Array(input);
state = addRoundKey(state, w, 10);
@@ -162,23 +114,6 @@ function aesDecryptBlock(input, w) {
return state;
}
function aesCbcEncryptPure(plainBytes, keyBytes, ivBytes) {
const padded = pkcs7Pad(plainBytes, 16);
const w = keyExpansion(keyBytes);
const out = new Uint8Array(padded.length);
let prev = new Uint8Array(ivBytes);
for (let i = 0; i < padded.length; i += 16) {
const block = padded.slice(i, i + 16);
const mixed = xorBytes(block, prev);
const cipherBlock = aesEncryptBlock(mixed, w);
out.set(cipherBlock, i);
prev = cipherBlock;
}
return out;
}
function aesCbcDecryptPureNoUnpad(cipherBytes, keyBytes, ivBytes) {
if (cipherBytes.length % 16 !== 0) {
throw new Error(`密文长度不是16的倍数: ${cipherBytes.length}`);
@@ -202,7 +137,7 @@ async function aesCbcEncryptToBase64(plainText, key, iv) {
const keyBytes = utf8Encode(key);
const ivBytes = utf8Encode(iv);
const plainBytes = utf8Encode(plainText);
const cipherBytes = aesCbcEncryptPure(plainBytes, keyBytes, ivBytes);
const cipherBytes = aesCbcEncrypt(plainBytes, keyBytes, ivBytes);
return bytesToBase64(cipherBytes);
}
+12 -2
View File
@@ -9,6 +9,7 @@ import { initBangumiData } from "./utils/bangumi-data-util.js";
import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js";
import { getFongmiDanmaku } from "./apis/clients/fongmi-api.js";
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords, handleCacheAnimes } from "./apis/system-api.js";
import { handleForwardTrace } from "./apis/forward-trace-api.js";
import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js";
import { Segment } from "./models/dandan-model.js"
import {
@@ -257,7 +258,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
if (path !== "/" && path !== "/danmaku" && path !== "/api/logs" && !path.startsWith('/api/env')
&& !path.startsWith('/api/deploy') && !path.startsWith('/api/cache')
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
&& !path.startsWith('/api/ai')) {
&& !path.startsWith('/api/ai') && !path.startsWith('/api/debug')) {
log("info", `[Path Check] Starting path normalization for: "${path}"`);
const pathBeforeCleanup = path; // 保存清理前的路径检查是否修改
@@ -281,7 +282,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
if (!path.startsWith('/api/v2') && path !== '/' && !path.startsWith('/api/logs')
&& !path.startsWith('/api/env') && !path.startsWith('/api/cache')
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
&& !path.startsWith('/api/ai')) {
&& !path.startsWith('/api/ai') && !path.startsWith('/api/debug')) {
if (path.startsWith('/v2/') || path === '/v2') {
log("info", `[Path Check] Path is missing /api prefix. Adding /api...`);
path = '/api' + path;
@@ -489,6 +490,15 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
return handleLogs();
}
if (path === '/api/debug/forward-trace') {
if (!isValidToken) {
return jsonResponse({ success: false, errorMessage: 'Explicit token required for Forward traces' }, 401);
}
if (method === 'POST') {
return handleForwardTrace(req);
}
}
// POST /api/logs/clear
if (path === "/api/logs/clear" && method === "POST") {
return handleClearLogs();
+291 -135
View File
@@ -5,7 +5,7 @@ dotenv.config();
import test from 'node:test';
import assert from 'node:assert';
import { handleRequest } from './worker.js';
import { extractTitleSeasonEpisode, getBangumi, getComment, matchAnime, searchAnime, buildSearchAnimeUrl } from "./apis/dandan-api.js";
import { extractTitleSeasonEpisode, getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, buildSearchAnimeUrl } from "./apis/dandan-api.js";
import { getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry } from "./utils/redis-util.js";
import { getLocalRedisKey, setLocalRedisKey, setLocalRedisKeyWithExpiry } from "./utils/local-redis-util.js";
import { getImdbepisodes } from "./utils/imdb-util.js";
@@ -26,6 +26,7 @@ import LeshiSource from "./sources/leshi.js";
import XiguaSource from "./sources/xigua.js";
import MaiduiduiSource from "./sources/maiduidui.js";
import AiyifanSource from "./sources/aiyifan.js";
import HongguoSource, { parseHongguoPlayerUrl } from "./sources/hongguo.js";
import AnimekoSource from "./sources/animeko.js";
import OtherSource from "./sources/other.js";
import { NodeHandler } from "./configs/handlers/node-handler.js";
@@ -129,6 +130,7 @@ test('worker.js API endpoints', async (t) => {
const xiguaSource = new XiguaSource();
const maiduiduiSource = new MaiduiduiSource();
const aiyifanSource = new AiyifanSource();
const hongguoSource = new HongguoSource();
const animekoSource = new AnimekoSource();
const otherSource = new OtherSource();
@@ -1143,157 +1145,311 @@ test('worker.js API endpoints', async (t) => {
// assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`);
// });
// await t.test('GET hongguo danmu', async () => {
// const episodeId = 'hongguo:v1:series-1:vid-1:60';
// const originalFetchCommentWindow = hongguoSource.fetchCommentWindow;
// hongguoSource.fetchCommentWindow = async (_info, startMs) => ({
// comments: startMs === 0
// ? [{ commentId: 'comment-1', offsetMs: 1500, text: 'first', diggCount: 7 }]
// : [
// { commentId: 'comment-1', offsetMs: 1500, text: 'first', diggCount: 7 },
// { commentId: 'comment-2', offsetMs: 31500, text: 'second', diggCount: 3 },
// ],
// nextStart: startMs + 30000,
// cursor: `cursor-${startMs}`,
// hasMore: true,
// });
// try {
// const res = await hongguoSource.getComments(episodeId, 'hongguo');
// assert.equal(res.length, 2);
// assert.deepEqual(res.map((item) => item.t), [1.5, 31.5]);
// assert.match(res[0].p, /\[hongguo\]$/);
// } finally {
// hongguoSource.fetchCommentWindow = originalFetchCommentWindow;
// }
// });
// await t.test('GET hongguo danmu segments', async () => {
// const episodeId = 'hongguo:v1:series-1:vid-1:60';
// const res = await hongguoSource.getComments(episodeId, 'hongguo', true);
// assert.equal(res.type, 'hongguo');
// assert.equal(res.duration, 60);
// assert.equal(res.segmentList.length, 2);
// assert.notEqual(res.segmentList[0].url, res.segmentList[1].url);
// });
// await t.test('GET hongguo segment danmu', async () => {
// const originalFetchCommentWindow = hongguoSource.fetchCommentWindow;
// hongguoSource.fetchCommentWindow = async () => ({
// comments: [
// { commentId: 'before', offsetMs: 29999, text: 'before', diggCount: 0 },
// { commentId: 'inside', offsetMs: 31500, text: 'inside', diggCount: 2 },
// { commentId: 'after', offsetMs: 60000, text: 'after', diggCount: 0 },
// ],
// nextStart: 60000,
// cursor: '',
// hasMore: false,
// });
// try {
// const segment = Segment.fromJson({
// type: 'hongguo',
// segment_start: 30,
// segment_end: 60,
// url: 'hongguo:v1:series-1:vid-1:60#segment=30',
// });
// const res = await hongguoSource.getSegmentComments(segment);
// assert.equal(res.length, 1);
// assert.equal(res[0].m, 'inside');
// assert.equal(res[0].t, 31.5);
// } finally {
// hongguoSource.fetchCommentWindow = originalFetchCommentWindow;
// }
// });
// await t.test('Hongguo player URL should resolve the exact episode', async () => {
// const playerUrl = 'https://hongguoduanju.com/player/7572458140411628568/7572460055539223614';
// assert.deepEqual(parseHongguoPlayerUrl(playerUrl), {
// seriesId: '7572458140411628568',
// vid: '7572460055539223614',
// });
// const source = new HongguoSource();
// let requestedSeriesId = '';
// let detailRequests = 0;
// source.getEpisodes = async (seriesId) => {
// detailRequests++;
// requestedSeriesId = seriesId;
// return {
// episodes: [
// { index: 1, vid: '7572459982168280126', duration: 150 },
// { index: 2, vid: '7572460055539223614', duration: 119 },
// ],
// imageUrl: '',
// };
// };
// const segments = await source.getComments(playerUrl, 'hongguo', true);
// assert.equal(requestedSeriesId, '7572458140411628568');
// assert.equal(segments.duration, 119);
// assert.equal(segments.segmentList.length, 4);
// assert.equal(
// segments.segmentList[0].url,
// 'hongguo:v1:7572458140411628568:7572460055539223614:119#segment=0',
// );
// source.fetchCommentWindow = async (info) => {
// assert.equal(info.vid, '7572460055539223614');
// return {
// comments: [{ commentId: 'link-comment', offsetMs: 1500, text: '链接弹幕', diggCount: 2 }],
// nextStart: 119000,
// cursor: '',
// hasMore: false,
// };
// };
// const comments = await source.getComments(playerUrl, 'hongguo');
// assert.equal(detailRequests, 1);
// assert.equal(comments.length, 1);
// assert.equal(comments[0].m, '链接弹幕');
// });
// await t.test('GET comments by Hongguo player URL should use resolved vid', async () => {
// const seriesId = '7572458140411628568';
// const vid = '7572460055539223614';
// const playerUrl = `https://hongguoduanju.com/player/${seriesId}/${vid}`;
// const requestedUrls = [];
// const response = await withMockFetch(async (url) => {
// requestedUrls.push(String(url));
// if (String(url).includes('/novel/player/multi_video_detail/v1/')) {
// return mockJsonResponse({
// code: 0,
// data: {
// [seriesId]: {
// video_data: {
// video_list: [{ vid_index: 1, vid, duration: 119 }],
// },
// },
// },
// }, String(url));
// }
// if (String(url).includes(`/novel/commentapi/comment/list/${vid}/v1/`)) {
// return mockJsonResponse({
// code: 0,
// data: {
// data_list: [{
// comment: {
// comment_id: 'route-comment',
// common: { content: { text: '路由弹幕' } },
// expand: { offset_time: 1500 },
// stat: { digg_count: 3 },
// },
// }],
// common_list_info: { cursor: '', has_more: false },
// extra: { next_query_danmaku_list_time: 119000 },
// },
// }, String(url));
// }
// throw new Error(`Unexpected Hongguo request: ${url}`);
// }, () => getCommentByUrl(playerUrl, 'json', false));
// const body = await parseResponse(response);
// assert.equal(body.count, 1);
// assert.equal(body.comments[0].m, '路由弹幕');
// assert(requestedUrls.some((url) => url.includes('/novel/player/multi_video_detail/v1/')));
// assert(requestedUrls.some((url) => url.includes(`/novel/commentapi/comment/list/${vid}/v1/`)));
// });
// await t.test('GET other_server danmu', async () => {
// const res = await otherSource.getComments("https://www.bilibili.com/bangumi/play/ep1231564");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
await t.test('Hanjutv warmup should retry after failure and share concurrent promise', async () => {
const source = new HanjutvSource();
let attempts = 0;
let finishFirst;
source.buildMobileHeaders = async () => ({ uid: 'stable-uid', headers: {} });
source.warmupMobileIdentity = async () => {
attempts++;
if (attempts === 1) return new Promise(resolve => { finishFirst = resolve; });
return true;
};
// await t.test('Hanjutv warmup should retry after failure and share concurrent promise', async () => {
// const source = new HanjutvSource();
// let attempts = 0;
// let finishFirst;
// source.buildMobileHeaders = async () => ({ uid: 'stable-uid', headers: {} });
// source.warmupMobileIdentity = async () => {
// attempts++;
// if (attempts === 1) return new Promise(resolve => { finishFirst = resolve; });
// return true;
// };
const concurrent = [source.ensureMobileIdentityWarmed(), source.ensureMobileIdentityWarmed()];
await new Promise(resolve => setImmediate(resolve));
assert.equal(attempts, 1);
finishFirst(false);
await Promise.all(concurrent);
await source.ensureMobileIdentityWarmed();
await source.ensureMobileIdentityWarmed();
assert.equal(attempts, 2);
});
// const concurrent = [source.ensureMobileIdentityWarmed(), source.ensureMobileIdentityWarmed()];
// await new Promise(resolve => setImmediate(resolve));
// assert.equal(attempts, 1);
// finishFirst(false);
// await Promise.all(concurrent);
// await source.ensureMobileIdentityWarmed();
// await source.ensureMobileIdentityWarmed();
// assert.equal(attempts, 2);
// });
await t.test('Hanjutv details should stay fully parallel and preserve candidate order', async () => {
const source = new HanjutvSource();
const candidates = Array.from({ length: 6 }, (_, index) => ({ sid: `sid-${index}`, name: `顺序测试剧${index}` }));
const resolvers = new Map();
const started = [];
const previous = { animes: Globals.animes, episodeIds: Globals.episodeIds, episodeNum: Globals.episodeNum };
Globals.animes = [];
Globals.episodeIds = [];
Globals.episodeNum = 10001;
source.buildAnimePayload = anime => new Promise(resolve => {
started.push(anime.sid);
resolvers.set(anime.sid, resolve);
});
source.sortAndPushAnimesByYear = (items, target) => target.push(...items);
// await t.test('Hanjutv details should stay fully parallel and preserve candidate order', async () => {
// const source = new HanjutvSource();
// const candidates = Array.from({ length: 6 }, (_, index) => ({ sid: `sid-${index}`, name: `顺序测试剧${index}` }));
// const resolvers = new Map();
// const started = [];
// const previous = { animes: Globals.animes, episodeIds: Globals.episodeIds, episodeNum: Globals.episodeNum };
// Globals.animes = [];
// Globals.episodeIds = [];
// Globals.episodeNum = 10001;
// source.buildAnimePayload = anime => new Promise(resolve => {
// started.push(anime.sid);
// resolvers.set(anime.sid, resolve);
// });
// source.sortAndPushAnimesByYear = (items, target) => target.push(...items);
try {
const current = [];
const task = source.handleAnimes(candidates, '顺序测试剧', current, new Map());
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(started, candidates.map(item => item.sid));
[...candidates].reverse().forEach(anime => {
const index = candidates.indexOf(anime);
resolvers.get(anime.sid)({
summary: { animeId: 900000 + index, bangumiId: String(900000 + index), animeTitle: anime.name, type: '韩剧', typeDescription: '韩剧', imageUrl: '', startDate: '2025-01-01T00:00:00Z', episodeCount: 1, rating: 0, isFavorited: true, source: 'hanjutv' },
links: [{ name: '第1集', url: `hxq:${anime.sid}`, title: '【hanjutv】 第1集' }],
});
});
const expected = candidates.map(item => item.name);
assert.deepEqual((await task).map(item => item.animeTitle), expected);
assert.deepEqual(current.map(item => item.animeTitle), expected);
assert.deepEqual(Globals.animes.map(item => item.animeTitle), expected);
} finally {
Globals.animes = previous.animes;
Globals.episodeIds = previous.episodeIds;
Globals.episodeNum = previous.episodeNum;
}
});
// try {
// const current = [];
// const task = source.handleAnimes(candidates, '顺序测试剧', current, new Map());
// await new Promise(resolve => setImmediate(resolve));
// assert.deepEqual(started, candidates.map(item => item.sid));
// [...candidates].reverse().forEach(anime => {
// const index = candidates.indexOf(anime);
// resolvers.get(anime.sid)({
// summary: { animeId: 900000 + index, bangumiId: String(900000 + index), animeTitle: anime.name, type: '韩剧', typeDescription: '韩剧', imageUrl: '', startDate: '2025-01-01T00:00:00Z', episodeCount: 1, rating: 0, isFavorited: true, source: 'hanjutv' },
// links: [{ name: '第1集', url: `hxq:${anime.sid}`, title: '【hanjutv】 第1集' }],
// });
// });
// const expected = candidates.map(item => item.name);
// assert.deepEqual((await task).map(item => item.animeTitle), expected);
// assert.deepEqual(current.map(item => item.animeTitle), expected);
// assert.deepEqual(Globals.animes.map(item => item.animeTitle), expected);
// } finally {
// Globals.animes = previous.animes;
// Globals.episodeIds = previous.episodeIds;
// Globals.episodeNum = previous.episodeNum;
// }
// });
await t.test('Hanjutv should merge only exact titles and disambiguate duplicate names', async () => {
const source = new HanjutvSource();
const getMergedPairs = (keyword, s5Items, tvItems) => source
.mergeSearchCandidates(keyword, s5Items, tvItems)
.resultList
.filter(item => item._variant === 'merged')
.map(item => [item.sid, item.tvSid])
.sort((left, right) => left[0].localeCompare(right[0]));
// await t.test('Hanjutv should merge only exact titles and disambiguate duplicate names', async () => {
// const source = new HanjutvSource();
// const getMergedPairs = (keyword, s5Items, tvItems) => source
// .mergeSearchCandidates(keyword, s5Items, tvItems)
// .resultList
// .filter(item => item._variant === 'merged')
// .map(item => [item.sid, item.tvSid])
// .sort((left, right) => left[0].localeCompare(right[0]));
const taxi = source.mergeSearchCandidates('模范出租车', [
{ sid: 's3', name: '模范出租车3' },
{ sid: 's2', name: '模范出租车2' },
], [
{ sid: 't2', name: '模范出租车2' },
{ sid: 't3', name: '模范出租车3' },
]).resultList.filter(item => item._variant === 'merged');
assert.deepEqual(taxi.map(item => [item.name, item.tvSid]), [
['模范出租车3', 't3'],
['模范出租车2', 't2'],
]);
// const taxi = source.mergeSearchCandidates('模范出租车', [
// { sid: 's3', name: '模范出租车3' },
// { sid: 's2', name: '模范出租车2' },
// ], [
// { sid: 't2', name: '模范出租车2' },
// { sid: 't3', name: '模范出租车3' },
// ]).resultList.filter(item => item._variant === 'merged');
// assert.deepEqual(taxi.map(item => [item.name, item.tvSid]), [
// ['模范出租车3', 't3'],
// ['模范出租车2', 't2'],
// ]);
const duplicate = source.mergeSearchCandidates('配对游戏', [
{ sid: 's-new', name: '配对游戏', playMode: 100, publishTime: '2025-01-01', lastSerialNo: 6 },
{ sid: 's-old', name: '配对游戏', playMode: 101, publishTime: '2024-01-01', lastSerialNo: 63 },
], [
{ sid: 't-old', name: '配对游戏', playMode: 101, publishTime: '2024-01-01', lastSerialNo: 63 },
{ sid: 't-new', name: '配对游戏', playMode: 100, publishTime: '2025-01-01', lastSerialNo: 6 },
]).resultList.filter(item => item._variant === 'merged');
assert.deepEqual(duplicate.map(item => item.tvSid), ['t-new', 't-old']);
// const duplicate = source.mergeSearchCandidates('配对游戏', [
// { sid: 's-new', name: '配对游戏', playMode: 100, publishTime: '2025-01-01', lastSerialNo: 6 },
// { sid: 's-old', name: '配对游戏', playMode: 101, publishTime: '2024-01-01', lastSerialNo: 63 },
// ], [
// { sid: 't-old', name: '配对游戏', playMode: 101, publishTime: '2024-01-01', lastSerialNo: 63 },
// { sid: 't-new', name: '配对游戏', playMode: 100, publishTime: '2025-01-01', lastSerialNo: 6 },
// ]).resultList.filter(item => item._variant === 'merged');
// assert.deepEqual(duplicate.map(item => item.tvSid), ['t-new', 't-old']);
const partialS5 = [
{ sid: 's-unknown', name: '同名剧', playMode: 100, category: 1 },
{ sid: 's-2025', name: '同名剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
];
const datedTv = [
{ sid: 't-2025', name: '同名剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
];
for (const s5Order of [partialS5, [...partialS5].reverse()]) {
assert.deepEqual(getMergedPairs('同名剧', s5Order, datedTv), [['s-2025', 't-2025']]);
}
// const partialS5 = [
// { sid: 's-unknown', name: '同名剧', playMode: 100, category: 1 },
// { sid: 's-2025', name: '同名剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
// ];
// const datedTv = [
// { sid: 't-2025', name: '同名剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
// ];
// for (const s5Order of [partialS5, [...partialS5].reverse()]) {
// assert.deepEqual(getMergedPairs('同名剧', s5Order, datedTv), [['s-2025', 't-2025']]);
// }
const ambiguousS5 = [
{ sid: 's-a', name: '歧义剧', playMode: 100, category: 1 },
{ sid: 's-b', name: '歧义剧', playMode: 100, category: 1 },
];
const ambiguousTv = [{ sid: 't-only', name: '歧义剧', playMode: 100, category: 1 }];
assert.deepEqual(getMergedPairs('歧义剧', ambiguousS5, ambiguousTv), []);
assert.deepEqual(getMergedPairs('歧义剧', [...ambiguousS5].reverse(), ambiguousTv), []);
// const ambiguousS5 = [
// { sid: 's-a', name: '歧义剧', playMode: 100, category: 1 },
// { sid: 's-b', name: '歧义剧', playMode: 100, category: 1 },
// ];
// const ambiguousTv = [{ sid: 't-only', name: '歧义剧', playMode: 100, category: 1 }];
// assert.deepEqual(getMergedPairs('歧义剧', ambiguousS5, ambiguousTv), []);
// assert.deepEqual(getMergedPairs('歧义剧', [...ambiguousS5].reverse(), ambiguousTv), []);
assert.deepEqual(getMergedPairs('待播剧', [
{ sid: 's-upcoming', name: '待播剧', playMode: 100, category: 1 },
], [
{ sid: 't-upcoming', name: '待播剧', playMode: 100, category: 1 },
]), [['s-upcoming', 't-upcoming']]);
// assert.deepEqual(getMergedPairs('待播剧', [
// { sid: 's-upcoming', name: '待播剧', playMode: 100, category: 1 },
// ], [
// { sid: 't-upcoming', name: '待播剧', playMode: 100, category: 1 },
// ]), [['s-upcoming', 't-upcoming']]);
const eliminationS5 = [
{ sid: 's-known', name: '排除剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
{ sid: 's-left', name: '排除剧', playMode: 100, category: 1 },
];
const eliminationTv = [
{ sid: 't-left', name: '排除剧', playMode: 100, category: 1 },
{ sid: 't-known', name: '排除剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
];
const expectedEliminationPairs = [['s-known', 't-known'], ['s-left', 't-left']];
for (const s5Order of [eliminationS5, [...eliminationS5].reverse()]) {
for (const tvOrder of [eliminationTv, [...eliminationTv].reverse()]) {
assert.deepEqual(getMergedPairs('排除剧', s5Order, tvOrder), expectedEliminationPairs);
}
}
});
// const eliminationS5 = [
// { sid: 's-known', name: '排除剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
// { sid: 's-left', name: '排除剧', playMode: 100, category: 1 },
// ];
// const eliminationTv = [
// { sid: 't-left', name: '排除剧', playMode: 100, category: 1 },
// { sid: 't-known', name: '排除剧', playMode: 100, publishTime: '2025-01-01', category: 1 },
// ];
// const expectedEliminationPairs = [['s-known', 't-known'], ['s-left', 't-left']];
// for (const s5Order of [eliminationS5, [...eliminationS5].reverse()]) {
// for (const tvOrder of [eliminationTv, [...eliminationTv].reverse()]) {
// assert.deepEqual(getMergedPairs('排除剧', s5Order, tvOrder), expectedEliminationPairs);
// }
// }
// });
await t.test('Hanjutv should parse search-pair years without confusing seconds and milliseconds', () => {
const source = new HanjutvSource();
assert.equal(source.getSearchPairYear({ publishTime: 888768000000 }), 1998);
assert.equal(source.getSearchPairYear({ publishTime: '956678400000' }), 2000);
assert.equal(source.getSearchPairYear({ publishTime: 1735689600 }), 2025);
assert.equal(source.getSearchPairYear({ publishTime: '20250101' }), 2025);
assert.equal(source.getSearchPairYear({ releaseTime: '2025-07-11T00:00:00Z' }), 2025);
assert.equal(source.getSearchPairYear({ publishTime: 0, searchMemo: '1998·韩剧·敬请期待' }), 1998);
assert.equal(source.getSearchPairYear({ publishTime: 'not-a-date' }), null);
assert.equal(source.getSearchPairYear({ publishTime: 253402300800000 }), null);
// await t.test('Hanjutv should parse search-pair years without confusing seconds and milliseconds', () => {
// const source = new HanjutvSource();
// assert.equal(source.getSearchPairYear({ publishTime: 888768000000 }), 1998);
// assert.equal(source.getSearchPairYear({ publishTime: '956678400000' }), 2000);
// assert.equal(source.getSearchPairYear({ publishTime: 1735689600 }), 2025);
// assert.equal(source.getSearchPairYear({ publishTime: '20250101' }), 2025);
// assert.equal(source.getSearchPairYear({ releaseTime: '2025-07-11T00:00:00Z' }), 2025);
// assert.equal(source.getSearchPairYear({ publishTime: 0, searchMemo: '1998·韩剧·敬请期待' }), 1998);
// assert.equal(source.getSearchPairYear({ publishTime: 'not-a-date' }), null);
// assert.equal(source.getSearchPairYear({ publishTime: 253402300800000 }), null);
assert.equal(source.isMergeableSearchPair(
{ name: '千禧剧', publishTime: 974788882000 },
{ name: '千禧剧', publishTime: 974820151000 },
), true);
});
// assert.equal(source.isMergeableSearchPair(
// { name: '千禧剧', publishTime: 974788882000 },
// { name: '千禧剧', publishTime: 974820151000 },
// ), true);
// });
// await t.test('GET hanjutv search', async () => {
// const res = await hanjutvSource.search("犯罪现场Zero");
+364 -22
View File
@@ -4,20 +4,28 @@ import { log } from '../danmu_api/utils/log-util.js';
import { simplized } from '../danmu_api/utils/zh-util.js';
const wv = typeof widgetVersion !== 'undefined' ? widgetVersion : Globals.VERSION;
WidgetMetadata = {
id: "forward.auto.danmu2",
title: "自动链接弹幕v2",
id: globalThis.__FORWARD_WIDGET_DEBUG__ === true ? "forward.auto.danmu2.debug" : "forward.auto.danmu2",
title: globalThis.__FORWARD_WIDGET_DEBUG__ === true ? "自动链接弹幕v2 [DEBUG]" : "自动链接弹幕v2",
version: wv,
requiredVersion: "0.0.2",
description: "自动获取播放链接并从服务器获取弹幕【五折码:CHEAP.5;七折码:CHEAP】",
author: "huangxd",
site: "https://github.com/huangxd-/ForwardWidgets",
globalParams: [
...(globalThis.__FORWARD_WIDGET_DEBUG__ === true ? [{
name: "debugEndpoint",
title: "调试回传地址,例如 http://192.168.1.10:9321/87654321",
type: "input",
placeholders: [{
title: "局域网 danmu_api 地址",
value: "http://192.168.1.10:9321/87654321",
}],
}] : []),
// 源配置
{
name: "sourceOrder",
title: "源排序配置,默认'douban,360,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
title: "源排序配置,默认'douban,360,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
type: "input",
placeholders: [
{
@@ -163,7 +171,7 @@ WidgetMetadata = {
// 匹配配置
{
name: "platformOrder",
title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi, 'xigua', 'maiduidui', 'aiyifan', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi, 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
type: "input",
placeholders: [
{
@@ -427,6 +435,22 @@ WidgetMetadata = {
},
],
},
{
name: "hongguoMergeAllEpisodes",
title: "红果短剧合并全集弹幕,默认关闭",
type: "enumeration",
value: "false",
enumOptions: [
{
title: "关闭",
value: "false",
},
{
title: "开启",
value: "true",
},
],
},
// 系统配置
{
@@ -511,11 +535,297 @@ if (typeof window !== 'undefined') {
// 初始化全局配置
let globals;
const FORWARD_TRACE_MAX_LOGS = 80;
const FORWARD_TRACE_MAX_ARRAY = 5;
const FORWARD_TRACE_MAX_STRING = 500;
const FORWARD_TRACE_BATCH_SIZE = 20;
let forwardDebugContext = null;
function redactForwardTraceValue(value, key = '', depth = 0, seen = []) {
if (/(cookie|token|api.?key|authorization|password|secret|debugEndpoint)/i.test(key)) {
return value ? '[REDACTED]' : value;
}
if (typeof value === 'string') {
return value.length > FORWARD_TRACE_MAX_STRING
? `${value.slice(0, FORWARD_TRACE_MAX_STRING)}...[truncated]`
: value;
}
if (value === null || value === undefined || typeof value !== 'object') return value;
if (depth >= 4) return '[max-depth]';
if (seen.includes(value)) return '[circular]';
const nextSeen = [...seen, value];
if (Array.isArray(value)) {
const items = value.slice(0, FORWARD_TRACE_MAX_ARRAY)
.map((item) => redactForwardTraceValue(item, key, depth + 1, nextSeen));
if (value.length > FORWARD_TRACE_MAX_ARRAY) {
items.push(`[${value.length - FORWARD_TRACE_MAX_ARRAY} more items]`);
}
return items;
}
const output = {};
Object.keys(value).slice(0, 50).forEach((childKey) => {
output[childKey] = redactForwardTraceValue(value[childKey], childKey, depth + 1, nextSeen);
});
return output;
}
function summarizeForwardResult(handlerName, result) {
if (handlerName === 'searchDanmu') {
const animes = Array.isArray(result?.animes) ? result.animes : [];
return { animeCount: animes.length, animes: redactForwardTraceValue(animes.slice(0, 5)) };
}
if (handlerName === 'getDetailById') {
const episodes = Array.isArray(result) ? result : [];
return { episodeCount: episodes.length, episodes: redactForwardTraceValue(episodes.slice(0, 5)) };
}
if (handlerName === 'getCommentsById') {
const segments = Array.isArray(result) ? result : [];
return {
segmentCount: segments.length,
firstSegment: redactForwardTraceValue(segments[0] || null),
lastSegment: redactForwardTraceValue(segments[segments.length - 1] || null),
};
}
if (handlerName === 'getDanmuWithSegmentTime') {
const comments = Array.isArray(result?.comments) ? result.comments : [];
return {
success: result?.success,
errorCode: result?.errorCode,
errorMessage: result?.errorMessage,
count: Number.isFinite(Number(result?.count)) ? Number(result.count) : comments.length,
comments: redactForwardTraceValue(comments.slice(0, 3)),
};
}
return redactForwardTraceValue(result);
}
function buildForwardTraceUrl(debugEndpoint) {
const endpoint = String(debugEndpoint || '').trim().replace(/\/+$/, '');
if (!endpoint) return '';
return endpoint.endsWith('/api/debug/forward-trace')
? endpoint
: `${endpoint}/api/debug/forward-trace`;
}
function redactForwardRequestUrl(url) {
return String(url || '').replace(
/([?&](?:token|access_token|api_?key|key|signature|x-signature|authorization)=)[^&]*/gi,
'$1[REDACTED]'
);
}
function enqueueForwardRuntimeLog(level, message, details = {}) {
if (!forwardDebugContext) return;
forwardDebugContext.runtimeLogs.push({
timestamp: new Date().toISOString(),
level,
message,
...redactForwardTraceValue(details),
});
flushForwardRuntimeLogs().catch(() => {});
}
function installForwardConsoleTrace() {
['log', 'info', 'warn', 'error'].forEach((method) => {
const original = console[method];
if (typeof original !== 'function' || original.__forwardTraceWrapped) return;
const wrapped = (...args) => {
const message = args.map((arg) => {
if (typeof arg === 'string') return arg;
try { return JSON.stringify(redactForwardTraceValue(arg)); } catch (_) { return String(arg); }
}).join(' ');
enqueueForwardRuntimeLog(method === 'log' ? 'info' : method, message, { kind: 'console' });
return original.apply(console, args);
};
wrapped.__forwardTraceWrapped = true;
console[method] = wrapped;
});
}
function collectPendingForwardLogs(context) {
const logs = [];
const centralLogKeys = new Set();
if (globals && Array.isArray(globals.logBuffer)) {
globals.logBuffer.forEach((entry) => {
if (context.seenLogs.includes(entry)) return;
const timestamp = Date.parse(entry?.timestamp || '');
if (!Number.isFinite(timestamp) || timestamp < context.startedAt - 1000) return;
context.seenLogs.push(entry);
const redacted = redactForwardTraceValue(entry);
centralLogKeys.add(`${redacted.level}|${redacted.message}`);
logs.push(redacted);
});
}
context.runtimeLogs.splice(0).forEach((entry) => {
const redacted = redactForwardTraceValue(entry);
if (redacted.kind === 'console' && centralLogKeys.has(`${redacted.level}|${redacted.message}`)) return;
logs.push(redacted);
});
return logs.slice(-FORWARD_TRACE_MAX_LOGS);
}
async function flushForwardRuntimeLogs() {
const context = forwardDebugContext;
if (!context || !context.debugEndpoint) return;
if (context.flushing) {
context.flushAgain = true;
return context.flushPromise;
}
context.flushing = true;
context.flushPromise = (async () => {
try {
do {
context.flushAgain = false;
const logs = collectPendingForwardLogs(context);
for (let index = 0; index < logs.length; index += FORWARD_TRACE_BATCH_SIZE) {
const batch = logs.slice(index, index + FORWARD_TRACE_BATCH_SIZE);
await sendForwardTrace(context.debugEndpoint, {
eventType: 'logBatch',
widgetId: WidgetMetadata.id,
widgetVersion: WidgetMetadata.version,
handler: context.handler,
status: batch.some((entry) => entry.level === 'error') ? 'error' : 'info',
timestamp: new Date().toISOString(),
logs: batch,
});
}
} while (context.flushAgain);
} finally {
context.flushing = false;
}
})();
return context.flushPromise;
}
async function startForwardRealtimeTrace(handlerName, params, startedAt) {
forwardDebugContext = {
handler: handlerName,
debugEndpoint: params?.debugEndpoint,
startedAt,
seenLogs: [],
runtimeLogs: [],
flushing: false,
flushAgain: false,
flushPromise: null,
};
await sendForwardTrace(params?.debugEndpoint, {
eventType: 'handlerStart',
widgetId: WidgetMetadata.id,
widgetVersion: WidgetMetadata.version,
handler: handlerName,
status: 'running',
timestamp: new Date(startedAt).toISOString(),
params: redactForwardTraceValue(params || {}),
});
}
async function stopForwardRealtimeTrace() {
const context = forwardDebugContext;
if (!context) return;
await flushForwardRuntimeLogs();
if (forwardDebugContext === context) forwardDebugContext = null;
}
async function forwardDebugHttpRequest(method, url, body, options = {}) {
const startedAt = Date.now();
const safeUrl = redactForwardRequestUrl(url);
enqueueForwardRuntimeLog('info', `[HTTP] ${method} ${safeUrl}`, { kind: 'httpStart', method, url: safeUrl });
try {
const response = method === 'GET'
? await Widget.http.get(url, options)
: await Widget.http.post(url, body, options);
enqueueForwardRuntimeLog(
'info',
`[HTTP] ${method} ${response?.status || 200} ${Date.now() - startedAt}ms ${safeUrl}`,
{ kind: 'httpComplete', method, url: safeUrl, status: response?.status || 200, durationMs: Date.now() - startedAt }
);
return response;
} catch (error) {
enqueueForwardRuntimeLog(
'error',
`[HTTP] ${method} ERROR ${Date.now() - startedAt}ms ${safeUrl}: ${error?.message || error}`,
{ kind: 'httpError', method, url: safeUrl, durationMs: Date.now() - startedAt, error: error?.message || String(error) }
);
throw error;
}
}
async function forwardDebugHttpGet(url, options = {}) {
return forwardDebugHttpRequest('GET', url, undefined, options);
}
async function forwardDebugHttpPost(url, body, options = {}) {
return forwardDebugHttpRequest('POST', url, body, options);
}
if (globalThis.__FORWARD_WIDGET_DEBUG__ === true) {
installForwardConsoleTrace();
globalThis.__forwardDebugHttpGet = forwardDebugHttpGet;
globalThis.__forwardDebugHttpPost = forwardDebugHttpPost;
}
async function sendForwardTrace(debugEndpoint, payload) {
const traceUrl = buildForwardTraceUrl(debugEndpoint);
if (!traceUrl) return;
try {
await Widget.http.post(traceUrl, JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
timeout: 3000,
});
} catch (error) {
console.warn('[ForwardDebug] trace upload failed:', error?.message || error);
}
}
async function runWithForwardTrace(handlerName, params, operation) {
if (globalThis.__FORWARD_WIDGET_DEBUG__ !== true) return operation();
const startedAt = Date.now();
await startForwardRealtimeTrace(handlerName, params, startedAt);
try {
const result = await operation();
await stopForwardRealtimeTrace();
await sendForwardTrace(params?.debugEndpoint, {
eventType: 'handlerComplete',
widgetId: WidgetMetadata.id,
widgetVersion: WidgetMetadata.version,
handler: handlerName,
status: 'success',
timestamp: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
params: redactForwardTraceValue(params || {}),
result: summarizeForwardResult(handlerName, result),
});
return result;
} catch (error) {
await stopForwardRealtimeTrace();
await sendForwardTrace(params?.debugEndpoint, {
eventType: 'handlerComplete',
widgetId: WidgetMetadata.id,
widgetVersion: WidgetMetadata.version,
handler: handlerName,
status: 'error',
timestamp: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
params: redactForwardTraceValue(params || {}),
error: redactForwardTraceValue({
name: error?.name,
message: error?.message || String(error),
stack: error?.stack,
}),
});
throw error;
}
}
async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch) {
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes) {
// 将传入的参数设置到环境变量中,以便Globals可以访问它们
const env = {};
if (globalThis.__FORWARD_WIDGET_DEBUG__ === true) env.LOG_LEVEL = 'info';
if (sourceOrder !== undefined) env.SOURCE_ORDER = sourceOrder;
if (otherServer !== undefined) env.OTHER_SERVER = otherServer;
@@ -541,6 +851,7 @@ async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServ
if (convertColor !== undefined) env.CONVERT_COLOR = convertColor;
if (colorPool !== undefined) env.COLOR_POOL = colorPool;
if (likeSwitch !== undefined) env.LIKE_SWITCH = likeSwitch;
if (hongguoMergeAllEpisodes !== undefined) env.HONGGUO_MERGE_ALL_EPISODES = hongguoMergeAllEpisodes;
if (proxyUrl !== undefined) env.PROXY_URL = proxyUrl;
if (tmdbApiKey !== undefined) env.TMDB_API_KEY = tmdbApiKey;
@@ -606,14 +917,14 @@ async function removeCaches() {
const PREFIX_URL = "http://localhost:9321"
async function searchDanmu(params) {
async function searchDanmuCore(params) {
const { tmdbId, type, title, season, link, videoUrl, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch } = params;
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch);
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes);
let simplifiedTitle = title
// 如果启用了搜索关键字繁转简,则进行转换
@@ -686,14 +997,14 @@ async function searchDanmu(params) {
};
}
async function getDetailById(params) {
async function getDetailByIdCore(params) {
const { animeId, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch } = params;
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch);
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes);
const response = await getBangumi(`${PREFIX_URL}/api/v2/bangumi/${animeId}`);
const resJson = await response.json();
@@ -705,14 +1016,14 @@ async function getDetailById(params) {
return resJson.bangumi.episodes;
}
async function getCommentsById(params) {
async function getCommentsByIdCore(params) {
const { commentId, link, videoUrl, season, episode, tmdbId, type, title, segmentTime, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch } = params;
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch);
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes);
if (commentId) {
const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`;
@@ -726,10 +1037,8 @@ async function getCommentsById(params) {
log("info", "[Forward] lastCommentId:", lastCommentId);
log("info", "[Forward] segmentList:", segmentList);
if (lastCommentId === commentId && segmentList) {
return await getDanmuWithSegmentTime({ segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch })
if (lastCommentId === commentId && Array.isArray(segmentList)) {
return segmentList;
} else {
Widget.storage.remove(storeKey);
Widget.storage.remove(commentIdKey);
@@ -750,14 +1059,14 @@ async function getCommentsById(params) {
return null;
}
async function getDanmuWithSegmentTime(params) {
async function getDanmuWithSegmentTimeCore(params) {
const { segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch } = params;
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, doubanCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch);
danmuLimit, danmuSimplifiedTraditional, danmuOffset, convertTopBottomToScroll, convertColor, colorPool, proxyUrl, tmdbApiKey, likeSwitch, hongguoMergeAllEpisodes);
const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`;
const segmentList = Widget.storage.get(storeKey);
@@ -769,6 +1078,15 @@ async function getDanmuWithSegmentTime(params) {
return time >= start && time < end;
});
log("info", "[Forward] segment:", segment);
if (!segment) {
const starts = segmentList.map((item) => Number(item.segment_start)).filter(Number.isFinite);
const ends = segmentList.map((item) => Number(item.segment_end)).filter(Number.isFinite);
const rangeStart = starts.length ? Math.min(...starts) : null;
const rangeEnd = ends.length ? Math.max(...ends) : null;
const errorMessage = `No segment covers ${segmentTime}s; available range: ${rangeStart}-${rangeEnd}s`;
log("warn", `[Forward] ${errorMessage}`);
return { errorCode: 404, success: false, errorMessage, count: 0, comments: [] };
}
const response = await getSegmentComment(segment);
const resJson = await response.json();
@@ -779,5 +1097,29 @@ async function getDanmuWithSegmentTime(params) {
return null;
}
async function searchDanmu(params = {}) {
return globalThis.__FORWARD_WIDGET_DEBUG__ === true
? runWithForwardTrace('searchDanmu', params, () => searchDanmuCore(params))
: searchDanmuCore(params);
}
async function getDetailById(params = {}) {
return globalThis.__FORWARD_WIDGET_DEBUG__ === true
? runWithForwardTrace('getDetailById', params, () => getDetailByIdCore(params))
: getDetailByIdCore(params);
}
async function getCommentsById(params = {}) {
return globalThis.__FORWARD_WIDGET_DEBUG__ === true
? runWithForwardTrace('getCommentsById', params, () => getCommentsByIdCore(params))
: getCommentsByIdCore(params);
}
async function getDanmuWithSegmentTime(params = {}) {
return globalThis.__FORWARD_WIDGET_DEBUG__ === true
? runWithForwardTrace('getDanmuWithSegmentTime', params, () => getDanmuWithSegmentTimeCore(params))
: getDanmuWithSegmentTimeCore(params);
}
// 导出函数以供ForwardWidgets调用
export { searchDanmu, getDetailById, getCommentsById, getDanmuWithSegmentTime };
+53 -12
View File
@@ -154,17 +154,17 @@ global.Widget = {
_store: {}, // 内部存储对象
get: (key) => {
console.log(`[iOS模拟] storage.get: ${key}`);
// console.log(`[iOS模拟] storage.get: ${key}`);
return Widget.storage._store[key] ?? null;
},
set: (key, value) => {
console.log(`[iOS模拟] storage.set: ${key} = ${JSON.stringify(value)}`);
// console.log(`[iOS模拟] storage.set: ${key} = ${JSON.stringify(value)}`);
Widget.storage._store[key] = value;
},
remove: (key) => {
console.log(`[iOS模拟] storage.remove: ${key}`);
// console.log(`[iOS模拟] storage.remove: ${key}`);
delete Widget.storage._store[key];
},
@@ -201,6 +201,7 @@ async function runTest() {
await testNewFlow();
} catch (error) {
console.error('Failed to load module:', error);
process.exitCode = 1;
}
}
@@ -213,10 +214,10 @@ async function testNewFlow() {
type: 'tv',
tmdbId: '242762',
season: 1,
episode: 21,
episode: 1,
airDate: '2025-07-18',
episodeName: '第2期上:首次大约会!温柔医生为爱冲锋',
sourceOrder: 'douban',
sourceOrder: 'hongguo',
otherServer: 'https://api.danmu.icu',
customSourceApiUrl: '',
vodServers: '金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top',
@@ -241,6 +242,7 @@ async function testNewFlow() {
likeSwitch: 'true',
proxyUrl: '',
tmdbApiKey: '',
hongguoMergeAllEpisodes: 'false',
};
// 测试自动获取分片弹幕
@@ -259,7 +261,7 @@ async function testNewFlow() {
// title: "https://www.bilibili.com/bangumi/play/ep1231564",
// title: "https://www.bilibili.com/video/av170001?p=2",
// title: "https://www.bilibili.com/video/BV17x411w7KC?p=3",
title: '寻秦记',
title: '装穷回家',
...commonParams,
});
if (verbose) {
@@ -268,6 +270,10 @@ async function testNewFlow() {
console.log(`✅ 搜索结果: 找到 ${searchRes.animes ? searchRes.animes.length : 0} 个动漫`);
}
if (!searchRes.animes || searchRes.animes.length === 0) {
throw new Error('搜索结果为空,未进入弹幕获取链路');
}
if (searchRes.animes && searchRes.animes.length > 0) {
const anime = searchRes.animes[0];
console.log(`📺 找到动漫: ${anime.animeTitle} (ID: ${anime.animeId})`);
@@ -278,20 +284,50 @@ async function testNewFlow() {
animeId: anime.animeId,
...commonParams,
});
const commentId = bangumi[0].episodeId;
const sgementList = await getCommentsById({
const targetEpisode = Number(commonParams.episode);
const selectedEpisode = bangumi.find((item) => {
const explicitNumber = Number(item.episodeNumber);
if (Number.isFinite(explicitNumber) && explicitNumber === targetEpisode) return true;
const titleMatch = String(item.episodeTitle || '').match(/第\s*(\d+)\s*[集期]/);
return titleMatch && Number(titleMatch[1]) === targetEpisode;
}) || bangumi[targetEpisode - 1];
if (!selectedEpisode) {
throw new Error(`未找到第 ${targetEpisode} 集,可用剧集数: ${bangumi.length}`);
}
console.log(`🎯 选择剧集: ${selectedEpisode.episodeTitle} (ID: ${selectedEpisode.episodeId})`);
const commentId = selectedEpisode.episodeId;
const segmentList = await getCommentsById({
commentId: commentId,
...commonParams,
});
if (verbose) {
console.log('✅ 弹幕分片:', JSON.stringify(sgementList, null, 2));
console.log('✅ 弹幕分片:', JSON.stringify(segmentList, null, 2));
} else {
console.log(`✅ 弹幕分片: 获取到 ${sgementList ? sgementList.length : 0} 个分片`);
console.log(`✅ 弹幕分片: 获取到 ${segmentList ? segmentList.length : 0} 个分片`);
}
if (sgementList.length > 0) {
if (!Array.isArray(segmentList) || segmentList.length === 0) {
throw new Error(`第 ${targetEpisode} 集没有可用弹幕分片`);
}
if (segmentList.length > 0) {
const preferredSegmentTime = 450;
const selectedSegment = segmentList.find((item) => (
preferredSegmentTime >= Number(item.segment_start)
&& preferredSegmentTime < Number(item.segment_end)
)) || segmentList[0];
const segmentStart = Number(selectedSegment.segment_start);
const segmentEnd = Number(selectedSegment.segment_end);
const segmentTime = preferredSegmentTime >= segmentStart && preferredSegmentTime < segmentEnd
? preferredSegmentTime
: segmentStart + Math.max(0, segmentEnd - segmentStart) / 2;
console.log(`🎯 选择弹幕分片: ${segmentStart}-${segmentEnd}s,请求时间点 ${segmentTime}s`);
const comments = await getDanmuWithSegmentTime({
segmentTime: 450,
segmentTime,
...commonParams,
});
if (verbose) {
@@ -299,6 +335,10 @@ async function testNewFlow() {
} else {
console.log(`✅ 弹幕评论: 获取到 ${comments && comments.comments ? comments.comments.length : 0} 条弹幕`);
}
if (!comments || !Array.isArray(comments.comments) || comments.comments.length === 0) {
throw new Error(`分片 ${segmentStart}-${segmentEnd}s 未获取到弹幕`);
}
}
}
console.log('\n' + '='.repeat(50) + '\n');
@@ -307,6 +347,7 @@ async function testNewFlow() {
} catch (error) {
console.error('❌ 测试失败:', error);
process.exitCode = 1;
}
}
+2 -1
View File
@@ -6,7 +6,8 @@
"main": "danmu_api/server.js",
"scripts": {
"start": "node danmu_api/server.js",
"build-forward-widget": "node build-forward-widget.js"
"build-forward-widget": "node build-forward-widget.js",
"build-forward-widget:debug": "node build-forward-widget.js --debug"
},
"author": "",
"license": "ISC",