新增红果短剧源 (#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:
1 parent
1bb163e77d
commit
c4d2951eb5
17 files changed
+2169
-273
No files matched your search
@@ -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
@@ -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
@@ -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自动匹配时优选上次的选择
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]。"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user