Feature: 支持分片弹幕接口并增加一键导出forward弹幕插件 (#118)

支持分片弹幕接口

/api/v2/comment 请求时支持定义 segmentflag=true 参数,用于请求弹幕分片列表
/api/v2/segmentcomment 通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据
增加一键导出forward弹幕插件,使用命令 node build-forward-widget.js

修复youku弹幕里color是字符串导致返回的格式错误的问题
This commit is contained in:
huangxd- authored and GitHub committed 2025-12-24 22:38:36 +08:00
1 parent 7e34823e5f
commit 3022f14fdf
27 files changed
+2591 -317

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ package-lock.json
node_modules
.env
.cache
dist
+10 -1
View File
@@ -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) 。
+98
View File
@@ -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);
}
})();
+103 -20
View File
@@ -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
);
}
}
}
// 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
);
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.9.3',
VERSION: '1.10.0',
MAX_LOGS: 500, // 日志存储,最多保存 500 行
MAX_ANIMES: 100,
+68
View File
@@ -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 };
}
}
+19
View File
@@ -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 => ({
+29 -1
View File
@@ -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
+106 -53
View File
@@ -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;
}
}
}
+19
View File
@@ -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,
+19
View File
@@ -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),
+116 -44
View File
@@ -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 [];
}
// 使用正则表达式提取 <title> 标签内容
const titleMatch = res.data.match(/<title[^>]*>(.*?)<\/title>/i);
const title = titleMatch ? titleMatch[1].split("_")[0] : "未知标题";
log("info", `标题: ${title}`);
// 获取弹幕分段数据
const segmentResult = await this.getEpisodeDanmuSegments(id);
if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) {
return [];
}
const segmentList = segmentResult.segmentList;
log("info", `弹幕分段数量: ${segmentList.length}`);
// 创建请求Promise数组
const promises = [];
for (const segment of segmentList) {
promises.push(this.getEpisodeSegmentDanmu(segment));
}
// 解析弹幕数据
let contents = [];
try {
const results = await Promise.allSettled(promises);
const datas = results
.filter(result => result.status === "fulfilled")
.map(result => result.value)
.filter(data => data !== null); // 过滤掉null值
datas.forEach(data => {
contents.push(...data);
});
} catch (error) {
log("error", "解析弹幕数据失败:", error);
return [];
}
printFirst200Chars(contents);
return contents;
}
async getEpisodeDanmuSegments(id) {
log("info", "获取爱奇艺视频弹幕分段列表...", id);
// 弹幕 API 基础地址
const api_decode_base = "https://pcw-api.iq.com/api/decode/";
const api_video_info = "https://pcw-api.iqiyi.com/video/video/baseinfo/";
const api_danmaku_base = "https://cmts.iqiyi.com/bullet/";
// 解析 URL 获取 tvid
let tvid;
@@ -701,7 +760,10 @@ export default class IqiyiSource extends BaseSource {
const idMatch = id.match(/v_(\w+)/);
if (!idMatch) {
log("error", "无法从 URL 中提取 tvid");
return [];
return new SegmentListResponse({
"type": "qiyi",
"segmentList": []
});
}
tvid = idMatch[1];
log("info", `tvid: ${tvid}`);
@@ -719,11 +781,14 @@ export default class IqiyiSource extends BaseSource {
log("info", `解码后 tvid: ${tvid}`);
} catch (error) {
log("error", "请求解码信息失败:", error);
return [];
return new SegmentListResponse({
"type": "qiyi",
"segmentList": []
});
}
// 获取视频基础信息
let title, duration, albumid, categoryid;
let duration, albumid, categoryid;
try {
const videoInfoUrl = `${api_video_info}${tvid}`;
const res = await httpGet(videoInfoUrl, {
@@ -734,22 +799,24 @@ export default class IqiyiSource extends BaseSource {
});
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
const videoInfo = data.data;
title = videoInfo.name || videoInfo.tvName || "未知标题";
duration = videoInfo.durationSec;
albumid = videoInfo.albumId;
categoryid = videoInfo.channelId || videoInfo.categoryId;
log("info", `标题: ${title}, 时长: ${duration}`);
log("info", `时长: ${duration}`);
} catch (error) {
log("error", "请求视频基础信息失败:", error);
return [];
return new SegmentListResponse({
"type": "qiyi",
"segmentList": []
});
}
// 计算弹幕分段数量(每5分钟一个分段)
const page = Math.ceil(duration / (60 * 5));
log("info", `弹幕分段数量: ${page}`);
// 构建弹幕请求
const promises = [];
// 构建分段列表
const segmentList = [];
for (let i = 0; i < page; i++) {
const params = {
rn: "0.0123456789123456",
@@ -759,40 +826,47 @@ export default class IqiyiSource extends BaseSource {
tvid: tvid,
albumid: albumid,
categoryid: categoryid,
qypid: "01010021010000000000",
qypid: "010102101000000000",
};
let queryParams = buildQueryString(params);
const api_url = `${api_danmaku_base}${tvid.slice(-4, -2)}/${tvid.slice(-2)}/${tvid}_300_${i + 1}.z?${queryParams.toString()}`;
promises.push(
httpGet(api_url, {
headers: {
"Accpet-Encoding": "gzip",
"Content-Type": "application/xml",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
zlibMode: true,
retries: 1,
})
);
const api_url = `https://cmts.iqiyi.com/bullet/${tvid.slice(-4, -2)}/${tvid.slice(-2)}/${tvid}_300_${i + 1}.z?${queryParams.toString()}`;
segmentList.push({
"type": "qiyi",
"segment_start": i * 5 * 60, // 每段5分钟
"segment_end": Math.min((i + 1) * 5 * 60, duration),
"url": api_url
});
}
// 提取 XML 标签内容的辅助函数
function extract(xml, tag) {
const reg = new RegExp(`<${tag}>(.*?)</${tag}>`, "g");
const res = xml.match(reg)?.map((x) => x.substring(tag.length + 2, x.length - tag.length - 3));
return res || [];
}
return new SegmentListResponse({
"type": "qiyi",
"segmentList": segmentList
});
}
// 解析弹幕数据
let contents = [];
async getEpisodeSegmentDanmu(segment) {
try {
const results = await Promise.allSettled(promises);
const datas = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
const response = await httpGet(segment.url, {
headers: {
"Accpet-Encoding": "gzip",
"Content-Type": "application/xml",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
zlibMode: true,
retries: 1,
});
datas.forEach(data => {
const xml = data.data;
// 提取 XML 标签内容的辅助函数
function extract(xml, tag) {
const reg = new RegExp(`<${tag}>(.*?)</${tag}>`, "g");
const res = xml.match(reg)?.map((x) => x.substring(tag.length + 2, x.length - tag.length - 3));
return res || [];
}
// 处理响应数据并返回 contents 格式的弹幕
let contents = [];
if (response && response.data) {
const xml = response.data;
const danmaku = extract(xml, "content");
const showTime = extract(xml, "showTime");
const color = extract(xml, "color");
@@ -802,15 +876,13 @@ export default class IqiyiSource extends BaseSource {
showTime: showTime[i],
color: color[i],
})));
});
}
return contents;
} catch (error) {
log("error", "解析弹幕数据失败:", error);
return [];
log("error", "请求分片弹幕失败:", error);
return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为
}
printFirst200Chars(contents);
return contents;
}
formatComments(comments) {
@@ -831,4 +903,4 @@ export default class IqiyiSource extends BaseSource {
return content;
});
}
}
}
+126 -75
View File
@@ -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;
});
}
}
}
+19
View File
@@ -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;
}
+19
View File
@@ -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 || "");
+123 -38
View File
@@ -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;
});
}
}
}
+108 -40
View File
@@ -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;
});
}
}
}
+1 -1
View File
@@ -315,4 +315,4 @@ input:checked + .slider:before {
background: #e8eaf6 !important;
border-color: #667eea !important;
}
`;
`;
+95 -31
View File
@@ -7,7 +7,7 @@ const apiConfigs = {
method: 'GET',
path: '/api/v2/search/anime',
params: [
{ name: 'keyword', label: '关键词', type: 'text', required: true, placeholder: '示例: 生万物' }
{ name: 'keyword', label: '关键词 或 播放链接URL', type: 'text', required: true, placeholder: '示例: 生万物 或 http://v.qq.com/x/cover/rjae621myqca41h/j0032ubhl9s.html' }
]
},
searchEpisodes: {
@@ -40,8 +40,19 @@ const apiConfigs = {
path: '/api/v2/comment/:commentId',
params: [
{ name: 'commentId', label: '弹幕ID', type: 'text', required: true, placeholder: '示例: 10009' },
{ name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] }
{ name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] },
{ name: 'segmentflag', label: '分片标志', type: 'select', required: false, placeholder: '可选: true或false', options: ['true', 'false'] }
]
},
getSegmentComment: {
name: '获取分片弹幕',
method: 'POST',
path: '/api/v2/segmentcomment',
params: [
{ name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] }
],
hasBody: true,
bodyType: 'json'
}
};
@@ -60,37 +71,58 @@ function loadApiParams() {
const config = apiConfigs[apiKey];
paramsDiv.style.display = 'block';
if (config.params.length === 0) {
formDiv.innerHTML = '<p class="text-gray">此接口无需参数</p>';
return;
}
let html = '';
formDiv.innerHTML = config.params.map(param => {
if (param.type === 'select') {
// 为select类型参数添加默认选项
let optionsHtml = '<option value="">-- 请选择 --</option>';
if (param.options) {
optionsHtml += param.options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join('');
// 添加查询参数部分
if (config.params && config.params.length > 0) {
html += '<div class="params-section">';
html += '<h4>查询参数</h4>';
html += config.params.map(param => {
if (param.type === 'select') {
// 为select类型参数添加默认选项
let optionsHtml = '<option value="">-- 请选择 --</option>';
if (param.options) {
optionsHtml += param.options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join('');
}
return \`
<div class="form-group">
<label>\${param.label}\${param.required ? ' *' : ''}</label>
<select id="param-\${param.name}">
\${optionsHtml}
</select>
\${param.placeholder ? \`<div class="form-help">\${param.placeholder}</div>\` : ''}
</div>
\`;
}
// 使用placeholder属性显示示例参数
const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label;
return \`
<div class="form-group">
<label>\${param.label}\${param.required ? ' *' : ''}</label>
<select id="param-\${param.name}">
\${optionsHtml}
</select>
\${param.placeholder ? \`<div class="form-help">\${param.placeholder}</div>\` : ''}
<input type="\${param.type}" id="param-\${param.name}" placeholder="\${placeholder}" \${param.required ? 'required' : ''}>
</div>
\`;
}
// 使用placeholder属性显示示例参数
const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label;
return \`
<div class="form-group">
<label>\${param.label}\${param.required ? ' *' : ''}</label>
<input type="\${param.type}" id="param-\${param.name}" placeholder="\${placeholder}" \${param.required ? 'required' : ''}>
</div>
\`;
}).join('');
}).join('');
html += '</div>';
}
// 添加请求体部分(如果接口有请求体)
if (config.hasBody) {
html += '<div class="body-section">';
html += '<h4>请求体</h4>';
html += \`<div class="form-group">
<label>请求体内容 * (JSON格式)</label>
<textarea id="body-content" rows="6" placeholder='输入JSON格式的请求体,例如:{"type": "qq","segment_start":0,"segment_end":30000,"url":"https://dm.video.qq.com/barrage/segment/j0032ubhl9s/t/v1/0/30000"}'></textarea>
<div class="form-help">输入JSON格式的请求体内容</div>
</div>\`;
html += '</div>';
}
if (!html) {
html = '<p class="text-gray">此接口无需参数</p>';
}
formDiv.innerHTML = html;
}
function testApi() {
@@ -111,10 +143,13 @@ function testApi() {
const config = apiConfigs[apiKey];
const params = {};
config.params.forEach(param => {
const value = document.getElementById(\`param-\${param.name}\`).value;
if (value) params[param.name] = value;
});
// 获取查询参数
if (config.params) {
config.params.forEach(param => {
const value = document.getElementById(\`param-\${param.name}\`).value;
if (value) params[param.name] = value;
});
}
addLog(\`调用接口: \${config.name} (\${config.method} \${config.path})\`, 'info');
addLog(\`请求参数: \${JSON.stringify(params)}\`, 'info');
@@ -168,7 +203,36 @@ function testApi() {
}
};
if (config.method === 'POST') {
// 处理请求体
if (config.hasBody) {
// 从请求体输入框获取内容
const bodyContent = document.getElementById('body-content').value;
if (bodyContent) {
try {
// 尝试解析用户输入的JSON
const bodyData = JSON.parse(bodyContent);
requestOptions.body = JSON.stringify(bodyData);
} catch (e) {
addLog('请求体JSON格式错误: ' + e.message, 'error');
sendButton.innerHTML = originalText;
sendButton.disabled = false;
return;
}
} else {
// 如果没有在请求体输入框中输入内容,则使用参数构建请求体(向后兼容)
if (apiKey === 'getSegmentComment') {
// 对于 getSegmentComment 接口,创建 segment 对象
const segmentData = { url: params.url };
if (params.format) {
segmentData.format = params.format;
}
requestOptions.body = JSON.stringify(segmentData);
} else {
requestOptions.body = JSON.stringify(params);
}
}
} else if (config.method === 'POST') {
// 对于没有特殊请求体配置的POST接口,使用参数构建请求体
requestOptions.body = JSON.stringify(params);
}
+2 -1
View File
@@ -85,12 +85,13 @@ export const HTML_TEMPLATE = /* html */ `
<div class="form-group">
<label>选择接口</label>
<select id="api-select" onchange="loadApiParams()">
<option value="">请选择接口</option>
<option value="">-- 请选择接口 --</option>
<option value="searchAnime">搜索动漫 - /api/v2/search/anime</option>
<option value="searchEpisodes">搜索剧集 - /api/v2/search/episodes</option>
<option value="matchAnime">匹配动漫 - /api/v2/match</option>
<option value="getBangumi">获取番剧详情 - /api/v2/bangumi/:animeId</option>
<option value="getComment">获取弹幕 - /api/v2/comment/:commentId</option>
<option value="getSegmentComment">获取分片弹幕 - /api/v2/segmentcomment</option>
</select>
</div>
</div>
+5
View File
@@ -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}`);
}
+35 -3
View File
@@ -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
+130 -7
View File
@@ -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}`);
// });
});
});
+361
View File
@@ -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;
}
}
+669
View File
@@ -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 };
+307
View File
@@ -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();
+2 -1
View File
@@ -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",