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