Files
danmu_api/forward/forward-widget.test.js
T
huangxd- c4d2951eb5 新增红果短剧源 (#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 真机调试步骤和使用说明
2026-07-26 20:56:32 +08:00

356 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import zlib from 'zlib';
// 模拟 iOS JavaScriptBridge 的 Widget 对象 - 模拟 iOS 环境
global.Widget = {
http: {
get: async (url, options) => {
console.log(`[iOS模拟] HTTP GET: ${url}`);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
...options.headers,
// 'User-Agent': 'ForwardWidgets/1.0.0'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
let data;
if (options.base64Data) {
console.log("base64模式");
// 先拿二进制
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// 转换为 Base64
let binary = '';
const chunkSize = 0x8000; // 分块防止大文件卡死
for (let i = 0; i < uint8Array.length; i += chunkSize) {
let chunk = uint8Array.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
data = btoa(binary); // 得到 base64 字符串
} else if (options.zlibMode) {
console.log("zlib模式")
data = await response.arrayBuffer();
// 使用 zlib 解压数据
const buffer = Buffer.from(data); // 将二进制数据转成 Buffer(Node.js 中使用)
let decompressedData;
try {
decompressedData = zlib.inflateSync(buffer); // 使用同步的 inflate 解压数据
} catch (e) {
console.error("[iOS模拟] 解压缩失败", e);
throw e;
}
// 将解压的数据转回字符串
const decodedData = decompressedData.toString('utf-8');
data = decodedData; // 更新解压后的数据
} else {
data = await response.text();
}
let parsedData;
try {
parsedData = JSON.parse(data); // 尝试将文本解析为 JSON
} catch (e) {
parsedData = data; // 如果解析失败,保留原始文本
}
if (verbose) {
console.log(`[iOS模拟] API响应:`, JSON.stringify(parsedData, null, 2));
}
// 获取所有 headers,但特别处理 set-cookie
const headers = {};
let setCookieValues = [];
// 遍历 headers 条目
for (const [key, value] of response.headers.entries()) {
if (key.toLowerCase() === 'set-cookie') {
setCookieValues.push(value);
} else {
headers[key] = value;
}
}
// 如果存在 set-cookie 头,将其合并为分号分隔的字符串
if (setCookieValues.length > 0) {
headers['set-cookie'] = setCookieValues.join(';');
}
// 模拟 iOS 环境:返回 { data: ... } 结构
return {
data: parsedData,
status: response.status,
headers: headers
};
} catch (error) {
console.error(`[iOS模拟] 请求失败:`, error.message);
throw error;
}
},
post: async (url, body, options = {}) => {
console.log(`[iOS模拟] HTTP POST: ${url}`);
// 处理请求头、body 和其他参数
const { headers = {}, params, allow_redirects = true } = options;
const fetchOptions = {
method: 'POST',
headers: {
...headers,
// 'Content-Type': 'application/json', // 默认使用 JSON 格式
// 'User-Agent': 'ForwardWidgets/1.0.0'
},
body: body
};
if (!allow_redirects) {
fetchOptions.redirect = 'manual'; // 禁止重定向
}
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.text();
let parsedData;
try {
parsedData = JSON.parse(data); // 尝试将文本解析为 JSON
} catch (e) {
parsedData = data; // 如果解析失败,保留原始文本
}
if (verbose) {
console.log(`[iOS模拟] API响应:`, JSON.stringify(parsedData, null, 2));
}
// 模拟 iOS 环境:返回 { data: ... } 结构
return {
data: parsedData,
status: response.status,
headers: Object.fromEntries(response.headers.entries())
};
} catch (error) {
console.error(`[iOS模拟] 请求失败:`, error.message);
throw error;
}
}
},
// 新增 storage 模拟
storage: {
_store: {}, // 内部存储对象
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)}`);
Widget.storage._store[key] = value;
},
remove: (key) => {
// console.log(`[iOS模拟] storage.remove: ${key}`);
delete Widget.storage._store[key];
},
clear: () => {
console.log(`[iOS模拟] storage.clear`);
Widget.storage._store = {};
},
},
};
// 模拟 WidgetMetadata
global.WidgetMetadata = {
id: "forward.danmu",
title: "弹幕",
version: "1.0.0",
description: "获取弹幕数据"
};
// 配置变量
const verbose = false; // 设置为 true 时打印详细结果
// 加载 forward-widget.js 模块
async function runTest() {
try {
const module = await import('./forward-widget.js');
// const module = await import('../dist/logvar-danmu.js');
// 将模块导出的函数添加到全局作用域,以便测试函数可以访问它们
global.searchDanmu = module.searchDanmu;
global.getDetailById = module.getDetailById;
global.getCommentsById = module.getCommentsById;
global.getDanmuWithSegmentTime = module.getDanmuWithSegmentTime;
// 运行测试
await testNewFlow();
} catch (error) {
console.error('Failed to load module:', error);
process.exitCode = 1;
}
}
async function testNewFlow() {
console.log('=== 测试新的弹幕获取链路 ===\n');
console.log(`[配置] 详细输出: ${verbose ? '开启' : '关闭'}\n`);
try {
const commonParams = {
type: 'tv',
tmdbId: '242762',
season: 1,
episode: 1,
airDate: '2025-07-18',
episodeName: '第2期上:首次大约会!温柔医生为爱冲锋',
sourceOrder: 'hongguo',
otherServer: 'https://api.danmu.icu',
customSourceApiUrl: '',
vodServers: '金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top',
vodReturnMode: 'fastest',
vodRequestTimeout: 10000,
bilibiliCookie: '',
doubanCookie: '',
platformOrder: [],
episodeTitleFilter: '',
enableAnimeEpisodeFilter: 'false',
strictTitleMatch: 'false',
animeTitleFilter: '',
animeTitleSimplified: 'false',
blockedWords: '',
groupMinute: 1,
danmuLimit: 0,
danmuSimplifiedTraditional: 'false',
danmuOffset: '',
convertTopBottomToScroll: 'false',
convertColor: 'default',
colorPool: '16777215,16744319,16752762,16774799,9498256,8388564,8900346,14204888,16758465',
likeSwitch: 'true',
proxyUrl: '',
tmdbApiKey: '',
hongguoMergeAllEpisodes: 'false',
};
// 测试自动获取分片弹幕
console.log('🔍 测试自动获取分片弹幕');
const searchRes = await searchDanmu({
// title: "https://m.v.qq.com/x/m/play?cid=53q0eh78q97e4d1&vid=x00174aq5no&ptag=hippySearch&pageType=long",
// title: "https://v.qq.com/x/cover/53q0eh78q97e4d1/x00174aq5no.html",
// title: "https://v.qq.com/x/cover/mzc002009y0nzq8/f4101bay23t.html",
// title: "https://m.iqiyi.com/v_1ftv9n1m3bg.html",
// title: "https://www.iqiyi.com/v_1ftv9n1m3bg.html",
// title: "https://m.youku.com/alipay_video/id_cbff0b0703e54d659628.html?spm=a2hww.12518357.drawer4.2",
// title: "https://v.youku.com/v_show/id_XNjQ3ODMyNjU3Mg==.html",
// title: "https://m.mgtv.com/b/771610/23300622.html?fpa=0&fpos=0",
// title: "https://www.mgtv.com/b/771610/23300622.html",
// title: "https://m.bilibili.com/bangumi/play/ep1231564",
// 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: '装穷回家',
...commonParams,
});
if (verbose) {
console.log('✅ 搜索结果:', JSON.stringify(searchRes, null, 2));
} else {
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})`);
// 获取弹幕评论
console.log('💬 获取弹幕评论...');
const bangumi = await getDetailById({
animeId: anime.animeId,
...commonParams,
});
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(segmentList, null, 2));
} else {
console.log(`✅ 弹幕分片: 获取到 ${segmentList ? segmentList.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,
...commonParams,
});
if (verbose) {
console.log('✅ 弹幕评论:', JSON.stringify(comments, null, 2));
} 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');
console.log('\n=== 所有测试完成 ===');
} catch (error) {
console.error('❌ 测试失败:', error);
process.exitCode = 1;
}
}
// 启动测试
runTest();