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>/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 = '此接口无需参数
';
- return;
- }
+ let html = '';
- formDiv.innerHTML = config.params.map(param => {
- if (param.type === 'select') {
- // 为select类型参数添加默认选项
- let optionsHtml = '';
- if (param.options) {
- optionsHtml += param.options.map(opt => \`\`).join('');
+ // 添加查询参数部分
+ if (config.params && config.params.length > 0) {
+ html += '';
+ html += '
查询参数
';
+ html += config.params.map(param => {
+ if (param.type === 'select') {
+ // 为select类型参数添加默认选项
+ let optionsHtml = '
';
+ if (param.options) {
+ optionsHtml += param.options.map(opt => \`
\`).join('');
+ }
+ return \`
+
+ \`;
}
+ // 使用placeholder属性显示示例参数
+ const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label;
return \`
\`;
- }
- // 使用placeholder属性显示示例参数
- const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label;
- return \`
-
-
-
-
- \`;
- }).join('');
+ }).join('');
+ html += '
';
+ }
+
+ // 添加请求体部分(如果接口有请求体)
+ if (config.hasBody) {
+ html += '';
+ html += '
请求体
';
+ html += \`
\`;
+ html += '
';
+ }
+
+ if (!html) {
+ html = '此接口无需参数
';
+ }
+
+ 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 */ `
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",