diff --git a/.gitignore b/.gitignore index 03e419d..6f5884e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ package-lock.json node_modules .env .cache +dist diff --git a/README.md b/README.md index d5d732a..1d173e5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ LogVar 弹幕 API 服务器 - `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。 - `GET /api/v2/comment/:commentId?format=json`:获取指定弹幕评论,支持返回相关评论和字符转换。 - `GET /api/v2/comment?url=${videoUrl}&format=json`:通过视频URL直接获取弹幕(兼容第三方弹幕服务器格式)。 + - `POST /api/v2/segmentcomment?format=json`:通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据。 - `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。 - **弹幕格式输出**:支持 JSON 和 XML 两种格式输出,通过以下方式配置: - 环境变量:`DANMU_OUTPUT_FORMAT=json|xml`(默认:json) @@ -75,6 +76,9 @@ LogVar 弹幕 API 服务器 - 转换弹幕颜色为白色或彩色(`CONVERT_COLOR`) - 解决部分播放器不支持顶部/底部弹幕和彩色弹幕的问题 - **弹幕限制数量**:支持通过环境变量配置等间隔采样弹幕数量。 +- **弹幕分片请求**: + - `/api/v2/comment` 请求时支持定义 `segmentflag=true` 参数,用于请求弹幕分片列表 + - `/api/v2/segmentcomment` 通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据 - **UI界面-后台配置管理系统**:支持通过UI执行一些操作(详细见 [UI 系统使用说明](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md) ),包括: - 配置预览 - 日志查看 @@ -122,6 +126,10 @@ LogVar 弹幕 API 服务器 node ./danmu_api/server.js # 测试 node --test ./danmu_api/worker.test.js + # 构建forward弹幕插件 + node build-forward-widget.js + # 测试forward弹幕插件 + node danmu_api/forward-widget.test.js ``` 5. **测试 API**: @@ -133,6 +141,7 @@ LogVar 弹幕 API 服务器 - `GET http://{ip}:9321/87654321/api/v2/bangumi/1` - `GET http://{ip}:9321/87654321/api/v2/comment/1?format=json` - `GET http://{ip}:9321/87654321/api/v2/comment?url=https://v.qq.com/x/cover/xxx.html&format=json` + - `POST http://{ip}:9321/87654321/api/v2/segmentcomment?format=json` (请求体包含segment类JSON数据,示例 `{"type": "qq","segment_start":0,"segment_end":30000,"url":"https://dm.video.qq.com/barrage/segment/j0032ubhl9s/t/v1/0/30000"}` ) - `GET http://{ip}:9321/87654321/api/logs` > 注意:TOKEN为默认87654321的情况下,可不带{TOKEN}请求,如`http://{ip}:9321/api/v2/search/anime?keyword=生万物` @@ -294,7 +303,7 @@ LogVar 弹幕 API 服务器 > cf部署可能不稳定,推荐用vercel/netlify部署。 ## API食用指南 -支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna/danmaku-anywhere/omnibox 等支持弹幕API的播放器。 +支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna/danmaku-anywhere/omnibox/ChaiChaiEmbyTV 等支持弹幕API的播放器。 配合 dd-danmaku 扩展新增对 Emby Web 端弹幕的支持,具体使用方法参考 [PR #98](https://github.com/huangxd-/danmu_api/pull/98) 。 diff --git a/build-forward-widget.js b/build-forward-widget.js new file mode 100644 index 0000000..801f170 --- /dev/null +++ b/build-forward-widget.js @@ -0,0 +1,98 @@ +const esbuild = require('esbuild'); +const fs = require('fs'); + +// 动态获取版本号 +const { Globals } = require('./danmu_api/configs/globals.js'); + +// 定义要排除的UI相关模块 +const uiModules = [ + './ui/template.js', + '../ui/template.js', + '../../ui/template.js', + './ui/css/base.css.js', + './ui/css/components.css.js', + './ui/css/forms.css.js', + './ui/css/responsive.css.js', + './ui/js/main.js', + './ui/js/preview.js', + './ui/js/logview.js', + './ui/js/apitest.js', + './ui/js/pushdanmu.js', + './ui/js/systemsettings.js', + 'danmu_api/ui/template.js', + 'danmu_api/ui/css/base.css.js', + 'danmu_api/ui/css/components.css.js', + 'danmu_api/ui/css/forms.css.js', + 'danmu_api/ui/css/responsive.css.js', + 'danmu_api/ui/js/main.js', + 'danmu_api/ui/js/preview.js', + 'danmu_api/ui/js/logview.js', + 'danmu_api/ui/js/apitest.js', + 'danmu_api/ui/js/pushdanmu.js', + 'danmu_api/ui/js/systemsettings.js' +]; + +let customPolyfillContent = fs.readFileSync('forward/custom-polyfill.js', 'utf8'); + +(async () => { + try { + await esbuild.build({ + entryPoints: ['forward/forward-widget.js'], // 新的入口文件 + bundle: true, + minify: false, // 暂时关闭压缩以便调试 + sourcemap: false, + platform: 'neutral', // 改为neutral以避免Node.js特定的全局变量 + target: 'es2020', + outfile: 'dist/logvar-danmu.js', + format: 'esm', // 保持ES模块格式 + plugins: [ + // 插件:排除UI相关模块 + { + name: 'exclude-ui-modules', + setup(build) { + // 拦截对UI相关模块的导入 + build.onResolve({ filter: /.*ui.*\.(css|js)$|.*template\.js$/ }, (args) => { + if (uiModules.some(uiModule => args.path.includes(uiModule.replace('./', '').replace('../', '')))) { + return { path: args.path, external: true }; + } + }); + } + }, + // 插件:移除导出语句(仅对输出文件进行处理) + { + name: 'remove-exports', + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length === 0) { + let outputContent = fs.readFileSync('dist/logvar-danmu.js', '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'); + + // 保存修改后的内容 + fs.writeFileSync('dist/logvar-danmu.js', outputContent); + } + }); + } + } + ], + define: { + 'widgetVersion': `"${Globals.VERSION}"` + }, + banner: { + js: customPolyfillContent + }, + logLevel: 'info' + }); + + console.log('Forward widget bundle created successfully!'); + } catch (error) { + console.error('Build failed:', error); + process.exit(1); + } +})(); diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 663369d..28cafe2 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -24,7 +24,7 @@ import MangoSource from "../sources/mango.js"; import BilibiliSource from "../sources/bilibili.js"; import YoukuSource from "../sources/youku.js"; import OtherSource from "../sources/other.js"; -import {Anime, AnimeMatch, Episodes, Bangumi} from "../models/dandan-model.js"; +import { Anime, AnimeMatch, Episodes, Bangumi } from "../models/dandan-model.js"; // ===================== // 兼容弹弹play接口 @@ -885,7 +885,7 @@ export async function getBangumi(path) { } // Extracted function for GET /api/v2/comment/:commentId -export async function getComment(path, queryFormat) { +export async function getComment(path, queryFormat, segmentFlag) { const commentId = parseInt(path.split("/").pop()); let url = findUrlById(commentId); let title = findTitleById(commentId); @@ -909,38 +909,38 @@ export async function getComment(path, queryFormat) { log("info", "开始从本地请求弹幕...", url); let danmus = []; if (url.includes('.qq.com')) { - danmus = await tencentSource.getComments(url, plat); + danmus = await tencentSource.getComments(url, plat, segmentFlag); } else if (url.includes('.iqiyi.com')) { - danmus = await iqiyiSource.getComments(url, plat); + danmus = await iqiyiSource.getComments(url, plat, segmentFlag); } else if (url.includes('.mgtv.com')) { - danmus = await mangoSource.getComments(url, plat); + danmus = await mangoSource.getComments(url, plat, segmentFlag); } else if (url.includes('.bilibili.com') || url.includes('b23.tv')) { // 如果是 b23.tv 短链接,先解析为完整 URL if (url.includes('b23.tv')) { url = await bilibiliSource.resolveB23Link(url); } - danmus = await bilibiliSource.getComments(url, plat); + danmus = await bilibiliSource.getComments(url, plat, segmentFlag); } else if (url.includes('.youku.com')) { - danmus = await youkuSource.getComments(url, plat); + danmus = await youkuSource.getComments(url, plat, segmentFlag); } // 请求其他平台弹幕 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; if (!urlPattern.test(url)) { if (plat === "renren") { - danmus = await renrenSource.getComments(url, plat); + danmus = await renrenSource.getComments(url, plat, segmentFlag); } else if (plat === "hanjutv") { - danmus = await hanjutvSource.getComments(url, plat); + danmus = await hanjutvSource.getComments(url, plat, segmentFlag); } else if (plat === "bahamut") { - danmus = await bahamutSource.getComments(url, plat); + danmus = await bahamutSource.getComments(url, plat, segmentFlag); } else if (plat === "dandan") { - danmus = await dandanSource.getComments(url, plat); + danmus = await dandanSource.getComments(url, plat, segmentFlag); } } // 如果弹幕为空,则请求第三方弹幕服务器作为兜底 if (danmus.length === 0 && urlPattern.test(url)) { - danmus = await otherSource.getComments(url, "other_server"); + danmus = await otherSource.getComments(url, "other_server", segmentFlag); } const [animeId, source] = findAnimeIdByCommentId(commentId); @@ -962,7 +962,7 @@ export async function getComment(path, queryFormat) { } // Extracted function for GET /api/v2/comment?url=xxx -export async function getCommentByUrl(videoUrl, queryFormat) { +export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) { try { // 验证URL参数 if (!videoUrl || typeof videoUrl !== 'string') { @@ -1005,24 +1005,24 @@ export async function getCommentByUrl(videoUrl, queryFormat) { // 根据URL域名判断平台并获取弹幕 if (url.includes('.qq.com')) { - danmus = await tencentSource.getComments(url, "qq"); + danmus = await tencentSource.getComments(url, "qq", segmentFlag); } else if (url.includes('.iqiyi.com')) { - danmus = await iqiyiSource.getComments(url, "qiyi"); + danmus = await iqiyiSource.getComments(url, "qiyi", segmentFlag); } else if (url.includes('.mgtv.com')) { - danmus = await mangoSource.getComments(url, "imgo"); + danmus = await mangoSource.getComments(url, "imgo", segmentFlag); } else if (url.includes('.bilibili.com') || url.includes('b23.tv')) { // 如果是 b23.tv 短链接,先解析为完整 URL if (url.includes('b23.tv')) { url = await bilibiliSource.resolveB23Link(url); } - danmus = await bilibiliSource.getComments(url, "bilibili1"); + danmus = await bilibiliSource.getComments(url, "bilibili1", segmentFlag); } else if (url.includes('.youku.com')) { - danmus = await youkuSource(url, "youku"); + danmus = await youkuSource.getComments(url, "youku", segmentFlag); } else { // 如果不是已知平台,尝试第三方弹幕服务器 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; if (urlPattern.test(url)) { - danmus = await otherSource.getComments(url, "other_server"); + danmus = await otherSource.getComments(url, "other_server", segmentFlag); } } @@ -1049,4 +1049,87 @@ export async function getCommentByUrl(videoUrl, queryFormat) { 500 ); } -} \ No newline at end of file +} + +// Extracted function for GET /api/v2/segmentcomment +export async function getSegmentComment(segment, queryFormat) { + try { + let url = segment.url; + let platform = segment.type; + + // 验证URL参数 + if (!url || typeof url !== 'string') { + log("error", "Missing or invalid url parameter"); + return jsonResponse( + { errorCode: 400, success: false, errorMessage: "Missing or invalid url parameter", count: 0, comments: [] }, + 400 + ); + } + + url = url.trim(); + + log("info", `Processing segment comment request for URL: ${url}`); + + // 检查弹幕缓存 + const cachedComments = getCommentCache(url); + if (cachedComments !== null) { + const responseData = { + errorCode: 0, + success: true, + errorMessage: "", + count: cachedComments.length, + comments: cachedComments + }; + return formatDanmuResponse(responseData, queryFormat); + } + + log("info", `开始从本地请求分段弹幕... URL: ${url}`); + let danmus = []; + + // 根据平台调用相应的分段弹幕获取方法 + if (platform === "qq") { + danmus = await tencentSource.getSegmentComments(segment); + } else if (platform === "qiyi") { + danmus = await iqiyiSource.getSegmentComments(segment); + } else if (platform === "imgo") { + danmus = await mangoSource.getSegmentComments(segment); + } else if (platform === "bilibili1") { + danmus = await bilibiliSource.getSegmentComments(segment); + } else if (platform === "youku") { + danmus = await youkuSource.getSegmentComments(segment); + } else if (platform === "hanjutv") { + danmus = await hanjutvSource.getSegmentComments(segment); + } else if (platform === "bahamut") { + danmus = await bahamutSource.getSegmentComments(segment); + } else if (platform === "renren") { + danmus = await renrenSource.getSegmentComments(segment); + } else if (platform === "dandan") { + danmus = await dandanSource.getSegmentComments(segment); + } else if (platform === "other_server") { + danmus = await otherSource.getSegmentComments(segment); + } + + log("info", `Successfully fetched ${danmus.length} segment comments from URL`); + + // 缓存弹幕结果 + if (danmus.length > 0) { + setCommentCache(url, danmus); + } + + const responseData = { + errorCode: 0, + success: true, + errorMessage: "", + count: danmus.length, + comments: danmus + }; + return formatDanmuResponse(responseData, queryFormat); + } catch (error) { + // 处理异常 + log("error", `Failed to process segment comment request: ${error.message}`); + return jsonResponse( + { errorCode: 500, success: false, errorMessage: "Internal server error", count: 0, comments: [] }, + 500 + ); + } +} diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 3aadc53..c7ead25 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.9.3', + VERSION: '1.10.0', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, diff --git a/danmu_api/models/dandan-model.js b/danmu_api/models/dandan-model.js index 7907fa0..85e029b 100644 --- a/danmu_api/models/dandan-model.js +++ b/danmu_api/models/dandan-model.js @@ -269,3 +269,71 @@ export class Bangumi { }; } } + +// ===================== +// 数据模型:SegmentListResponse +// ===================== +export class SegmentListResponse { + constructor({ type = "", segmentList = [] } = {}) { + validateType(type, "string"); + validateType(segmentList, "array"); + + // 将 segmentList 转换为 Segment 实例数组 + this.segmentList = segmentList.map(segmentData => Segment.fromJson(segmentData)); + + // 直接解构并赋值给 this + Object.assign(this, { type }); + } + + // ---- 静态方法:从 JSON 创建 SegmentListResponse 对象 ---- + static fromJson(json) { + if (typeof json !== "object" || json === null) { + throw new TypeError("fromJson 参数必须是对象"); + } + + const segmentList = (json.segmentList || []).map(segment => Segment.fromJson(segment)); + return new SegmentListResponse({ ...json, segmentList }); + } + + // ---- 转换为纯 JSON ---- + toJson() { + return { + ...this, + segmentList: this.segmentList.map(segment => segment.toJson()) + }; + } +} + +// ===================== +// 数据模型:Segment +// ===================== +export class Segment { + constructor({ type, segment_start, segment_end, url, data, _m_h5_tk, _m_h5_tk_enc } = {}) { + // 必需字段验证 + validateType(type, "string", "type"); + validateType(segment_start, "number", "segment_start"); + validateType(segment_end, "number", "segment_end"); + validateType(url, "string", "url"); + + // 可选字段验证 + if (data !== undefined) validateType(data, "string", "data"); + if (_m_h5_tk !== undefined) validateType(_m_h5_tk, "string", "_m_h5_tk"); + if (_m_h5_tk_enc !== undefined) validateType(_m_h5_tk_enc, "string", "_m_h5_tk_enc"); + + // 直接解构并赋值给 this + Object.assign(this, { type, segment_start, segment_end, url, data, _m_h5_tk, _m_h5_tk_enc }); + } + + // ---- 静态方法:从 JSON 创建 Segment 对象 ---- + static fromJson(json) { + if (typeof json !== "object" || json === null) { + throw new TypeError("fromJson 参数必须是对象"); + } + return new Segment(json); + } + + // ---- 转换为纯 JSON ---- + toJson() { + return { ...this }; + } +} diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index a13a4b8..b77dde2 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -7,6 +7,7 @@ import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized, traditionalized } from "../utils/zh-util.js"; import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js"; import { strictTitleMatch, normalizeSpaces } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取巴哈姆特弹幕 @@ -381,6 +382,24 @@ export default class BahamutSource extends BaseSource { } } + async getEpisodeDanmuSegments(id) { + log("info", "获取巴哈姆特弹幕分段列表...", id); + + return new SegmentListResponse({ + "type": "bahamut", + "segmentList": [{ + "type": "bahamut", + "segment_start": 0, + "segment_end": 30000, + "url": id + }] + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getEpisodeDanmu(segment.url); + } + formatComments(comments) { const positionToMode = { 0: 1, 1: 5, 2: 4 }; return comments.map(c => ({ diff --git a/danmu_api/sources/base.js b/danmu_api/sources/base.js index 4bfc485..6e51c09 100644 --- a/danmu_api/sources/base.js +++ b/danmu_api/sources/base.js @@ -31,13 +31,28 @@ export default class BaseSource { throw new Error("Method 'getEpisodeDanmu' must be implemented"); } + // 获取某集的弹幕分片列表 + async getEpisodeDanmuSegments(id) { + throw new Error("Method 'getEpisodeDanmuSegments' must be implemented"); + } + + // 获取某集的分片弹幕 + async getEpisodeSegmentDanmu(segment) { + throw new Error("Method 'getEpisodeSegmentDanmu' must be implemented"); + } + // 格式化弹幕 formatComments(comments) { throw new Error("Method 'formatComments' must be implemented"); } // 获取弹幕流水线方法(获取某集弹幕 -> 格式化弹幕 -> 弹幕处理,如去重/屏蔽字等) - async getComments(id, sourceName, progressCallback=null) { + async getComments(id, sourceName, segmentFlag=false, progressCallback=null) { + if (segmentFlag) { + if(progressCallback) await progressCallback(5, `开始获取弹幕${sourceName}弹幕分片列表`); + log("info", `开始获取弹幕${sourceName}弹幕分片列表`); + return await this.getEpisodeDanmuSegments(id); + } if(progressCallback) await progressCallback(5, `开始获取弹幕${sourceName}弹幕`); log("info", `开始获取弹幕${sourceName}弹幕`); const raw = await this.getEpisodeDanmu(id); @@ -49,6 +64,19 @@ export default class BaseSource { return convertToDanmakuJson(formatted, sourceName); } + // 获取分片弹幕流水线方法(获取某集分片弹幕 -> 格式化弹幕 -> 弹幕处理,如去重/屏蔽字等) + async getSegmentComments(segment, progressCallback=null) { + if(progressCallback) await progressCallback(5, `开始获取分片弹幕${segment.type}弹幕`); + log("info", `开始获取分片弹幕${segment.type}弹幕`); + const raw = await this.getEpisodeSegmentDanmu(segment); + if(progressCallback) await progressCallback(85,`原始分片弹幕 ${raw.length} 条,正在规范化`); + log("info", `原始分片弹幕 ${raw.length} 条,正在规范化`); + const formatted = this.formatComments(raw); + if(progressCallback) await progressCallback(100,`分片弹幕处理完成,共 ${formatted.length} 条`); + log("info", `分片弹幕处理完成,共 ${formatted.length} 条`); + return convertToDanmakuJson(formatted, segment.type); + } + // 按年份降序排序并添加到curAnimes sortAndPushAnimesByYear(processedAnimes, curAnimes) { processedAnimes diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 3f92211..15d29cd 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -6,6 +6,7 @@ import { parseDanmakuBase64, md5, convertToAsciiSum } from "../utils/codec-util. import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取b站弹幕 @@ -33,8 +34,7 @@ export default class BilibiliSource extends BaseSource { // 使用原生 fetch 获取重定向后的 URL // fetch 默认会自动跟踪重定向,response.url 会是最终的 URL - const response = await fetch(shortUrl, { - method: 'GET', + const response = await httpGet(shortUrl, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, @@ -448,15 +448,14 @@ export default class BilibiliSource extends BaseSource { return tmpAnimes; } - async getEpisodeDanmu(id) { - log("info", "开始从本地请求B站弹幕...", id); + // 提取视频信息的公共方法 + async _extractVideoInfo(id) { + log("info", "提取B站视频信息...", id); - // 弹幕和视频信息 API 基础地址 const api_video_info = "https://api.bilibili.com/x/web-interface/view"; const api_epid_cid = "https://api.bilibili.com/pgc/view/web/season"; // 解析 URL 获取必要参数 - // 手动解析 URL(没有 URL 对象的情况下) const regex = /^(https?:\/\/[^\/]+)(\/[^?#]*)/; const match = id.match(regex); @@ -467,10 +466,10 @@ export default class BilibiliSource extends BaseSource { log("info", path); } else { log("error", 'Invalid URL'); - return []; + return null; } - let title, danmakuUrl, cid, aid, duration; + let cid, aid, duration, title; // 普通投稿视频 if (id.includes("video/")) { @@ -509,15 +508,14 @@ export default class BilibiliSource extends BaseSource { const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; if (data.code !== 0) { log("error", "获取普通投稿视频信息失败:", data.message); - return []; + return null; } duration = data.data.duration; cid = data.data.pages[p - 1].cid; - danmakuUrl = `https://comment.bilibili.com/${cid}.xml`; } catch (error) { log("error", "请求普通投稿视频信息失败:", error); - return []; + return null; } // 番剧 - ep格式 @@ -536,7 +534,7 @@ export default class BilibiliSource extends BaseSource { const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; if (data.code !== 0) { log("error", "获取番剧视频信息失败:", data.message); - return []; + return null; } for (const episode of data.result.episodes) { @@ -544,23 +542,22 @@ export default class BilibiliSource extends BaseSource { title = episode.share_copy; cid = episode.cid; duration = episode.duration / 1000; - danmakuUrl = `https://comment.bilibili.com/${cid}.xml`; break; } } - if (!danmakuUrl) { + if (!cid || !duration) { log("error", "未找到匹配的番剧集信息"); - return []; + return null; } } catch (error) { log("error", "请求番剧视频信息失败:", error); - return []; + return null; } // 番剧 - ss格式 - } else if (id.includes("bangumi/") && inputUrl.includes("ss")) { + } else if (id.includes("bangumi/") && id.includes("ss")) { try { const ssid = path.slice(-1)[0].slice(2).split('?')[0]; // 移除可能的查询参数 const ssInfoUrl = `${api_epid_cid}?season_id=${ssid}`; @@ -577,34 +574,92 @@ export default class BilibiliSource extends BaseSource { const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; if (data.code !== 0) { log("error", "获取番剧视频信息失败:", data.message); - return []; + return null; } // 检查是否有episodes数据 if (!data.result.episodes || data.result.episodes.length === 0) { log("error", "番剧没有可用的集数"); - return []; + return null; } // 默认获取第一集的弹幕 const firstEpisode = data.result.episodes[0]; - title = firstEpisode.share_copy; cid = firstEpisode.cid; duration = firstEpisode.duration / 1000; - danmakuUrl = `https://comment.bilibili.com/${cid}.xml`; + title = firstEpisode.share_copy; log("info", `使用第一集: ${title}, cid=${cid}`); } catch (error) { log("error", "请求番剧视频信息失败:", error); - return []; + return null; } } else { log("error", "不支持的B站视频网址,仅支持普通视频(av,bv)、剧集视频(ep,ss)"); + return null; + } + + log("info", `提取视频信息完成: cid=${cid}, aid=${aid}, duration=${duration}`); + + return { cid, aid, duration, title }; + } + + async getEpisodeDanmu(id) { + log("info", "开始从本地请求B站弹幕...", id); + + // 获取弹幕分段数据 + const segmentResult = await this.getEpisodeDanmuSegments(id); + if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) { return []; } - log("info", danmakuUrl, cid, aid, duration); + + const segmentList = segmentResult.segmentList; + log("info", `弹幕分段数量: ${segmentList.length}`); + + // 创建请求Promise数组 + const promises = []; + for (const segment of segmentList) { + promises.push( + this.getEpisodeSegmentDanmu(segment) + ); + } + + // 解析弹幕数据 + let contents = []; + try { + const results = await Promise.allSettled(promises); + const datas = results + .filter(result => result.status === "fulfilled") + .map(result => result.value) + .filter(data => data !== null); // 过滤掉null值 + + datas.forEach(data => { + contents.push(...data); + }); + } catch (error) { + log("error", "解析弹幕数据失败:", error); + return []; + } + + return contents; + } + + async getEpisodeDanmuSegments(id) { + log("info", "获取B站弹幕分段列表...", id); + + // 提取视频信息 + const videoInfo = await this._extractVideoInfo(id); + if (!videoInfo) { + return new SegmentListResponse({ + "type": "bilibili1", + "segmentList": [] + }); + } + + const { cid, aid, duration } = videoInfo; + log("info", `视频信息: cid=${cid}, aid=${aid}, duration=${duration}`); // 计算视频的分片数量 const maxLen = Math.floor(duration / 360) + 1; @@ -620,46 +675,44 @@ export default class BilibiliSource extends BaseSource { } segmentList.push({ - segment_start: i * 360 * 1000, - segment_end: (i + 1) * 360 * 1000, - url: danmakuUrl, + "type": "bilibili1", + "segment_start": i * 360, + "segment_end": (i + 1) * 360, + "url": danmakuUrl }); } - // 使用 Promise.all 并行请求所有分片 + return new SegmentListResponse({ + "type": "bilibili1", + "segmentList": segmentList + }); + } + + async getEpisodeSegmentDanmu(segment) { try { - const allComments = await Promise.all( - segmentList.map(async (segment) => { - log("info", "正在请求弹幕数据...", segment.url); - try { - // 请求单个分片的弹幕数据 - let res = await httpGet(segment.url, { - headers: { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "Cookie": globals.bilibliCookie - }, - base64Data: true, - retries: 1, - }); + const response = await httpGet(segment.url, { + headers: { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Cookie": globals.bilibliCookie + }, + base64Data: true, + retries: 1, + }); - return parseDanmakuBase64(res.data); - } catch (error) { - log("error", "请求弹幕数据失败: ", error); - return []; - } - }) - ); + // 处理响应数据并返回 contents 格式的弹幕 + let contents = []; + if (response && response.data) { + contents = parseDanmakuBase64(response.data); + } - // 合并所有分片的弹幕数据 - const mergedComments = allComments.flat(); - return mergedComments; + return contents; } catch (error) { - log("error", "获取所有弹幕数据时出错: ", error); - return []; + log("error", "请求分片弹幕失败:", error); + return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为 } } formatComments(comments) { return comments; } -} \ No newline at end of file +} diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index 7659a98..bb3d867 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -4,6 +4,7 @@ import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized } from "../utils/zh-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取弹弹play弹幕 @@ -166,6 +167,24 @@ export default class DandanSource extends BaseSource { } } + async getEpisodeDanmuSegments(id) { + log("info", "获取弹弹play弹幕分段列表...", id); + + return new SegmentListResponse({ + "type": "dandan", + "segmentList": [{ + "type": "dandan", + "segment_start": 0, + "segment_end": 30000, + "url": id + }] + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getEpisodeDanmu(segment.url); + } + formatComments(comments) { return comments.map(c => ({ cid: c.cid, diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index 9232481..e4f41e0 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -6,6 +6,7 @@ import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取韩剧TV弹幕 @@ -230,6 +231,24 @@ export default class HanjutvSource extends BaseSource { } } + async getEpisodeDanmuSegments(id) { + log("info", "获取韩剧TV弹幕分段列表...", id); + + return new SegmentListResponse({ + "type": "hanjutv", + "segmentList": [{ + "type": "hanjutv", + "segment_start": 0, + "segment_end": 30000, + "url": id + }] + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getEpisodeDanmu(segment.url); + } + formatComments(comments) { return comments.map(c => ({ cid: Number(c.did), diff --git a/danmu_api/sources/iqiyi.js b/danmu_api/sources/iqiyi.js index 525733f..79b1c51 100644 --- a/danmu_api/sources/iqiyi.js +++ b/danmu_api/sources/iqiyi.js @@ -6,6 +6,7 @@ import { md5, convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { globals } from '../configs/globals.js'; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取爱奇艺弹幕 @@ -690,10 +691,68 @@ export default class IqiyiSource extends BaseSource { async getEpisodeDanmu(id) { log("info", "开始从本地请求爱奇艺弹幕...", id); + // 获取页面标题 + let res; + try { + res = await httpGet(id, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + }); + } catch (error) { + log("error", "请求页面失败:", error); + return []; + } + + // 使用正则表达式提取 标签内容 + const titleMatch = res.data.match(/<title[^>]*>(.*?)<\/title>/i); + const title = titleMatch ? titleMatch[1].split("_")[0] : "未知标题"; + log("info", `标题: ${title}`); + + // 获取弹幕分段数据 + const segmentResult = await this.getEpisodeDanmuSegments(id); + if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) { + return []; + } + + const segmentList = segmentResult.segmentList; + log("info", `弹幕分段数量: ${segmentList.length}`); + + // 创建请求Promise数组 + const promises = []; + for (const segment of segmentList) { + promises.push(this.getEpisodeSegmentDanmu(segment)); + } + + // 解析弹幕数据 + let contents = []; + try { + const results = await Promise.allSettled(promises); + const datas = results + .filter(result => result.status === "fulfilled") + .map(result => result.value) + .filter(data => data !== null); // 过滤掉null值 + + datas.forEach(data => { + contents.push(...data); + }); + } catch (error) { + log("error", "解析弹幕数据失败:", error); + return []; + } + + printFirst200Chars(contents); + + return contents; + } + + async getEpisodeDanmuSegments(id) { + log("info", "获取爱奇艺视频弹幕分段列表...", id); + // 弹幕 API 基础地址 const api_decode_base = "https://pcw-api.iq.com/api/decode/"; const api_video_info = "https://pcw-api.iqiyi.com/video/video/baseinfo/"; - const api_danmaku_base = "https://cmts.iqiyi.com/bullet/"; // 解析 URL 获取 tvid let tvid; @@ -701,7 +760,10 @@ export default class IqiyiSource extends BaseSource { const idMatch = id.match(/v_(\w+)/); if (!idMatch) { log("error", "无法从 URL 中提取 tvid"); - return []; + return new SegmentListResponse({ + "type": "qiyi", + "segmentList": [] + }); } tvid = idMatch[1]; log("info", `tvid: ${tvid}`); @@ -719,11 +781,14 @@ export default class IqiyiSource extends BaseSource { log("info", `解码后 tvid: ${tvid}`); } catch (error) { log("error", "请求解码信息失败:", error); - return []; + return new SegmentListResponse({ + "type": "qiyi", + "segmentList": [] + }); } // 获取视频基础信息 - let title, duration, albumid, categoryid; + let duration, albumid, categoryid; try { const videoInfoUrl = `${api_video_info}${tvid}`; const res = await httpGet(videoInfoUrl, { @@ -734,22 +799,24 @@ export default class IqiyiSource extends BaseSource { }); const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; const videoInfo = data.data; - title = videoInfo.name || videoInfo.tvName || "未知标题"; duration = videoInfo.durationSec; albumid = videoInfo.albumId; categoryid = videoInfo.channelId || videoInfo.categoryId; - log("info", `标题: ${title}, 时长: ${duration}`); + log("info", `时长: ${duration}`); } catch (error) { log("error", "请求视频基础信息失败:", error); - return []; + return new SegmentListResponse({ + "type": "qiyi", + "segmentList": [] + }); } // 计算弹幕分段数量(每5分钟一个分段) const page = Math.ceil(duration / (60 * 5)); log("info", `弹幕分段数量: ${page}`); - // 构建弹幕请求 - const promises = []; + // 构建分段列表 + const segmentList = []; for (let i = 0; i < page; i++) { const params = { rn: "0.0123456789123456", @@ -759,40 +826,47 @@ export default class IqiyiSource extends BaseSource { tvid: tvid, albumid: albumid, categoryid: categoryid, - qypid: "01010021010000000000", + qypid: "010102101000000000", }; let queryParams = buildQueryString(params); - const api_url = `${api_danmaku_base}${tvid.slice(-4, -2)}/${tvid.slice(-2)}/${tvid}_300_${i + 1}.z?${queryParams.toString()}`; - promises.push( - httpGet(api_url, { - headers: { - "Accpet-Encoding": "gzip", - "Content-Type": "application/xml", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, - zlibMode: true, - retries: 1, - }) - ); + const api_url = `https://cmts.iqiyi.com/bullet/${tvid.slice(-4, -2)}/${tvid.slice(-2)}/${tvid}_300_${i + 1}.z?${queryParams.toString()}`; + segmentList.push({ + "type": "qiyi", + "segment_start": i * 5 * 60, // 每段5分钟 + "segment_end": Math.min((i + 1) * 5 * 60, duration), + "url": api_url + }); } - // 提取 XML 标签内容的辅助函数 - function extract(xml, tag) { - const reg = new RegExp(`<${tag}>(.*?)</${tag}>`, "g"); - const res = xml.match(reg)?.map((x) => x.substring(tag.length + 2, x.length - tag.length - 3)); - return res || []; - } + return new SegmentListResponse({ + "type": "qiyi", + "segmentList": segmentList + }); + } - // 解析弹幕数据 - let contents = []; + async getEpisodeSegmentDanmu(segment) { try { - const results = await Promise.allSettled(promises); - const datas = results - .filter((result) => result.status === "fulfilled") - .map((result) => result.value); + const response = await httpGet(segment.url, { + headers: { + "Accpet-Encoding": "gzip", + "Content-Type": "application/xml", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + zlibMode: true, + retries: 1, + }); - datas.forEach(data => { - const xml = data.data; + // 提取 XML 标签内容的辅助函数 + function extract(xml, tag) { + const reg = new RegExp(`<${tag}>(.*?)</${tag}>`, "g"); + const res = xml.match(reg)?.map((x) => x.substring(tag.length + 2, x.length - tag.length - 3)); + return res || []; + } + + // 处理响应数据并返回 contents 格式的弹幕 + let contents = []; + if (response && response.data) { + const xml = response.data; const danmaku = extract(xml, "content"); const showTime = extract(xml, "showTime"); const color = extract(xml, "color"); @@ -802,15 +876,13 @@ export default class IqiyiSource extends BaseSource { showTime: showTime[i], color: color[i], }))); - }); + } + + return contents; } catch (error) { - log("error", "解析弹幕数据失败:", error); - return []; + log("error", "请求分片弹幕失败:", error); + return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为 } - - printFirst200Chars(contents); - - return contents; } formatComments(comments) { @@ -831,4 +903,4 @@ export default class IqiyiSource extends BaseSource { return content; }); } -} \ No newline at end of file +} diff --git a/danmu_api/sources/mango.js b/danmu_api/sources/mango.js index 1ef9142..bd02c24 100644 --- a/danmu_api/sources/mango.js +++ b/danmu_api/sources/mango.js @@ -7,6 +7,7 @@ import { time_to_second, generateValidStartDate } from "../utils/time-util.js"; import { rgbToInt } from "../utils/danmu-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取芒果TV弹幕 @@ -507,30 +508,87 @@ export default class MangoSource extends BaseSource { async getEpisodeDanmu(id) { log("info", "开始从本地请求芒果TV弹幕...", id); + // 获取弹幕分段列表 + const segmentResult = await this.getEpisodeDanmuSegments(id); + if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) { + return []; + } + + const segmentList = segmentResult.segmentList; + log("info", `弹幕分段数量: ${segmentList.length}`); + + // 创建请求Promise数组 + const promises = []; + for (const segment of segmentList) { + promises.push( + httpGet(segment.url, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + retries: 1, + }) + ); + } + + // 解析弹幕数据 + let contents = []; + try { + const results = await Promise.allSettled(promises); + const datas = results + .filter(result => result.status === "fulfilled") + .map(result => { + // 检查result是否包含响应数据 + if (result.value && result.value.data) { + return result.value.data; + } + return null; + }) + .filter(data => data !== null); // 过滤掉null值 + + datas.forEach(data => { + data = typeof data === "string" ? JSON.parse(data) : data; + if (data.data?.items) { + contents.push(...data.data.items); + } + }); + } catch (error) { + log("error", "解析弹幕数据失败:", error); + return []; + } + + printFirst200Chars(contents); + + return contents; + } + + async getEpisodeDanmuSegments(id) { + log("info", "获取芒果TV弹幕分段列表...", id); + // 弹幕和视频信息 API 基础地址 const api_video_info = "https://pcweb.api.mgtv.com/video/info"; const api_ctl_barrage = "https://galaxy.bz.mgtv.com/getctlbarrage"; - const api_rd_barrage = "https://galaxy.bz.mgtv.com/rdbarrage"; // 解析 URL 获取 cid 和 vid - // 手动解析 URL(没有 URL 对象的情况下) const regex = /^(https?:\/\/[^\/]+)(\/[^?#]*)/; const match = id.match(regex); let path; if (match) { path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串 - log("info", path); } else { log("error", 'Invalid URL'); - return []; + return new SegmentListResponse({ + "type": "imgo", + "segmentList": [] + }); } const cid = path[path.length - 2]; const vid = path[path.length - 1].split(".")[0]; - log("info", `cid: ${cid}, vid: ${vid}`); + log("info", `获取弹幕分段列表 - cid: ${cid}, vid: ${vid}`); - // 获取页面标题和视频时长 + // 获取视频信息 let res; try { const videoInfoUrl = `${api_video_info}?cid=${cid}&vid=${vid}`; @@ -541,20 +599,23 @@ export default class MangoSource extends BaseSource { }, }); } catch (error) { + if (error.response?.status === 404) { + return new SegmentListResponse({ + "type": "imgo", + "segmentList": [] + }); + } log("error", "请求视频信息失败:", error); - return []; + return new SegmentListResponse({ + "type": "imgo", + "segmentList": [] + }); } const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; - const title = data.data.info.videoName; const time = data.data.info.time; - log("info", `标题: ${title}`); - // 计算弹幕分段请求 - let promises = []; - let useNewApi = true; - - // 尝试使用新API(支持彩色弹幕) + // 尝试使用新API(支持彩色弹幕)获取分段列表 try { const ctlBarrageUrl = `${api_ctl_barrage}?version=8.1.39&abroad=0&uuid=&os=10.15.7&platform=0&mac=&vid=${vid}&pid=&cid=${cid}&ticket=`; const res = await httpGet(ctlBarrageUrl, { @@ -567,75 +628,65 @@ export default class MangoSource extends BaseSource { // 检查数据结构 if (!ctlBarrage.data || !ctlBarrage.data.cdn_list || !ctlBarrage.data.cdn_version) { - log("warn", `新API缺少必要字段,切换到旧API`); - useNewApi = false; - } else { - // 每1分钟一个分段 - for (let i = 0; i < Math.ceil(time_to_second(time) / 60); i += 1) { - const danmakuUrl = `https://${ctlBarrage.data.cdn_list.split(',')[0]}/${ctlBarrage.data.cdn_version}/${i}.json`; - promises.push( - httpGet(danmakuUrl, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, - retries: 1, - }) - ); - } - log("info", `使用新API,弹幕分段数量: ${promises.length}`); + log("warn", `新API缺少必要字段,返回空分段列表`); + return new SegmentListResponse({ + "type": "imgo", + "segmentList": [] + }); } - } catch (error) { - log("warn", "新API请求失败,切换到旧API:", error.message); - useNewApi = false; - } - // 如果新API失败,使用旧API作为兜底 - if (!useNewApi) { - try { - const step = 60 * 1000; // 每60秒一个分段 - const end_time = time_to_second(time) * 1000; - for (let i = 0; i < end_time; i += step) { - const danmakuUrl = `${api_rd_barrage}?vid=${vid}&cid=${cid}&time=${i}`; - promises.push( - httpGet(danmakuUrl, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, - retries: 1, - }) - ); - } - log("info", `使用旧API,弹幕分段数量: ${promises.length}`); - } catch (error) { - log("error", "旧API请求失败:", error); - return []; + // 构建分段列表 + const segmentList = []; + const totalSegments = Math.ceil(time_to_second(time) / 60); // 每1分钟一个分段 + const cdnList = ctlBarrage.data.cdn_list.split(',')[0]; + const cdnVersion = ctlBarrage.data.cdn_version; + + for (let i = 0; i < totalSegments; i++) { + segmentList.push({ + "type": "imgo", + "segment_start": i * 60, // 每段开始时间(秒) + "segment_end": Math.min((i + 1) * 60, time_to_second(time)), // 每段结束时间(秒) + "url": `https://${cdnList}/${cdnVersion}/${i}.json` // 每段弹幕URL + }); } - } - // 解析弹幕数据 - let contents = []; - try { - const results = await Promise.allSettled(promises); - const datas = results - .filter(result => result.status === "fulfilled") - .map(result => result.value.data); - - datas.forEach(data => { - const dataJson = typeof data === "string" ? JSON.parse(data) : data; - if (dataJson.data?.items) { - contents.push(...dataJson.data.items); - } + return new SegmentListResponse({ + "type": "imgo", + "segmentList": segmentList }); } catch (error) { - log("error", "解析弹幕数据失败:", error); - return []; + log("error", "请求弹幕分段数据失败:", error); + return new SegmentListResponse({ + "type": "imgo", + "segmentList": [] + }); } + } - printFirst200Chars(contents); + async getEpisodeSegmentDanmu(segment) { + try { + const response = await httpGet(segment.url, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + retries: 1, + }); - return contents; + // 处理响应数据并返回 contents 格式的弹幕 + let contents = []; + if (response && response.data) { + const parsedData = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + if (parsedData.data?.items) { + contents.push(...parsedData.data.items); + } + } + + return contents; + } catch (error) { + log("error", "请求分片弹幕失败:", error); + return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为 + } } formatComments(comments) { @@ -665,4 +716,4 @@ export default class MangoSource extends BaseSource { return content; }); } -} \ No newline at end of file +} diff --git a/danmu_api/sources/other.js b/danmu_api/sources/other.js index dc4be58..da2fcf3 100644 --- a/danmu_api/sources/other.js +++ b/danmu_api/sources/other.js @@ -3,6 +3,7 @@ import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { printFirst200Chars } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取第三方弹幕服务器弹幕 @@ -36,6 +37,24 @@ export default class OtherSource extends BaseSource { } } + async getEpisodeDanmuSegments(id) { + log("info", "获取第三方服务器弹幕分段列表...", id); + + return new SegmentListResponse({ + "type": "other_server", + "segmentList": [{ + "type": "other_server", + "segment_start": 0, + "segment_end": 30000, + "url": id + }] + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getEpisodeDanmu(segment.url); + } + formatComments(comments) { return comments; } diff --git a/danmu_api/sources/renren.js b/danmu_api/sources/renren.js index be694c6..e39aac3 100644 --- a/danmu_api/sources/renren.js +++ b/danmu_api/sources/renren.js @@ -6,6 +6,7 @@ import { autoDecode, createHmacSha256, generateRandomSid, generateSign, generate import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取人人视频弹幕 @@ -427,6 +428,24 @@ export default class RenrenSource extends BaseSource { } } + async getEpisodeDanmuSegments(id) { + log("info", "获取人人视频弹幕分段列表...", id); + + return new SegmentListResponse({ + "type": "renren", + "segmentList": [{ + "type": "renren", + "segment_start": 0, + "segment_end": 30000, + "url": id + }] + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getEpisodeDanmu(segment.url); + } + formatComments(comments) { return comments.map(item => { const text = String(item.d || ""); diff --git a/danmu_api/sources/tencent.js b/danmu_api/sources/tencent.js index 76fa56a..c010718 100644 --- a/danmu_api/sources/tencent.js +++ b/danmu_api/sources/tencent.js @@ -6,6 +6,7 @@ import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取腾讯视频弹幕 @@ -400,25 +401,30 @@ export default class TencentSource extends BaseSource { return processTencentAnimes; } + // 提取vid的公共函数 + extractVid(id) { + let vid = id; + // 如果传入的是完整URL,则从中提取vid + if (typeof id === 'string' && (id.startsWith('http') || id.includes('vid='))) { + // 1. 尝试从查询参数中提取 vid + const queryMatch = id.match(/[?&]vid=([^&]+)/); + if (queryMatch) { + vid = queryMatch[1]; // 获取 vid 参数值 + } else { + // 2. 从路径末尾提取 vid + const pathParts = id.split('/'); + const lastPart = pathParts[pathParts.length - 1]; + vid = lastPart.split('.')[0]; // 去除文件扩展名 + } + } + return vid; + } + async getEpisodeDanmu(id) { log("info", "开始从本地请求腾讯视频弹幕...", id); - // 弹幕 API 基础地址 - const api_danmaku_base = "https://dm.video.qq.com/barrage/base/"; - const api_danmaku_segment = "https://dm.video.qq.com/barrage/segment/"; - // 解析 URL 获取 vid - let vid; - // 1. 尝试从查询参数中提取 vid - const queryMatch = id.match(/[?&]vid=([^&]+)/); - if (queryMatch) { - vid = queryMatch[1]; // 获取 vid 参数值 - } else { - // 2. 从路径末尾提取 vid - const pathParts = id.split('/'); - const lastPart = pathParts[pathParts.length - 1]; - vid = lastPart.split('.')[0]; // 去除文件扩展名 - } + let vid = this.extractVid(id); log("info", `vid: ${vid}`); @@ -441,31 +447,20 @@ export default class TencentSource extends BaseSource { const title = titleMatch ? titleMatch[1].split("_")[0] : "未知标题"; log("info", `标题: ${title}`); - // 获取弹幕基础数据 - try { - res = await httpGet(api_danmaku_base + vid, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, - }); - } catch (error) { - if (error.response?.status === 404) { - return []; - } - log("error", "请求弹幕基础数据失败:", error); + // 获取弹幕分段数据 + const segmentResult = await this.getEpisodeDanmuSegments(id); + if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) { return []; } - // 先把 res.data 转成 JSON - const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + const segmentList = segmentResult.segmentList; + log("info", `弹幕分段数量: ${segmentList.length}`); - // 获取弹幕分段数据 + // 创建请求Promise数组 const promises = []; - const segmentList = Object.values(data.segment_index); - for (const item of segmentList) { + for (const segment of segmentList) { promises.push( - httpGet(`${api_danmaku_segment}${vid}/${item.segment_name}`, { + httpGet(segment.url, { headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", @@ -475,15 +470,20 @@ export default class TencentSource extends BaseSource { ); } - log("info", `弹幕分段数量: ${promises.length}`); - // 解析弹幕数据 let contents = []; try { const results = await Promise.allSettled(promises); const datas = results .filter(result => result.status === "fulfilled") - .map(result => result.value.data); + .map(result => { + // 检查result是否包含响应数据 + if (result.value && result.value.data) { + return result.value.data; + } + return null; + }) + .filter(data => data !== null); // 过滤掉null值 datas.forEach(data => { data = typeof data === "string" ? JSON.parse(data) : data; @@ -499,6 +499,91 @@ export default class TencentSource extends BaseSource { return contents; } + async getEpisodeDanmuSegments(id) { + log("info", "获取腾讯视频弹幕分段列表...", id); + + // 弹幕 API 基础地址 + const api_danmaku_base = "https://dm.video.qq.com/barrage/base/"; + const api_danmaku_segment = "https://dm.video.qq.com/barrage/segment/"; + + let vid = this.extractVid(id); + + log("info", `获取弹幕分段列表 - vid: ${vid}`); + + // 获取弹幕基础数据 + let res; + try { + res = await httpGet(api_danmaku_base + vid, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + }); + } catch (error) { + if (error.response?.status === 404) { + return new SegmentListResponse({ + "type": "qq", + "segmentList": [] + }); + } + log("error", "请求弹幕基础数据失败:", error); + return new SegmentListResponse({ + "type": "qq", + "segmentList": [] + }); + } + + // 先把 res.data 转成 JSON + const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + + // 构建分段列表 + const segmentList = []; + const segmentItems = Object.values(data.segment_index); + for (const item of segmentItems) { + segmentList.push({ + "type": "qq", + "segment_start": (() => { + const start = Number(item.segment_start) || 0; + return start / 1000; + })(), + "segment_end": (() => { + const end = Number(item.segment_name.split('/').pop()) || 0; + return end / 1000; + })(), + "url": `${api_danmaku_segment}${vid}/${item.segment_name}` + }); + } + + return new SegmentListResponse({ + "type": "qq", + "segmentList": segmentList + }); + } + + async getEpisodeSegmentDanmu(segment) { + try { + const response = await httpGet(segment.url, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + retries: 1, + }); + + // 处理响应数据并返回 contents 格式的弹幕 + let contents = []; + if (response && response.data) { + const parsedData = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + contents.push(...parsedData.barrage_list); + } + + return contents; + } catch (error) { + log("error", "请求分片弹幕失败:", error); + return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为 + } + } + formatComments(comments) { return comments.map(item => { const content = { @@ -534,4 +619,4 @@ export default class TencentSource extends BaseSource { return content; }); } -} \ No newline at end of file +} diff --git a/danmu_api/sources/youku.js b/danmu_api/sources/youku.js index bbd8da9..f58ec7d 100644 --- a/danmu_api/sources/youku.js +++ b/danmu_api/sources/youku.js @@ -6,6 +6,7 @@ import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; import { md5, convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取优酷弹幕 @@ -363,13 +364,73 @@ export default class YoukuSource extends BaseSource { return 'drama'; } - async getEpisodeDanmu(id) { + async getEpisodeDanmu(id) { log("info", "开始从本地请求优酷弹幕...", id); if (!id) { return []; } + // 获取分片URL列表 + const segmentListResponse = await this.getEpisodeDanmuSegments(id); + const segmentList = segmentListResponse.segmentList; + + let contents = []; + + // 并发限制(可通过环境变量 YOUKU_CONCURRENCY 配置,默认 8) + const concurrency = globals.youkuConcurrency; + const segments = [...segmentList]; + + for (let i = 0; i < segments.length; i += concurrency) { + const batch = segments.slice(i, i + concurrency).map(async (segment) => { + const response = await httpPost(segment.url, buildQueryString({ data: segment.data }), { + headers: { + "Cookie": `_m_h5_tk=${segment._m_h5_tk};_m_h5_tk_enc=${segment._m_h5_tk_enc};`, + "Referer": "https://v.youku.com", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36", + }, + allow_redirects: false, + retries: 1, + }); + + const results = []; + if (response.data?.data && response.data.data.result) { + const result = JSON.parse(response.data.data.result); + if (result.code !== "-1") { + results.push(...result.data.result); + } + } + return results; + }); + + try { + const settled = await Promise.allSettled(batch); + for (const s of settled) { + if (s.status === "fulfilled" && Array.isArray(s.value)) { + contents = contents.concat(s.value); + } + } + } catch (e) { + log("error", "优酷分段批量请求失败:", e.message); + } + } + + printFirst200Chars(contents); + + return contents; + } + + async getEpisodeDanmuSegments(id) { + log("info", "获取优酷弹幕分段列表...", id); + + if (!id) { + return new SegmentListResponse({ + "type": "youku", + "segmentList": [] + }); + } + // 处理302场景 // https://v.youku.com/video?vid=XNjQ4MTIwOTE2NA==&tpa=dW5pb25faWQ9MTAyMjEzXzEwMDAwNl8wMV8wMQ需要转成https://v.youku.com/v_show/id_XNjQ4MTIwOTE2NA==.html if (id.includes("youku.com/video?vid")) { @@ -472,7 +533,7 @@ export default class YoukuSource extends BaseSource { // 计算弹幕分段请求 const step = 60; // 每60秒一个分段 const max_mat = Math.floor(duration / step) + 1; - let contents = []; + let segmentList = []; // 将构造请求和解析逻辑封装为函数,返回该分段的弹幕数组 const requestOneMat = async (mat) => { @@ -549,47 +610,52 @@ export default class YoukuSource extends BaseSource { const url = `${api_danmaku}?${queryString}`; log("info", `piece_url: ${url}`); - const response = await httpPost(url, buildQueryString({ data: data }), { - headers: { - "Cookie": `_m_h5_tk=${_m_h5_tk};_m_h5_tk_enc=${_m_h5_tk_enc};`, - "Referer": "https://v.youku.com", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36", - }, - allow_redirects: false, - retries: 1, - }); + return { + "type": "youku", + "segment_start": mat * step, + "segment_end": Math.min((mat + 1) * step, duration), + "url": url, + "data": data, + "_m_h5_tk": _m_h5_tk, + "_m_h5_tk_enc": _m_h5_tk_enc, + }; + } - const results = []; - if (response.data?.data && response.data.data.result) { - const result = JSON.parse(response.data.data.result); - if (result.code !== "-1") { - results.push(...result.data.result); - } - } - return results; - }; - - // 并发限制(可通过环境变量 YOUKU_CONCURRENCY 配置,默认 8) - const concurrency = globals.youkuConcurrency; const mats = Array.from({ length: max_mat }, (_, i) => i); - for (let i = 0; i < mats.length; i += concurrency) { - const batch = mats.slice(i, i + concurrency).map((m) => requestOneMat(m)); - try { - const settled = await Promise.allSettled(batch); - for (const s of settled) { - if (s.status === "fulfilled" && Array.isArray(s.value)) { - contents = contents.concat(s.value); - } - } - } catch (e) { - log("error", "优酷分段批量请求失败:", e.message); + for (let i = 0; i < mats.length; i++) { + const result = await requestOneMat(mats[i]); + segmentList.push(result); + } + + return new SegmentListResponse({ + "type": "youku", + "segmentList": segmentList + }); + } + + async getEpisodeSegmentDanmu(segment) { + log("info", "开始从本地请求优酷分段弹幕...", segment.url); + + const response = await httpPost(segment.url, buildQueryString({ data: segment.data }), { + headers: { + "Cookie": `_m_h5_tk=${segment._m_h5_tk};_m_h5_tk_enc=${segment._m_h5_tk_enc};`, + "Referer": "https://v.youku.com", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36", + }, + allow_redirects: false, + retries: 1, + }); + + const results = []; + if (response.data?.data && response.data.data.result) { + const result = JSON.parse(response.data.data.result); + if (result.code !== "-1") { + results.push(...result.data.result); } } - printFirst200Chars(contents); - - return contents; + return results; } formatComments(comments) { @@ -606,7 +672,9 @@ export default class YoukuSource extends BaseSource { content.timepoint = item.playat / 1000; const prop = JSON.parse(item.propertis) if (prop?.color) { - content.color = prop.color; + content.color = typeof prop.color === 'string' + ? parseInt(prop.color, 10) + : prop.color; } if (prop?.pos) { const pos = prop.pos; @@ -617,4 +685,4 @@ export default class YoukuSource extends BaseSource { return content; }); } -} \ No newline at end of file +} diff --git a/danmu_api/ui/css/forms.css.js b/danmu_api/ui/css/forms.css.js index c4f8a25..475ffc9 100644 --- a/danmu_api/ui/css/forms.css.js +++ b/danmu_api/ui/css/forms.css.js @@ -315,4 +315,4 @@ input:checked + .slider:before { background: #e8eaf6 !important; border-color: #667eea !important; } -`; +`; \ No newline at end of file diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index 4b80287..193a294 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -7,7 +7,7 @@ const apiConfigs = { method: 'GET', path: '/api/v2/search/anime', params: [ - { name: 'keyword', label: '关键词', type: 'text', required: true, placeholder: '示例: 生万物' } + { name: 'keyword', label: '关键词 或 播放链接URL', type: 'text', required: true, placeholder: '示例: 生万物 或 http://v.qq.com/x/cover/rjae621myqca41h/j0032ubhl9s.html' } ] }, searchEpisodes: { @@ -40,8 +40,19 @@ const apiConfigs = { path: '/api/v2/comment/:commentId', params: [ { name: 'commentId', label: '弹幕ID', type: 'text', required: true, placeholder: '示例: 10009' }, - { name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] } + { name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] }, + { name: 'segmentflag', label: '分片标志', type: 'select', required: false, placeholder: '可选: true或false', options: ['true', 'false'] } ] + }, + getSegmentComment: { + name: '获取分片弹幕', + method: 'POST', + path: '/api/v2/segmentcomment', + params: [ + { name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] } + ], + hasBody: true, + bodyType: 'json' } }; @@ -60,37 +71,58 @@ function loadApiParams() { const config = apiConfigs[apiKey]; paramsDiv.style.display = 'block'; - if (config.params.length === 0) { - formDiv.innerHTML = '<p class="text-gray">此接口无需参数</p>'; - return; - } + let html = ''; - formDiv.innerHTML = config.params.map(param => { - if (param.type === 'select') { - // 为select类型参数添加默认选项 - let optionsHtml = '<option value="">-- 请选择 --</option>'; - if (param.options) { - optionsHtml += param.options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join(''); + // 添加查询参数部分 + if (config.params && config.params.length > 0) { + html += '<div class="params-section">'; + html += '<h4>查询参数</h4>'; + html += config.params.map(param => { + if (param.type === 'select') { + // 为select类型参数添加默认选项 + let optionsHtml = '<option value="">-- 请选择 --</option>'; + if (param.options) { + optionsHtml += param.options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join(''); + } + return \` + <div class="form-group"> + <label>\${param.label}\${param.required ? ' *' : ''}</label> + <select id="param-\${param.name}"> + \${optionsHtml} + </select> + \${param.placeholder ? \`<div class="form-help">\${param.placeholder}</div>\` : ''} + </div> + \`; } + // 使用placeholder属性显示示例参数 + const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label; return \` <div class="form-group"> <label>\${param.label}\${param.required ? ' *' : ''}</label> - <select id="param-\${param.name}"> - \${optionsHtml} - </select> - \${param.placeholder ? \`<div class="form-help">\${param.placeholder}</div>\` : ''} + <input type="\${param.type}" id="param-\${param.name}" placeholder="\${placeholder}" \${param.required ? 'required' : ''}> </div> \`; - } - // 使用placeholder属性显示示例参数 - const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label; - return \` - <div class="form-group"> - <label>\${param.label}\${param.required ? ' *' : ''}</label> - <input type="\${param.type}" id="param-\${param.name}" placeholder="\${placeholder}" \${param.required ? 'required' : ''}> - </div> - \`; - }).join(''); + }).join(''); + html += '</div>'; + } + + // 添加请求体部分(如果接口有请求体) + if (config.hasBody) { + html += '<div class="body-section">'; + html += '<h4>请求体</h4>'; + html += \`<div class="form-group"> + <label>请求体内容 * (JSON格式)</label> + <textarea id="body-content" rows="6" placeholder='输入JSON格式的请求体,例如:{"type": "qq","segment_start":0,"segment_end":30000,"url":"https://dm.video.qq.com/barrage/segment/j0032ubhl9s/t/v1/0/30000"}'></textarea> + <div class="form-help">输入JSON格式的请求体内容</div> + </div>\`; + html += '</div>'; + } + + if (!html) { + html = '<p class="text-gray">此接口无需参数</p>'; + } + + formDiv.innerHTML = html; } function testApi() { @@ -111,10 +143,13 @@ function testApi() { const config = apiConfigs[apiKey]; const params = {}; - config.params.forEach(param => { - const value = document.getElementById(\`param-\${param.name}\`).value; - if (value) params[param.name] = value; - }); + // 获取查询参数 + if (config.params) { + config.params.forEach(param => { + const value = document.getElementById(\`param-\${param.name}\`).value; + if (value) params[param.name] = value; + }); + } addLog(\`调用接口: \${config.name} (\${config.method} \${config.path})\`, 'info'); addLog(\`请求参数: \${JSON.stringify(params)}\`, 'info'); @@ -168,7 +203,36 @@ function testApi() { } }; - if (config.method === 'POST') { + // 处理请求体 + if (config.hasBody) { + // 从请求体输入框获取内容 + const bodyContent = document.getElementById('body-content').value; + if (bodyContent) { + try { + // 尝试解析用户输入的JSON + const bodyData = JSON.parse(bodyContent); + requestOptions.body = JSON.stringify(bodyData); + } catch (e) { + addLog('请求体JSON格式错误: ' + e.message, 'error'); + sendButton.innerHTML = originalText; + sendButton.disabled = false; + return; + } + } else { + // 如果没有在请求体输入框中输入内容,则使用参数构建请求体(向后兼容) + if (apiKey === 'getSegmentComment') { + // 对于 getSegmentComment 接口,创建 segment 对象 + const segmentData = { url: params.url }; + if (params.format) { + segmentData.format = params.format; + } + requestOptions.body = JSON.stringify(segmentData); + } else { + requestOptions.body = JSON.stringify(params); + } + } + } else if (config.method === 'POST') { + // 对于没有特殊请求体配置的POST接口,使用参数构建请求体 requestOptions.body = JSON.stringify(params); } diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js index ad325e8..32453a8 100644 --- a/danmu_api/ui/template.js +++ b/danmu_api/ui/template.js @@ -85,12 +85,13 @@ export const HTML_TEMPLATE = /* html */ ` <div class="form-group"> <label>选择接口</label> <select id="api-select" onchange="loadApiParams()"> - <option value="">请选择接口</option> + <option value="">-- 请选择接口 --</option> <option value="searchAnime">搜索动漫 - /api/v2/search/anime</option> <option value="searchEpisodes">搜索剧集 - /api/v2/search/episodes</option> <option value="matchAnime">匹配动漫 - /api/v2/match</option> <option value="getBangumi">获取番剧详情 - /api/v2/bangumi/:animeId</option> <option value="getComment">获取弹幕 - /api/v2/comment/:commentId</option> + <option value="getSegmentComment">获取分片弹幕 - /api/v2/segmentcomment</option> </select> </div> </div> diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index 4573476..90a1dc1 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -255,6 +255,11 @@ export function validateType(value, expectedType) { if (!Array.isArray(value)) { throw new TypeError(`${value} 必须是一个数组,但传入的是 ${fieldName}`); } + } else if (expectedType === "boolean") { + // 对于 boolean 类型,允许任何可转换为布尔值的类型(number, boolean) + if (typeof value !== "boolean" && typeof value !== "number") { + throw new TypeError(`${value} 必须是 boolean 或 number,但传入的是 ${fieldName}`); + } } else if (typeof value !== expectedType) { throw new TypeError(`${value} 必须是 ${expectedType},但传入的是 ${fieldName}`); } diff --git a/danmu_api/worker.js b/danmu_api/worker.js index affcf17..624b6f1 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -4,9 +4,10 @@ import { log, formatLogMessage } from './utils/log-util.js' import { getRedisCaches, judgeRedisValid } from "./utils/redis-util.js"; import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js"; import { formatDanmuResponse } from "./utils/danmu-util.js"; -import { getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js"; +import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js"; import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache } from "./apis/system-api.js"; import { handleSetEnv, handleAddEnv, handleDelEnv } from "./apis/env-api.js"; +import { Segment } from "./models/dandan-model.js" let globals; @@ -177,6 +178,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) { if (path.startsWith("/api/v2/comment") && method === "GET") { const queryFormat = url.searchParams.get('format'); const videoUrl = url.searchParams.get('url'); + const segmentFlag = url.searchParams.get('segmentflag'); // ⚠️ 限流设计说明: // 1. 先检查缓存,缓存命中时直接返回,不计入限流次数 @@ -225,7 +227,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) { } // 通过URL获取弹幕 - return getCommentByUrl(videoUrl, queryFormat); + return getCommentByUrl(videoUrl, queryFormat, segmentFlag); } // 否则通过commentId获取弹幕 @@ -285,7 +287,37 @@ async function handleRequest(req, env, deployPlatform, clientIp) { log("info", `[Rate Limit] IP ${clientIp} request count: ${recentRequests.length}/${globals.rateLimitMaxRequests}`); } - return getComment(path, queryFormat); + return getComment(path, queryFormat, segmentFlag); + } + + // POST /api/v2/segmentcomment - 接收segment类的JSON请求体 + if (path.startsWith("/api/v2/segmentcomment") && method === "POST") { + try { + const queryFormat = url.searchParams.get('format'); + // 从请求体获取segment数据 + const requestBody = await req.json(); + let segment; + + // 尝试解析JSON + try { + segment = Segment.fromJson(requestBody); + } catch (e) { + log("error", "Invalid JSON in request body for segment"); + return jsonResponse( + { errorCode: 400, success: false, errorMessage: "Invalid JSON in request body" }, + 400 + ); + } + + // 通过URL和平台获取分段弹幕 + return getSegmentComment(segment, queryFormat); + } catch (error) { + log("error", `Error processing segmentcomment request: ${error.message}`); + return jsonResponse( + { errorCode: 500, success: false, errorMessage: "Internal server error" }, + 500 + ); + } } // GET /api/logs diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 5ce5d0b..0247eaa 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -24,6 +24,7 @@ import { VercelHandler } from "./configs/handlers/vercel-handler.js"; import { NetlifyHandler } from "./configs/handlers/netlify-handler.js"; import { CloudflareHandler } from "./configs/handlers/cloudflare-handler.js"; import { EdgeoneHandler } from "./configs/handlers/edgeone-handler.js"; +import { Segment } from "./models/dandan-model.js" // Mock Request class for testing class MockRequest { @@ -65,8 +66,6 @@ test('worker.js API endpoints', async (t) => { const body = await parseResponse(res); assert.equal(res.status, 200); - assert.equal(res.headers.get('Content-Type'), 'application/json'); - assert.deepEqual(body.message, 'Welcome to the LogVar Danmu API server'); }); // 测试标题解析 @@ -92,30 +91,118 @@ test('worker.js API endpoints', async (t) => { }); // await t.test('GET tencent danmu', async () => { - // const res = await tencentSource.getComments("http://v.qq.com/x/cover/rjae621myqca41h/j0032ubhl9s.html"); + // const res = await tencentSource.getComments("http://v.qq.com/x/cover/rjae621myqca41h/j0032ubhl9s.html", "qq"); + // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); + // }); + + // await t.test('GET tencent danmu segments', async () => { + // const res = await tencentSource.getComments("http://v.qq.com/x/cover/rjae621myqca41h/j0032ubhl9s.html", "qq", true); + // assert(res.type === "qq", `Expected res.type === "qq", but got ${res.type === "qq"}`); + // assert(res.segmentList.length > 2, `Expected res.segmentList.length > 2, but got ${res.length}`); + // }); + + // await t.test('GET tencent segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "qq", + // "segment_start": 0, + // "segment_end": 60, + // "url": "https://dm.video.qq.com/barrage/segment/j0032ubhl9s/t/v1/30000/60000" + // }); + // const res = await tencentSource.getSegmentComments(segment); // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); // await t.test('GET iqiyi danmu', async () => { - // const res = await iqiyiSource.getComments("https://www.iqiyi.com/v_1ftv9n1m3bg.html"); + // const res = await iqiyiSource.getComments("https://www.iqiyi.com/v_1ftv9n1m3bg.html", "qiyi"); + // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); + // }); + + // await t.test('GET iqiyi danmu segments', async () => { + // const res = await iqiyiSource.getComments("https://www.iqiyi.com/v_1ftv9n1m3bg.html", "qiyi", true); + // assert(res.type === "qiyi", `Expected res.type === "qiyi", but got ${res.type === "qiyi"}`); + // assert(res.segmentList.length > 2, `Expected res.segmentList.length > 2, but got ${res.length}`); + // }); + + // await t.test('GET iqiyi segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "qiyi", + // "segment_start": 0, + // "segment_end": 60, + // "url": "https://cmts.iqiyi.com/bullet/80/00/5284367795028000_300_4.z?rn=0.0123456789123456&business=danmu&is_iqiyi=true&is_video_page=true&tvid=5284367795028000&albumid=2524115110632101&categoryid=2&qypid=010102101000000000" + // }); + // const res = await iqiyiSource.getSegmentComments(segment); // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); // await t.test('GET mango danmu', async () => { - // const res = await mangoSource.getComments("https://www.mgtv.com/b/771610/23300622.html"); + // const res = await mangoSource.getComments("https://www.mgtv.com/b/771610/23300622.html", "imgo"); + // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); + // }); + + // await t.test('GET mango danmu segments', async () => { + // const res = await mangoSource.getComments("https://www.mgtv.com/b/771610/23300622.html", "imgo", true); + // assert(res.type === "imgo", `Expected res.type === "imgo", but got ${res.type}`); + // assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`); + // }); + + // await t.test('GET mango segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "imgo", + // "segment_start": 0, + // "segment_end": 60, + // "url": "https://bullet-ali.hitv.com/bullet/tx/2025/12/14/011640/23300622/23.json" + // }); + // const res = await mangoSource.getSegmentComments(segment); // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); // await t.test('GET bilibili danmu', async () => { - // const res = await bilibiliSource.getComments("https://www.bilibili.com/bangumi/play/ep1231564"); + // const res = await bilibiliSource.getComments("https://www.bilibili.com/bangumi/play/ep1231564", "bilibili1"); // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); + // await t.test('GET bilibili danmu segments', async () => { + // const res = await bilibiliSource.getComments("https://www.bilibili.com/bangumi/play/ep1231564", "bilibili1", true); + // assert(res.type === "bilibili1", `Expected res.type === "bilibili1", but got ${res.type}`); + // assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`); + // }); + + // await t.test('GET bilibili segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "bilibili1", + // "segment_start": 0, + // "segment_end": 60, + // "url": "https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=32131450212&segment_index=2" + // }); + // const res = await bilibiliSource.getSegmentComments(segment); + // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); + // }); + // await t.test('GET youku danmu', async () => { // const res = await youkuSource.getComments("https://v.youku.com/v_show/id_XNjQ3ODMyNjU3Mg==.html"); // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); + // await t.test('GET youku danmu segments', async () => { + // const res = await youkuSource.getComments("https://v.youku.com/v_show/id_XNjQ3ODMyNjU3Mg==.html", "youku", true); + // assert(res.type === "youku", `Expected res.type === "youku", but got ${res.type === "youku"}`); + // assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`); + // }); + + // await t.test('GET youku segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "youku", + // "segment_start": 0, + // "segment_end": 60, + // "url": "https://acs.youku.com/h5/mopen.youku.danmu.list/1.0/?jsv=2.5.6&appKey=24679788&t=1765980205381&sign=355caad7d41ec0bf445cce48fce4d93e&api=mopen.youku.danmu.list&v=1.0&type=originaljson&dataType=jsonp&timeout=20000&jsonpIncPrefix=utility", + // "data": "{\"ctime\":1765980205380,\"ctype\":10004,\"cver\":\"v1.0\",\"guid\":\"JqbJIT/Q0XMCAXPAGpb9gBcg\",\"mat\":0,\"mcount\":1,\"pid\":0,\"sver\":\"3.1.0\",\"type\":1,\"vid\":\"XNjQ3ODMyNjU3Mg==\",\"msg\":\"eyJjdGltZSI6MTc2NTk4MDIwNTM4MCwiY3R5cGUiOjEwMDA0LCJjdmVyIjoidjEuMCIsImd1aWQiOiJKcWJKSVQvUTBYTUNBWFBBR3BiOWdCY2ciLCJtYXQiOjAsIm1jb3VudCI6MSwicGlkIjowLCJzdmVyIjoiMy4xLjAiLCJ0eXBlIjoxLCJ2aWQiOiJYTmpRM09ETXlOalUzTWc9PSJ9\",\"sign\":\"b94e1d2cf6dc1ffcf80845b0ea82b7ef\"}", + // "_m_h5_tk": "d12df59d06f2830de1c681e04285a895_1765985058907", + // "_m_h5_tk_enc": "082c6cbbad97b5b48b7798a51933bbfa" + // }); + // const res = await youkuSource.getSegmentComments(segment); + // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); + // }); + // 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}`); @@ -141,6 +228,24 @@ test('worker.js API endpoints', async (t) => { // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); // }); + // await t.test('GET hanjutv danmu segments', async () => { + // const res = await hanjutvSource.getComments("12tY0Ktjzu5TCBrfTolNO", "hanjutv", true); + // console.log(res); + // assert(res.type === "hanjutv", `Expected res.type === "hanjutv", but got ${res.type === "hanjutv"}`); + // assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`); + // }); + + // await t.test('GET hanjutv segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "hanjutv", + // "segment_start": 0, + // "segment_end": 30000, + // "url": "12tY0Ktjzu5TCBrfTolNO" + // }); + // const res = await hanjutvSource.getSegmentComments(segment); + // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); + // }); + // await t.test('GET bahamut search', async () => { // const res = await bahamutSource.search("胆大党"); // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); @@ -156,6 +261,24 @@ test('worker.js API endpoints', async (t) => { // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`); // }); + // await t.test('GET bahamut danmu segments', async () => { + // const res = await bahamutSource.getComments("44453", "bahamut", true); + // console.log(res); + // assert(res.type === "bahamut", `Expected res.type === "bahamut", but got ${res.type === "bahamut"}`); + // assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`); + // }); + + // await t.test('GET bahamut segment danmu', async () => { + // const segment = Segment.fromJson({ + // "type": "bahamut", + // "segment_start": 0, + // "segment_end": 30000, + // "url": "44453" + // }); + // const res = await bahamutSource.getSegmentComments(segment); + // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); + // }); + // await t.test('GET realistic danmu', async () => { // // tencent // // const keyword = "子夜归"; @@ -478,4 +601,4 @@ test('worker.js API endpoints', async (t) => { // const res = await handler.deploy(); // assert(res, `Expected res is true, but got ${res}`); // }); -}); \ No newline at end of file +}); diff --git a/forward/custom-polyfill.js b/forward/custom-polyfill.js new file mode 100644 index 0000000..9909ae0 --- /dev/null +++ b/forward/custom-polyfill.js @@ -0,0 +1,361 @@ +// 本插件从项目 https://github.com/huangxd-/danmu_api.git 一键导出,配置参数使用说明请参考该项目README + +class URL { + constructor(url, base) { + if (base) { + // 如果提供了基础URL,拼接相对路径 + if (url.startsWith('/')) { + // 处理绝对路径形式 + const baseWithoutPath = base.replace(/\/[^\/]*$/, ''); + this._url = baseWithoutPath + url; + } else if (url.startsWith('./') || !url.startsWith('http')) { + // 处理相对路径 + const baseWithoutPath = base.replace(/\/[^\/]*$/, ''); + this._url = baseWithoutFile + '/' + url.replace('./', ''); + } else { + this._url = url; + } + } else { + this._url = url; + } + + // 解析URL组件 + this.parseURL(this._url); + } + + parseURL(url) { + // 基础URL解析逻辑 + const match = url.match(/^([^:]+):\/\/([^\/]+)(.*)$/); + if (match) { + this.protocol = match[1] + ':'; + this.hostname = match[2]; + const pathAndQuery = match[3] || ''; + const queryIndex = pathAndQuery.indexOf('?'); + + if (queryIndex !== -1) { + this.pathname = pathAndQuery.substring(0, queryIndex); + this.search = pathAndQuery.substring(queryIndex); + } else { + this.pathname = pathAndQuery; + this.search = ''; + } + } else { + this.protocol = ''; + this.hostname = ''; + this.pathname = url; + this.search = ''; + } + } + + toString() { + return this._url; + } + + static createObjectURL(obj) { + // 简单的模拟实现 + return 'blob:' + Date.now(); + } + + static revokeObjectURL(url) { + // 简单的模拟实现 + } + + get href() { + return this._url; + } + + get origin() { + return this.protocol + '//' + this.hostname; + } + + get host() { + return this.hostname; + } + + get searchParams() { + // 创建一个简单的SearchParams实现 + const paramsString = this.search.substring(1); // 移除开头的? + const params = new (function() { + const entries = {}; + if (paramsString) { + paramsString.split('&').forEach(pair => { + const [key, value] = pair.split('='); + if (key) { + entries[decodeURIComponent(key)] = decodeURIComponent(value || ''); + } + }); + } + + this.get = (name) => entries[name] || null; + this.set = (name, value) => { entries[name] = value.toString(); }; + this.toString = () => Object.keys(entries).map(key => + encodeURIComponent(key) + '=' + encodeURIComponent(entries[key]) + ).join('&'); + })(); + return params; + } +} + +class AbortController { + constructor() { + this.signal = new AbortSignal(); + } + + abort() { + this.signal.abort(); + } +} + +class AbortSignal { + constructor() { + this.aborted = false; + this.onabort = null; + this.listeners = []; + } + + abort() { + if (this.aborted) return; + + this.aborted = true; + + // 触发所有监听器 + this.listeners.forEach(listener => { + try { + if (typeof listener === 'function') { + listener({ type: 'abort' }); + } else if (listener && typeof listener.handleEvent === 'function') { + listener.handleEvent({ type: 'abort' }); + } + } catch (e) { + // 忽略监听器中的错误 + } + }); + + // 触发onabort回调 + if (this.onabort) { + try { + this.onabort({ type: 'abort' }); + } catch (e) { + // 忽略onabort回调中的错误 + } + } + } + + addEventListener(type, listener) { + if (type === 'abort') { + this.listeners.push(listener); + // 如果已经中止,立即触发监听器 + if (this.aborted) { + try { + if (typeof listener === 'function') { + listener({ type: 'abort' }); + } else if (listener && typeof listener.handleEvent === 'function') { + listener.handleEvent({ type: 'abort' }); + } + } catch (e) { + // 忽略监听器中的错误 + } + } + } + } + + removeEventListener(type, listener) { + if (type === 'abort') { + const index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + } + } + } + + dispatchEvent(event) { + if (event.type === 'abort') { + this.abort(); + } + } +} + +const { setTimeout: customSetTimeout, clearTimeout: customClearTimeout } = (function() { + let timerId = 0; + const timers = new Map(); + + const setTimeoutFn = function(callback, delay = 0) { + const id = ++timerId; + + if (typeof Promise !== 'undefined') { + Promise.resolve().then(() => { + if (timers.has(id)) { + try { + callback(); + } catch (e) { + console.error('setTimeout error:', e); + } finally { + timers.delete(id); + } + } + }); + } else { + // 同步执行 + try { + callback(); + } catch (e) { + console.error('setTimeout error:', e); + } + } + + timers.set(id, { callback, delay, timestamp: Date.now() }); + return id; + }; + + const clearTimeoutFn = function(id) { + return timers.delete(id); + }; + + return { + setTimeout: setTimeoutFn, + clearTimeout: clearTimeoutFn + }; +})(); + +const setTimeout = customSetTimeout; +const clearTimeout = customClearTimeout; + +class Headers { + constructor(init = {}) { + this._headers = {}; + if (init instanceof Headers) { + // 从另一个Headers实例初始化 + for (const [key, value] of init.entries()) { + this.set(key, value); + } + } else if (Array.isArray(init)) { + // 从键值对数组初始化 + for (const [key, value] of init) { + this.set(key, value); + } + } else if (init && typeof init === 'object') { + // 从对象初始化 + for (const [key, value] of Object.entries(init)) { + this.set(key, value); + } + } + } + + append(name, value) { + name = name.toLowerCase(); + if (this._headers[name]) { + this._headers[name] = this._headers[name] + ', ' + value; + } else { + this._headers[name] = value; + } + } + + delete(name) { + delete this._headers[name.toLowerCase()]; + } + + get(name) { + return this._headers[name.toLowerCase()] || null; + } + + has(name) { + return name.toLowerCase() in this._headers; + } + + set(name, value) { + this._headers[name.toLowerCase()] = String(value); + } + + forEach(callback, thisArg) { + for (const [name, value] of Object.entries(this._headers)) { + callback.call(thisArg, value, name, this); + } + } + + *entries() { + for (const [name, value] of Object.entries(this._headers)) { + yield [name, value]; + } + } + + *keys() { + for (const name of Object.keys(this._headers)) { + yield name; + } + } + + *values() { + for (const value of Object.values(this._headers)) { + yield value; + } + } + + [Symbol.iterator]() { + return this.entries(); + } + + toJSON() { + return { ...this._headers }; + } +} + +class Response { + constructor(body, init = {}) { + this.status = init.status || 200; + this.statusText = init.statusText || 'OK'; + this.headers = new Headers(init.headers || {}); + this.type = 'default'; + this.url = ''; + this.redirected = false; + + this._bodyUsed = false; + if (body !== undefined && body !== null) { + this._body = body; + } else { + this._body = ''; + } + } + + get ok() { + return this.status >= 200 && this.status < 300; + } + + get bodyUsed() { + return this._bodyUsed; + } + + _checkBodyUsed() { + if (this._bodyUsed) { + throw new TypeError('body stream already read'); + } + this._bodyUsed = true; + } + + async json() { + this._checkBodyUsed(); + if (typeof this._body === 'string') { + return JSON.parse(this._body); + } + return this._body; + } + + async text() { + this._checkBodyUsed(); + if (typeof this._body === 'string') { + return this._body; + } + return String(this._body); + } + + clone() { + if (this._bodyUsed) { + throw new TypeError('cannot clone a disturbed response'); + } + const cloned = new Response(this._body, { + status: this.status, + statusText: this.statusText, + headers: this.headers + }); + return cloned; + } +} diff --git a/forward/forward-widget.js b/forward/forward-widget.js new file mode 100644 index 0000000..8dfab98 --- /dev/null +++ b/forward/forward-widget.js @@ -0,0 +1,669 @@ +import { searchAnime, getBangumi, getComment, getSegmentComment, matchSeason } from '../danmu_api/apis/dandan-api.js'; +import { Globals } from '../danmu_api/configs/globals.js'; +import { log } from '../danmu_api/utils/log-util.js'; + +const wv = typeof widgetVersion !== 'undefined' ? widgetVersion : Globals.VERSION; + +WidgetMetadata = { + id: "forward.auto.danmu2", + title: "自动链接弹幕v2", + version: wv, + requiredVersion: "0.0.2", + description: "自动获取播放链接并从服务器获取弹幕【五折码:CHEAP.5;七折码:CHEAP】", + author: "huangxd", + site: "https://github.com/huangxd-/ForwardWidgets", + globalParams: [ + // 源配置 + { + name: "sourceOrder", + title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan']", + type: "input", + placeholders: [ + { + title: "配置1", + value: "tencent,iqiyi,imgo,bilibili,youku,renren,hanjutv", + }, + { + title: "配置2", + value: "douban,360,vod,renren,hanjutv", + }, + { + title: "配置3", + value: "360,vod,renren,hanjutv", + }, + { + title: "配置4", + value: "vod,360,renren,hanjutv,bahamut,dandan", + }, + ], + }, + { + name: "otherServer", + title: "第三方弹幕服务器,默认https://api.danmu.icu", + type: "input", + placeholders: [ + { + title: "icu", + value: "https://api.danmu.icu", + }, + { + title: "lyz05", + value: "https://fc.lyz05.cn", + }, + { + title: "hls", + value: "https://dmku.hls.one", + }, + { + title: "678", + value: "https://se.678.ooo", + }, + { + title: "56uxi", + value: "https://danmu.56uxi.com", + }, + { + title: "lxlad", + value: "https://dm.lxlad.com", + }, + ], + }, + { + name: "vodServers", + title: "VOD站点配置,格式:名称@URL,名称@URL,默认金蝉'https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top'", + type: "input", + placeholders: [ + { + title: "配置1", + value: "金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top", + }, + { + title: "配置2", + value: "金蝉@https://zy.jinchancaiji.com", + }, + { + title: "配置3", + value: "金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou", + }, + { + title: "配置4", + value: "金蝉@https://zy.jinchancaiji.com,听风@https://gctf.tfdh.top", + }, + ], + }, + { + name: "vodReturnMode", + title: "VOD返回模式:all(所有站点)或 fastest(最快的站点),默认fastest", + type: "input", + placeholders: [ + { + title: "fastest", + value: "fastest", + }, + { + title: "all", + value: "all", + }, + ], + }, + { + name: "vodRequestTimeout", + title: "VOD请求超时时间,默认10000", + type: "input", + placeholders: [ + { + title: "10s", + value: "10000", + }, + { + title: "15s", + value: "15000", + }, + { + title: "20s", + value: "20000", + }, + ], + }, + { + name: "bilibiliCookie", + title: "B站Cookie(填入后能抓取b站完整弹幕)", + type: "input", + placeholders: [ + { + title: "示例", + value: "SESSDATA=xxxx", + }, + ], + }, + + // 匹配配置 + { + name: "platformOrder", + title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan']", + type: "input", + placeholders: [ + { + title: "配置1", + value: "qq,qiyi,imgo,bilibili1,youku,renren,hanjutv,bahamut,dandan", + }, + { + title: "配置2", + value: "bilibili1,qq,qiyi,imgo", + }, + { + title: "配置3", + value: "dandan,bilibili1,bahamut", + }, + { + title: "配置4", + value: "imgo,qiyi,qq,youku,bilibili1", + }, + ], + }, + { + name: "episodeTitleFilter", + title: "剧集标题过滤规则", + type: "input", + placeholders: [ + { + title: "示例", + value: "(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘", + }, + ], + }, + { + name: "enableEpisodeFilter", + title: "集标题过滤开关,是否在手动选择接口中启用集标题过滤,默认false", + type: "input", + placeholders: [ + { + title: "false", + value: "false", + }, + { + title: "true", + value: "true", + }, + ], + }, + { + name: "strictTitleMatch", + title: "严格标题匹配模式,默认false", + type: "input", + placeholders: [ + { + title: "false", + value: "false", + }, + { + title: "true", + value: "true", + }, + ], + }, + + // 弹幕配置 + { + name: "blockedWords", + title: "屏蔽词列表", + type: "input", + placeholders: [ + { + title: "示例", + value: "/.{20,}/,/^\\d{2,4}[-/.]\\d{1,2}[-/.]\\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\u4e00-\u9fa5])\\1{2,}/,/[0-9]+\\.*[0-9]*\\s*(w|万)+\\s*(\\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\\d{5,}$/,/^(\\d)\\1{2,}$/,/\\d{1,4}/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\\d{1,2}[.-]\\d{1,2}/,/[@#&$%^*+\\|/\\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\\d]+刷/,/第[一二三四五六七八九十百\\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/", + }, + ], + }, + { + name: "groupMinute", + title: "合并去重分钟数,表示按n分钟分组后对弹幕合并去重", + type: "input", + placeholders: [ + { + title: "1分钟", + value: "1", + }, + { + title: "2分钟", + value: "2", + }, + { + title: "5分钟", + value: "5", + }, + { + title: "10分钟", + value: "10", + }, + { + title: "20分钟", + value: "20", + }, + { + title: "30分钟", + value: "30", + }, + ], + }, + { + name: "danmuLimit", + title: "弹幕数量限制,单位为k,即千:默认0,表示不限制弹幕数", + type: "input", + placeholders: [ + { + title: "不限制", + value: "0", + }, + { + title: "10k", + value: "10", + }, + { + title: "8k", + value: "8", + }, + { + title: "6k", + value: "6", + }, + { + title: "4k", + value: "4", + }, + { + title: "2k", + value: "2", + }, + ], + }, + { + name: "danmuSimplified", + title: "弹幕繁体转简体开关,目前只对巴哈姆特生效,默认true", + type: "input", + placeholders: [ + { + title: "true", + value: "true", + }, + { + title: "false", + value: "false", + }, + ], + }, + { + name: "convertTopBottomToScroll", + title: "顶部/底部弹幕转换为浮动弹幕,默认false", + type: "input", + placeholders: [ + { + title: "false", + value: "false", + }, + { + title: "true", + value: "true", + }, + ], + }, + { + name: "convertColor", + title: "弹幕转换颜色配置,默认default(不转换)", + type: "input", + placeholders: [ + { + title: "不转换", + value: "default", + }, + { + title: "白色", + value: "white", + }, + { + title: "随机颜色(包括白色)", + value: "color", + }, + ], + }, + + // 系统配置 + { + name: "proxyUrl", + title: "代理/反代地址,目前只对巴哈姆特和TMDB API生效", + type: "input", + placeholders: [ + { + title: "如果添加了巴哈源且访问不了,请填写", + value: "", + }, + { + title: "正常代理示例", + value: "http://127.0.0.1:7890", + }, + { + title: "万能反代示例", + value: "@http://127.0.0.1", + }, + { + title: "特定反代示例1", + value: "bahamut@http://127.0.0.1", + }, + { + title: "特定反代示例2", + value: "tmdb@http://127.0.0.1", + }, + ], + }, + { + name: "tmdbApiKey", + title: "TMDB API密钥,目前只对巴哈姆特生效,配置后并行从TMDB获取日语原名搜索巴哈", + type: "input", + placeholders: [ + { + title: "如果添加了巴哈源,想自动获取日语原名搜索巴哈,请填写", + value: "", + }, + { + title: "示例", + value: "a1b2xxxxxxxxxxxxxxxxxxx", + }, + ], + } + ], + modules: [ + { + id: "searchDanmu", + title: "搜索弹幕", + functionName: "searchDanmu", + type: "danmu", + params: [], + }, + { + id: "getDetail", + title: "获取详情", + functionName: "getDetailById", + type: "danmu", + params: [], + }, + { + id: "getComments", + title: "获取弹幕", + functionName: "getCommentsById", + type: "danmu", + params: [], + }, + { + id: "getDanmuWithSegmentTime", + title: "获取指定时刻弹幕", + functionName: "getDanmuWithSegmentTime", + type: "danmu", + params: [], + } + ], +}; + +// 在浏览器环境中设置全局变量(ForwardWidget系统使用) +if (typeof window !== 'undefined') { + window.WidgetMetadata = WidgetMetadata; +} + +// 初始化全局配置 +let globals; +async function initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey) { + // 将传入的参数设置到环境变量中,以便Globals可以访问它们 + const env = {}; + + if (sourceOrder !== undefined) env.SOURCE_ORDER = sourceOrder; + if (otherServer !== undefined) env.OTHER_SERVER = otherServer; + if (vodServers !== undefined) env.VOD_SERVERS = vodServers; + if (vodReturnMode !== undefined) env.VOD_RETURN_MODE = vodReturnMode; + if (vodRequestTimeout !== undefined) env.VOD_REQUEST_TIMEOUT = vodRequestTimeout; + if (bilibiliCookie !== undefined) env.BILIBILI_COOKIE = bilibiliCookie; + if (platformOrder !== undefined) env.PLATFORM_ORDER = platformOrder; + if (episodeTitleFilter !== undefined) env.EPISODE_TITLE_FILTER = episodeTitleFilter; + if (enableEpisodeFilter !== undefined) env.ENABLE_EPISODE_FILTER = enableEpisodeFilter; + if (strictTitleMatch !== undefined) env.STRICT_TITLE_MATCH = strictTitleMatch; + if (blockedWords !== undefined) env.BLOCKED_WORDS = blockedWords; + if (groupMinute !== undefined) env.GROUP_MINUTE = groupMinute; + if (danmuLimit !== undefined) env.DANMU_LIMIT = danmuLimit; + if (danmuSimplified !== undefined) env.DANMU_SIMPLIFIED = danmuSimplified; + if (convertTopBottomToScroll !== undefined) env.CONVERT_TOP_BOTTOM_TO_SCROLL = convertTopBottomToScroll; + if (convertColor !== undefined) env.CONVERT_COLOR = convertColor; + if (proxyUrl !== undefined) env.PROXY_URL = proxyUrl; + if (tmdbApiKey !== undefined) env.TMDB_API_KEY = tmdbApiKey; + + if (!globals) { + globals = Globals.init(env); + } + + await getCaches(); + + return globals; +} + +// 获取变量数据 +async function getCaches() { + if (globals.animes.length === 0) { + log("info", 'getCaches start.'); + const [kv_animes, kv_episodeIds, kv_episodeNum, kv_logBuffer, kv_lastSelectMap] = await Promise.all([ + Widget.storage.get('animes'), + Widget.storage.get('episodeIds'), + Widget.storage.get('episodeNum'), + Widget.storage.get('logBuffer'), + Widget.storage.get('lastSelectMap'), + ]); + + globals.animes = kv_animes ? (typeof kv_animes === 'string' ? JSON.parse(kv_animes) : kv_animes) : globals.animes; + globals.episodeIds = kv_episodeIds ? (typeof kv_episodeIds === 'string' ? JSON.parse(kv_episodeIds) : kv_episodeIds) : globals.episodeIds; + globals.episodeNum = kv_episodeNum ? (typeof kv_episodeNum === 'string' ? JSON.parse(kv_episodeNum) : kv_episodeNum) : globals.episodeNum; + globals.logBuffer = kv_logBuffer ? (typeof kv_logBuffer === 'string' ? JSON.parse(kv_logBuffer) : kv_logBuffer) : globals.logBuffer; + + // 特殊处理 Map + if (kv_lastSelectMap) { + const parsed = typeof kv_lastSelectMap === 'string' ? JSON.parse(kv_lastSelectMap) : kv_lastSelectMap; + globals.lastSelectMap = new Map( + Array.isArray(parsed) ? parsed : Object.entries(parsed) + ); + } + } +} + +// 存储更新后的变量 +async function updateCaches() { + log("info", 'updateCaches start.'); + await Promise.all([ + Widget.storage.set('animes', globals.animes), + Widget.storage.set('episodeIds', globals.episodeIds), + Widget.storage.set('episodeNum', globals.episodeNum), + Widget.storage.set('logBuffer', globals.logBuffer), + Widget.storage.set('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap))) + ]); +} + +// 删除存储的变量 +async function removeCaches() { + log("info", 'removeCaches start.'); + await Promise.all([ + Widget.storage.remove('animes'), + Widget.storage.remove('episodeIds'), + Widget.storage.remove('episodeNum'), + Widget.storage.remove('logBuffer'), + Widget.storage.remove('lastSelectMap') + ]); +} + +const PREFIX_URL = "http://localhost:9321" + +async function searchDanmu(params) { + const { tmdbId, type, title, season, link, videoUrl, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params; + + await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey); + + const response = await searchAnime(new URL(`${PREFIX_URL}/api/v2/search/anime?keyword=${title}`)); + const resJson = await response.json(); + const curAnimes = resJson.animes; + + // 开始排序数据,将匹配到季的数据挪到前面 + let animes = []; + if (curAnimes && curAnimes.length > 0) { + animes = curAnimes; + if (season) { + // order by season + const matchedAnimes = []; + const nonMatchedAnimes = []; + + animes.forEach((anime) => { + if (matchSeason(anime, title, season) && !(anime.animeTitle.includes("电影") || anime.animeTitle.includes("movie"))) { + matchedAnimes.push(anime); + } else { + nonMatchedAnimes.push(anime); + } + }); + + // Sort matched animes by title length (before first parenthesis) + matchedAnimes.sort((a, b) => { + const aLength = a.animeTitle.split('(')[0].length; + const bLength = b.animeTitle.split('(')[0].length; + return aLength - bLength; + }); + + // Combine matched and non-matched animes, with matched ones at the front + animes = [...matchedAnimes, ...nonMatchedAnimes]; + } else { + // order by type + const matchedAnimes = []; + const nonMatchedAnimes = []; + + animes.forEach((anime) => { + if (anime.animeTitle.includes("电影") || anime.animeTitle.includes("movie")) { + matchedAnimes.push(anime); + } else { + nonMatchedAnimes.push(anime); + } + }); + + // Sort matched animes by title length (before first parenthesis) + matchedAnimes.sort((a, b) => { + const aLength = a.animeTitle.split('(')[0].length; + const bLength = b.animeTitle.split('(')[0].length; + return aLength - bLength; + }); + + // Combine matched and non-matched animes, with matched ones at the front + animes = [...matchedAnimes, ...nonMatchedAnimes]; + } + } + + log("info", "animes: ", animes); + + await updateCaches(); + + return { + animes: animes, + }; +} + +async function getDetailById(params) { + const { animeId, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params; + + await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey); + + const response = await getBangumi(`${PREFIX_URL}/api/v2/bangumi/${animeId}`); + const resJson = await response.json(); + + log("info", "bangumi", resJson); + + await updateCaches(); + + return resJson.bangumi.episodes; +} + +async function getCommentsById(params) { + const { commentId, link, videoUrl, season, episode, tmdbId, type, title, segmentTime, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params; + + await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey); + + if (commentId) { + const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`; + const commentIdKey = `${storeKey}.${commentId}`; + const segmentList = Widget.storage.get(storeKey); + const lastCommentId = Widget.storage.get(commentIdKey); + + log("info", "storeKey:", storeKey); + log("info", "commentIdKey:", commentIdKey); + log("info", "commentId:", commentId); + log("info", "lastCommentId:", lastCommentId); + log("info", "segmentList:", segmentList); + + if (lastCommentId === commentId && segmentList) { + return await getDanmuWithSegmentTime({ segmentTime, tmdbId, season, episode, otherServer, vodServers, bilibiliCookie, sourceOrder, blockedWords, groupMinute, + vodReturnMode, vodRequestTimeout, platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey }) + } else { + Widget.storage.remove(storeKey); + Widget.storage.remove(commentIdKey); + } + + const response = await getComment(`${PREFIX_URL}/api/v2/comment/${commentId}`, "json", true); + const resJson = await response.json(); + + log("info", "segmentList:", resJson.comments.segmentList); + + Widget.storage.set(storeKey, resJson.comments.segmentList); + Widget.storage.set(commentIdKey, commentId); + + console.log("segmentList", resJson.comments.segmentList); + + await updateCaches(); + + return resJson.comments.segmentList; + } + return null; +} + +async function getDanmuWithSegmentTime(params) { + const { segmentTime, tmdbId, season, episode, sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params; + + await initGlobals(sourceOrder, otherServer, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie, + platformOrder, episodeTitleFilter, enableEpisodeFilter, strictTitleMatch, blockedWords, groupMinute, + danmuLimit, danmuSimplified, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey); + + const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`; + const segmentList = Widget.storage.get(storeKey); + if (segmentList) { + const segment = segmentList.find((item) => { + const start = Number(item.segment_start); + const end = Number(item.segment_end); + const time = Number(segmentTime); + return time >= start && time < end; + }); + log("info", "segment:", segment); + const response = await getSegmentComment(segment); + const resJson = await response.json(); + + await updateCaches(); + + return resJson; + } + return null; +} + +// 导出函数以供ForwardWidgets调用 +export { searchDanmu, getDetailById, getCommentsById, getDanmuWithSegmentTime }; diff --git a/forward/forward-widget.test.js b/forward/forward-widget.test.js new file mode 100644 index 0000000..fcfdb20 --- /dev/null +++ b/forward/forward-widget.test.js @@ -0,0 +1,307 @@ +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); + } +} + +async function testNewFlow() { + console.log('=== 测试新的弹幕获取链路 ===\n'); + console.log(`[配置] 详细输出: ${verbose ? '开启' : '关闭'}\n`); + + try { + const commonParams = { + type: 'tv', + tmdbId: '242762', + season: 1, + episode: 21, + airDate: '2025-07-18', + episodeName: '第2期上:首次大约会!温柔医生为爱冲锋', + sourceOrder: 'douban', + otherServer: 'https://api.danmu.icu', + vodServers: '金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top', + vodReturnMode: 'fastest', + vodRequestTimeout: 10000, + bilibiliCookie: '', + platformOrder: [], + episodeTitleFilter: '', + enableEpisodeFilter: false, + strictTitleMatch: false, + blockedWords: '', + groupMinute: 1, + danmuLimit: 0, + danmuSimplified: false, + convertTopBottomToScroll: false, + convertColor: 'default', + proxyUrl: '', + tmdbApiKey: '', + }; + + // 测试自动获取分片弹幕 + 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: '罗小黑战记2', + ...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) { + const anime = searchRes.animes[0]; + console.log(`📺 找到动漫: ${anime.animeTitle} (ID: ${anime.animeId})`); + + // 获取弹幕评论 + console.log('💬 获取弹幕评论...'); + const bangumi = await getDetailById({ + animeId: anime.animeId, + ...commonParams, + }); + const commentId = bangumi[0].episodeId; + const sgementList = await getCommentsById({ + commentId: commentId, + ...commonParams, + }); + if (verbose) { + console.log('✅ 弹幕分片:', JSON.stringify(sgementList, null, 2)); + } else { + console.log(`✅ 弹幕分片: 获取到 ${sgementList ? sgementList.length : 0} 个分片`); + } + + if (sgementList.length > 0) { + const comments = await getDanmuWithSegmentTime({ + segmentTime: 450, + ...commonParams, + }); + if (verbose) { + console.log('✅ 弹幕评论:', JSON.stringify(comments, null, 2)); + } else { + console.log(`✅ 弹幕评论: 获取到 ${comments && comments.comments ? comments.comments.length : 0} 条弹幕`); + } + } + } + console.log('\n' + '='.repeat(50) + '\n'); + + console.log('\n=== 所有测试完成 ==='); + + } catch (error) { + console.error('❌ 测试失败:', error); + } +} + +// 启动测试 +runTest(); diff --git a/package.json b/package.json index 199347f..24a9c50 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "A danmu Node.js Express API server", "main": "danmu_api/server.js", "scripts": { - "start": "node danmu_api/server.js" + "start": "node danmu_api/server.js", + "build-forward-widget": "node build-forward-widget.js" }, "author": "", "license": "ISC",