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:
1 parent
7e34823e5f
commit
3022f14fdf
27 files changed
+2591
-317
No files matched your search
@@ -3,3 +3,4 @@ package-lock.json
|
||||
node_modules
|
||||
.env
|
||||
.cache
|
||||
dist
|
||||
@@ -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) 。
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const Globals = {
|
||||
accessedEnvVars: {},
|
||||
|
||||
// 静态常量
|
||||
VERSION: '1.9.3',
|
||||
VERSION: '1.10.0',
|
||||
MAX_LOGS: 500, // 日志存储,最多保存 500 行
|
||||
MAX_ANIMES: 100,
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,4 +315,4 @@ input:checked + .slider:before {
|
||||
background: #e8eaf6 !important;
|
||||
border-color: #667eea !important;
|
||||
}
|
||||
`;
|
||||
`;
|
||||
+95
-31
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`);
|
||||
// });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user