优化弹幕测试界面的视频时长获取 (#239)
* 优化弹幕测试页时长获取 变更内容: - 优先从评论接口和源字段提取测试页视频时长 - 补充不同来源时长回退与测试页展示逻辑 - 更新相关文档与测试用例 * 移除测试页时长界面改动 变更内容: - 删除接口调试页面中附带时长参数与展示逻辑 - 回退测试页说明文档中的时长处理描述 - 保留评论接口时长能力与相关测试用例 * 恢复接口调试页时长参数 变更内容: - 恢复接口调试页面中的duration参数与时长展示逻辑 - 恢复UI说明文档中的时长处理描述 - 保持评论接口视频时长能力与测试页联动 * 调整测试页时长展示细节 变更内容:让 comment JSON 的 videoDuration 字段固定排在返回体开头 变更内容:接口调试页在 xml 格式下隐藏附带时长开关并在切换时清空该参数 变更内容:版本号更新为 1.17.1
This commit is contained in:
1 parent
cdbea1b77d
commit
f04403962f
9 files changed
+496
-81
No files matched your search
@@ -52,7 +52,7 @@ LogVar 弹幕 API 服务器
|
||||
- `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`;已支持从网盘资源命名,如`无忧渡.S01E01.2160p.WEB-DL.H265.DDP.5.1`中提取 title/season/episode);已支持外语标题匹配,如`Blood.River.S01E05`,需配置环境变量`TITLE_TO_CHINESE`使用;已适配该格式`爱情公寓.ipartment.2009.S03E05.H.265.25fps.mkv`标题;已支持AI自动匹配,需配合AI相关环境变量使用
|
||||
- `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。
|
||||
- `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。
|
||||
- `GET /api/v2/comment/:commentId?format=json`:获取指定弹幕评论,支持返回相关评论和字符转换。
|
||||
- `GET /api/v2/comment/:commentId?format=json&duration=true`:获取指定弹幕评论;当 `duration=true` 且返回 JSON 时,会额外附带 `videoDuration` 字段,优先返回源站时长,拿不到时返回 `0`。
|
||||
- `GET /api/v2/comment?url=${videoUrl}&format=json`:通过视频URL直接获取弹幕(兼容第三方弹幕服务器格式)。
|
||||
- `POST /api/v2/segmentcomment?format=json`:通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据。
|
||||
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
|
||||
@@ -81,6 +81,7 @@ LogVar 弹幕 API 服务器
|
||||
- **弹幕时间偏移功能**:支持通过环境变量 `DANMU_OFFSET` 配置弹幕时间偏移,解决弹幕与视频不同步的问题。格式为 `剧名:秒`(全剧偏移)、`剧名/季:秒`(整季偏移)、`剧名/季/集:秒`(单集偏移),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02:120, re-zero/S02/E03:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。
|
||||
- **弹幕分片请求**:
|
||||
- `/api/v2/comment` 请求时支持定义 `segmentflag=true` 参数,用于请求弹幕分片列表
|
||||
- `/api/v2/comment/:commentId?format=json&duration=true` 可在 JSON 返回体中附带 `videoDuration`
|
||||
- `/api/v2/segmentcomment` 通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据
|
||||
- **UI界面-后台配置管理系统**:支持通过UI执行一些操作(详细见 [UI 系统使用说明](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md) ),包括:
|
||||
- 配置预览
|
||||
@@ -142,6 +143,7 @@ LogVar 弹幕 API 服务器
|
||||
- `GET http://{ip}:9321/87654321/api/v2/search/episodes?anime=生万物`
|
||||
- `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/1?format=json&duration=true`
|
||||
- `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`
|
||||
@@ -355,6 +357,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
**使用示例**:
|
||||
- 获取 JSON 格式:`GET /api/v2/comment/10001`
|
||||
- 获取 XML 格式:`GET /api/v2/comment/10001?format=xml`
|
||||
- 获取 JSON 并附带视频时长:`GET /api/v2/comment/10001?format=json&duration=true`
|
||||
- 通过 URL 获取弹幕:`GET /api/v2/comment?url=https://v.qq.com/x/cover/xxx.html&format=json`
|
||||
|
||||
> 注意:
|
||||
|
||||
@@ -69,6 +69,103 @@ const tmdbSource = new TmdbSource(doubanSource);
|
||||
// 用于聚合请求的去重Map
|
||||
const PENDING_DANMAKU_REQUESTS = new Map();
|
||||
|
||||
function normalizeDurationValue(rawValue) {
|
||||
const duration = Number(rawValue || 0);
|
||||
if (!Number.isFinite(duration) || duration <= 0) return 0;
|
||||
return duration > 6 * 60 * 60 ? duration / 1000 : duration;
|
||||
}
|
||||
|
||||
function shouldIncludeVideoDuration(queryFormat, includeDuration = false) {
|
||||
if (!includeDuration) return false;
|
||||
const format = String(queryFormat || globals.danmuOutputFormat || 'json').toLowerCase();
|
||||
return format === 'json';
|
||||
}
|
||||
|
||||
function buildDanmuResponse(data, videoDuration = null) {
|
||||
if (videoDuration === null) return data;
|
||||
return { videoDuration, ...data };
|
||||
}
|
||||
|
||||
function extractDurationFromSegments(segmentResult) {
|
||||
const explicitDuration = normalizeDurationValue(segmentResult?.duration || segmentResult?.videoDuration || 0);
|
||||
if (explicitDuration > 0) return explicitDuration;
|
||||
|
||||
const segmentList = Array.isArray(segmentResult?.segmentList) ? segmentResult.segmentList : [];
|
||||
if (!segmentList.length) return 0;
|
||||
|
||||
let duration = 0;
|
||||
segmentList.forEach((segment) => {
|
||||
const normalized = normalizeDurationValue(segment?.segment_end || 0);
|
||||
if (normalized <= 0) return;
|
||||
if (normalized > duration) duration = normalized;
|
||||
});
|
||||
|
||||
return duration > 0 ? duration : 0;
|
||||
}
|
||||
|
||||
async function resolveUrlDuration(url) {
|
||||
if (!/^https?:\/\//i.test(url)) return 0;
|
||||
|
||||
try {
|
||||
let targetUrl = url;
|
||||
let segmentResult = null;
|
||||
|
||||
if (targetUrl.includes('.qq.com')) {
|
||||
segmentResult = await tencentSource.getComments(targetUrl, 'qq', true);
|
||||
} else if (targetUrl.includes('.iqiyi.com')) {
|
||||
segmentResult = await iqiyiSource.getComments(targetUrl, 'qiyi', true);
|
||||
} else if (targetUrl.includes('.mgtv.com')) {
|
||||
segmentResult = await mangoSource.getComments(targetUrl, 'imgo', true);
|
||||
} else if (targetUrl.includes('.bilibili.com') || targetUrl.includes('b23.tv')) {
|
||||
if (targetUrl.includes('b23.tv')) {
|
||||
targetUrl = await bilibiliSource.resolveB23Link(targetUrl);
|
||||
}
|
||||
segmentResult = await bilibiliSource.getComments(targetUrl, 'bilibili1', true);
|
||||
} else if (targetUrl.includes('.youku.com')) {
|
||||
segmentResult = await youkuSource.getComments(targetUrl, 'youku', true);
|
||||
} else if (targetUrl.includes('.miguvideo.com')) {
|
||||
segmentResult = await miguSource.getComments(targetUrl, 'migu', true);
|
||||
} else if (targetUrl.includes('.sohu.com')) {
|
||||
segmentResult = await sohuSource.getComments(targetUrl, 'sohu', true);
|
||||
} else if (targetUrl.includes('.le.com')) {
|
||||
segmentResult = await leshiSource.getComments(targetUrl, 'leshi', true);
|
||||
} else if (targetUrl.includes('.douyin.com') || targetUrl.includes('.ixigua.com')) {
|
||||
segmentResult = await xiguaSource.getComments(targetUrl, 'xigua', true);
|
||||
} else if (targetUrl.includes('.mddcloud.com.cn')) {
|
||||
segmentResult = await maiduiduiSource.getComments(targetUrl, 'maiduidui', true);
|
||||
}
|
||||
|
||||
return extractDurationFromSegments(segmentResult);
|
||||
} catch (error) {
|
||||
log('warn', `[Duration] 获取时长失败: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMergedUrls(url) {
|
||||
return String(url || '')
|
||||
.split(MERGE_DELIMITER)
|
||||
.map((part) => {
|
||||
const firstColonIndex = part.indexOf(':');
|
||||
if (firstColonIndex === -1) return part.trim();
|
||||
return part.slice(firstColonIndex + 1).trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function resolveMergedDuration(url) {
|
||||
if (!url) return 0;
|
||||
|
||||
try {
|
||||
const targetUrls = url.includes(MERGE_DELIMITER) ? extractMergedUrls(url) : [url];
|
||||
const durations = await Promise.all(targetUrls.map(resolveUrlDuration));
|
||||
return durations.reduce((maxValue, currentValue) => Math.max(maxValue, currentValue || 0), 0);
|
||||
} catch (error) {
|
||||
log('warn', `[Duration] 获取时长失败: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配年份函数,优先于季匹配
|
||||
function matchYear(anime, queryYear) {
|
||||
if (!queryYear) {
|
||||
@@ -1347,12 +1444,13 @@ async function fetchMergedComments(url) {
|
||||
}
|
||||
|
||||
// Extracted function for GET /api/v2/comment/:commentId
|
||||
export async function getComment(path, queryFormat, segmentFlag, clientIp) {
|
||||
export async function getComment(path, queryFormat, segmentFlag, clientIp, includeDuration = false) {
|
||||
const commentId = parseInt(path.split("/").pop());
|
||||
let animeTitle = findAnimeTitleById(commentId);
|
||||
let url = findUrlById(commentId);
|
||||
let title = findTitleById(commentId);
|
||||
let plat = title ? (title.match(/【(.*?)】/) || [null])[0]?.replace(/[【】]/g, '') : null;
|
||||
const shouldAttachDuration = shouldIncludeVideoDuration(queryFormat, includeDuration);
|
||||
log("info", "comment url...", url);
|
||||
log("info", "comment title...", title);
|
||||
log("info", "comment platform...", plat);
|
||||
@@ -1365,12 +1463,16 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp) {
|
||||
// 检查弹幕缓存
|
||||
const cachedComments = getCommentCache(url);
|
||||
if (cachedComments !== null) {
|
||||
const responseData = { count: cachedComments.length, comments: cachedComments };
|
||||
const responseData = buildDanmuResponse(
|
||||
{ count: cachedComments.length, comments: cachedComments },
|
||||
shouldAttachDuration ? await resolveMergedDuration(url) : null
|
||||
);
|
||||
return formatDanmuResponse(responseData, queryFormat);
|
||||
}
|
||||
|
||||
log("info", "开始从本地请求弹幕...", url);
|
||||
let danmus = [];
|
||||
const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null;
|
||||
|
||||
if (url && url.includes(MERGE_DELIMITER)) {
|
||||
danmus = await fetchMergedComments(url);
|
||||
@@ -1482,12 +1584,15 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp) {
|
||||
}
|
||||
}
|
||||
|
||||
const responseData = { count: danmus.length, comments: danmus };
|
||||
const responseData = buildDanmuResponse(
|
||||
{ count: danmus.length, comments: danmus },
|
||||
durationPromise ? await durationPromise : null
|
||||
);
|
||||
return formatDanmuResponse(responseData, queryFormat);
|
||||
}
|
||||
|
||||
// Extracted function for GET /api/v2/comment?url=xxx
|
||||
export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) {
|
||||
export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includeDuration = false) {
|
||||
try {
|
||||
// 验证URL参数
|
||||
if (!videoUrl || typeof videoUrl !== 'string') {
|
||||
@@ -1512,21 +1617,23 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) {
|
||||
log("info", `Processing comment request for URL: ${videoUrl}`);
|
||||
|
||||
let url = videoUrl;
|
||||
const shouldAttachDuration = shouldIncludeVideoDuration(queryFormat, includeDuration);
|
||||
// 检查弹幕缓存
|
||||
const cachedComments = getCommentCache(url);
|
||||
if (cachedComments !== null) {
|
||||
const responseData = {
|
||||
const responseData = buildDanmuResponse({
|
||||
errorCode: 0,
|
||||
success: true,
|
||||
errorMessage: "",
|
||||
count: cachedComments.length,
|
||||
comments: cachedComments
|
||||
};
|
||||
}, shouldAttachDuration ? await resolveMergedDuration(url) : null);
|
||||
return formatDanmuResponse(responseData, queryFormat);
|
||||
}
|
||||
|
||||
log("info", "开始从本地请求弹幕...", url);
|
||||
let danmus = [];
|
||||
const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null;
|
||||
|
||||
// 根据URL域名判断平台并获取弹幕
|
||||
if (url.includes('.qq.com')) {
|
||||
@@ -1568,13 +1675,13 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) {
|
||||
setCommentCache(url, danmus);
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
const responseData = buildDanmuResponse({
|
||||
errorCode: 0,
|
||||
success: true,
|
||||
errorMessage: "",
|
||||
count: danmus.length,
|
||||
comments: danmus
|
||||
};
|
||||
}, durationPromise ? await durationPromise : null);
|
||||
return formatDanmuResponse(responseData, queryFormat);
|
||||
} catch (error) {
|
||||
// 处理异常
|
||||
|
||||
@@ -274,15 +274,16 @@ export class Bangumi {
|
||||
// 数据模型:SegmentListResponse
|
||||
// =====================
|
||||
export class SegmentListResponse {
|
||||
constructor({ type = "", segmentList = [] } = {}) {
|
||||
constructor({ type = "", segmentList = [], duration = 0 } = {}) {
|
||||
validateType(type, "string");
|
||||
validateType(segmentList, "array");
|
||||
validateType(duration, "number");
|
||||
|
||||
// 将 segmentList 转换为 Segment 实例数组
|
||||
this.segmentList = segmentList.map(segmentData => Segment.fromJson(segmentData));
|
||||
|
||||
// 直接解构并赋值给 this
|
||||
Object.assign(this, { type });
|
||||
Object.assign(this, { type, duration });
|
||||
}
|
||||
|
||||
// ---- 静态方法:从 JSON 创建 SegmentListResponse 对象 ----
|
||||
|
||||
@@ -800,7 +800,7 @@ export default class BilibiliSource extends BaseSource {
|
||||
}
|
||||
|
||||
// 计算视频的分片数量
|
||||
const maxLen = (duration > 0) ? (Math.floor(duration / 360) + 1) : 36;
|
||||
const maxLen = (duration > 0) ? Math.ceil(duration / 360) : 36;
|
||||
log("info", `maxLen: ${maxLen}`);
|
||||
|
||||
const segmentList = [];
|
||||
@@ -815,13 +815,14 @@ export default class BilibiliSource extends BaseSource {
|
||||
segmentList.push({
|
||||
"type": "bilibili1",
|
||||
"segment_start": i * 360,
|
||||
"segment_end": (i + 1) * 360,
|
||||
"segment_end": duration > 0 ? Math.min((i + 1) * 360, duration) : (i + 1) * 360,
|
||||
"url": danmakuUrl
|
||||
});
|
||||
}
|
||||
|
||||
return new SegmentListResponse({
|
||||
"type": "bilibili1",
|
||||
"duration": duration > 0 ? duration : 0,
|
||||
"segmentList": segmentList
|
||||
});
|
||||
}
|
||||
|
||||
+59
-40
@@ -147,51 +147,52 @@ export default class SohuSource extends BaseSource {
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistData(id) {
|
||||
const params = {
|
||||
'playlistid': id,
|
||||
'api_key': "f351515304020cad28c92f70f002261c"
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://tv.sohu.com/'
|
||||
};
|
||||
|
||||
const playlistUrl = `https://pl.hd.sohu.com/videolist?${buildQueryString(params)}`;
|
||||
const response = await httpGet(playlistUrl, { headers, timeout: 15000 });
|
||||
|
||||
if (!response || !response.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = response.data;
|
||||
if (typeof data === "string" && data.startsWith('jsonp')) {
|
||||
const start = data.indexOf('(') + 1;
|
||||
const end = data.lastIndexOf(')');
|
||||
if (start > 0 && end > start) {
|
||||
data = JSON.parse(data.substring(start, end));
|
||||
} else {
|
||||
log("error", "搜狐视频: 无法解析JSONP响应");
|
||||
return null;
|
||||
}
|
||||
} else if (typeof data === "string") {
|
||||
data = JSON.parse(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getEpisodes(id) {
|
||||
try {
|
||||
log("info", `[Sohu] 获取分集列表: media_id=${id}`);
|
||||
|
||||
let videosData = null;
|
||||
|
||||
const params = {
|
||||
'playlistid': id,
|
||||
'api_key': "f351515304020cad28c92f70f002261c"
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://tv.sohu.com/'
|
||||
};
|
||||
|
||||
const playlistUrl = `https://pl.hd.sohu.com/videolist?${buildQueryString(params)}`;
|
||||
|
||||
const response = await httpGet(playlistUrl, { headers, timeout: 15000 });
|
||||
|
||||
if (!response || !response.data) {
|
||||
const data = await this.getPlaylistData(id);
|
||||
if (!data) {
|
||||
log("info", "[Sohu] 分集响应为空");
|
||||
return [];
|
||||
}
|
||||
|
||||
let data = response.data;
|
||||
|
||||
// 解析JSONP响应
|
||||
if (typeof data === "string" && data.startsWith('jsonp')) {
|
||||
// 提取括号内的JSON
|
||||
const start = data.indexOf('(') + 1;
|
||||
const end = data.lastIndexOf(')');
|
||||
if (start > 0 && end > start) {
|
||||
const jsonStr = data.substring(start, end);
|
||||
data = JSON.parse(jsonStr);
|
||||
} else {
|
||||
log("error", "搜狐视频: 无法解析JSONP响应");
|
||||
return [];
|
||||
}
|
||||
} else if (typeof data === "string") {
|
||||
data = JSON.parse(data);
|
||||
}
|
||||
|
||||
// 提取视频列表
|
||||
videosData = data.videos || [];
|
||||
const videosData = data.videos || [];
|
||||
|
||||
if (!videosData || videosData.length === 0) {
|
||||
log("warning", `搜狐视频: 未找到分集列表 (media_id=${id})`);
|
||||
@@ -302,6 +303,23 @@ export default class SohuSource extends BaseSource {
|
||||
return processSohuAnimes;
|
||||
}
|
||||
|
||||
async getEpisodeDuration(aid, vid) {
|
||||
if (!aid) return 0;
|
||||
|
||||
try {
|
||||
const data = await this.getPlaylistData(aid);
|
||||
const videos = Array.isArray(data?.videos) ? data.videos : [];
|
||||
if (!videos.length) return 0;
|
||||
|
||||
const matchedVideo = videos.find(video => String(video?.vid || '') === String(vid || '')) || (videos.length === 1 ? videos[0] : null);
|
||||
const duration = Number(matchedVideo?.playLength || 0);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : 0;
|
||||
} catch (error) {
|
||||
log("warn", `[Sohu] 获取真实时长失败: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取vid和aid的公共函数
|
||||
async extractVidAndAid(id) {
|
||||
let vid;
|
||||
@@ -430,13 +448,13 @@ export default class SohuSource extends BaseSource {
|
||||
// 解析 episode_id
|
||||
const { vid, aid } = await this.extractVidAndAid(id);
|
||||
|
||||
// 搜狐视频的弹幕分段逻辑,使用固定的最大时间
|
||||
const maxTime = 10800;
|
||||
const duration = await this.getEpisodeDuration(aid, vid);
|
||||
const maxTime = duration > 0 ? Math.ceil(duration) : 10800;
|
||||
const segmentDuration = 300; // 300秒一段
|
||||
const segments = [];
|
||||
|
||||
for (let start = 0; start < maxTime; start += segmentDuration) {
|
||||
const end = start + segmentDuration;
|
||||
const end = Math.min(start + segmentDuration, maxTime);
|
||||
segments.push({
|
||||
"type": "sohu",
|
||||
"segment_start": start,
|
||||
@@ -447,6 +465,7 @@ export default class SohuSource extends BaseSource {
|
||||
|
||||
return new SegmentListResponse({
|
||||
"type": "sohu",
|
||||
"duration": duration > 0 ? duration : 0,
|
||||
"segmentList": segments
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性:
|
||||
- 弹幕热力图:按时间段可视化弹幕密度分布
|
||||
- 弹幕列表:支持全部/滚动/顶部/底部过滤,显示颜色和类型标签,懒加载每次100条
|
||||
- 导出功能:支持导出JSON和XML格式弹幕文件
|
||||
- 异常时间处理:使用P99分位数剔除异常时间点
|
||||
- 时长处理:优先请求 `/api/v2/comment/:commentId?format=json&duration=true` 返回的 `videoDuration`,没有真实时长时再回退到尾部裁剪算法
|
||||
- 视图导航优化:搜索结果/剧集详情/弹幕详情逐级切换,带返回按钮和加载动画
|
||||
|
||||
### 4. 推送弹幕
|
||||
|
||||
+128
-19
@@ -42,6 +42,7 @@ const apiConfigs = {
|
||||
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: 'duration', label: '附带时长', type: 'select', required: false, placeholder: '可选: true或false', options: ['true', 'false'] },
|
||||
{ name: 'segmentflag', label: '分片标志', type: 'select', required: false, placeholder: '可选: true或false', options: ['true', 'false'] }
|
||||
]
|
||||
},
|
||||
@@ -65,7 +66,8 @@ let danmuTestState = {
|
||||
displayedCount: 0,
|
||||
pageSize: 100,
|
||||
currentEpisodeId: null,
|
||||
currentTitle: ''
|
||||
currentTitle: '',
|
||||
currentDuration: 0
|
||||
};
|
||||
|
||||
// 初始化接口调试界面
|
||||
@@ -105,6 +107,31 @@ function attachEnterEventToParams() {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateCommentDurationFieldVisibility() {
|
||||
const apiSelect = document.getElementById('api-select');
|
||||
const durationGroup = document.querySelector('#params-form [data-param-name="duration"]');
|
||||
const durationInput = document.getElementById('param-duration');
|
||||
const formatInput = document.getElementById('param-format');
|
||||
|
||||
if (!durationGroup) return;
|
||||
|
||||
const isCommentApi = apiSelect && apiSelect.value === 'getComment';
|
||||
const formatValue = formatInput ? formatInput.value.toLowerCase() : '';
|
||||
const shouldShowDuration = isCommentApi && (formatValue === '' || formatValue === 'json');
|
||||
|
||||
durationGroup.style.display = shouldShowDuration ? '' : 'none';
|
||||
if (!shouldShowDuration && durationInput) {
|
||||
durationInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function bindCommentDurationFieldVisibility() {
|
||||
const formatInput = document.getElementById('param-format');
|
||||
if (!formatInput) return;
|
||||
formatInput.addEventListener('change', updateCommentDurationFieldVisibility);
|
||||
updateCommentDurationFieldVisibility();
|
||||
}
|
||||
|
||||
// 处理参数输入框的回车事件
|
||||
function handleParamInputEnter(event) {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -145,7 +172,7 @@ function loadApiParams() {
|
||||
optionsHtml += param.options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join('');
|
||||
}
|
||||
return \`
|
||||
<div class="form-group">
|
||||
<div class="form-group" data-param-name="\${param.name}">
|
||||
<label>\${param.label}\${param.required ? ' *' : ''}</label>
|
||||
<select id="param-\${param.name}">
|
||||
\${optionsHtml}
|
||||
@@ -157,7 +184,7 @@ function loadApiParams() {
|
||||
// 使用placeholder属性显示示例参数
|
||||
const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label;
|
||||
return \`
|
||||
<div class="form-group">
|
||||
<div class="form-group" data-param-name="\${param.name}">
|
||||
<label>\${param.label}\${param.required ? ' *' : ''}</label>
|
||||
<input type="\${param.type}" id="param-\${param.name}" placeholder="\${placeholder}" \${param.required ? 'required' : ''}>
|
||||
</div>
|
||||
@@ -186,6 +213,7 @@ function loadApiParams() {
|
||||
|
||||
// 为参数输入框添加回车事件监听
|
||||
attachEnterEventToParams();
|
||||
bindCommentDurationFieldVisibility();
|
||||
}
|
||||
|
||||
function testApi() {
|
||||
@@ -429,12 +457,89 @@ function formatDuration(seconds) {
|
||||
return m + ':' + String(sec).padStart(2,'0');
|
||||
}
|
||||
|
||||
// 用P99分位数计算有效时长,剔除异常时间点
|
||||
function getEffectiveDuration(comments) {
|
||||
const times = comments.map(c => c.t !== undefined ? c.t : parseDanmuTime(c.p)).sort((a, b) => a - b);
|
||||
if (times.length === 0) return 0;
|
||||
const idx = Math.floor(times.length * 0.99);
|
||||
return times[Math.min(idx, times.length - 1)];
|
||||
function getValidDanmuTimes(comments) {
|
||||
return comments
|
||||
.map(c => c.t !== undefined ? Number(c.t) : parseDanmuTime(c.p))
|
||||
.filter(time => Number.isFinite(time) && time >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function getPercentileValue(sortedValues, percentile) {
|
||||
if (!sortedValues.length) return 0;
|
||||
const safePercentile = Math.min(Math.max(percentile, 0), 1);
|
||||
const index = Math.floor((sortedValues.length - 1) * safePercentile);
|
||||
return sortedValues[index] || 0;
|
||||
}
|
||||
|
||||
function getMedian(values) {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
}
|
||||
|
||||
function buildTailGapThreshold(sortedValues) {
|
||||
if (sortedValues.length < 2) return 45;
|
||||
|
||||
const gaps = [];
|
||||
const startIndex = Math.max(1, sortedValues.length - 80);
|
||||
for (let i = startIndex; i < sortedValues.length; i++) {
|
||||
const gap = sortedValues[i] - sortedValues[i - 1];
|
||||
if (gap > 0) {
|
||||
gaps.push(gap);
|
||||
}
|
||||
}
|
||||
|
||||
if (!gaps.length) return 45;
|
||||
|
||||
const sortedGaps = [...gaps].sort((a, b) => a - b);
|
||||
const medianGap = getMedian(sortedGaps);
|
||||
const p90Gap = getPercentileValue(sortedGaps, 0.9);
|
||||
return Math.min(240, Math.max(45, medianGap * 12, p90Gap * 3));
|
||||
}
|
||||
|
||||
function estimateVideoDurationFromComments(comments) {
|
||||
const validTimes = getValidDanmuTimes(comments);
|
||||
if (!validTimes.length) return 0;
|
||||
if (validTimes.length < 200) return validTimes[validTimes.length - 1];
|
||||
|
||||
const p995Time = getPercentileValue(validTimes, 0.995);
|
||||
const p998Time = getPercentileValue(validTimes, 0.998);
|
||||
const gapThreshold = buildTailGapThreshold(validTimes);
|
||||
const trimBaseline = p998Time + Math.max(90, gapThreshold * 2);
|
||||
let endIndex = validTimes.length - 1;
|
||||
|
||||
while (endIndex > 0) {
|
||||
let tailStartIndex = endIndex;
|
||||
while (tailStartIndex > 0) {
|
||||
const gap = validTimes[tailStartIndex] - validTimes[tailStartIndex - 1];
|
||||
if (gap > gapThreshold) break;
|
||||
tailStartIndex--;
|
||||
}
|
||||
|
||||
const tailClusterSize = endIndex - tailStartIndex + 1;
|
||||
const tailClusterSpan = validTimes[endIndex] - validTimes[tailStartIndex];
|
||||
const previousGap = tailStartIndex > 0 ? validTimes[tailStartIndex] - validTimes[tailStartIndex - 1] : 0;
|
||||
const isIsolatedTail = tailStartIndex > 0
|
||||
&& tailClusterSize <= 2
|
||||
&& tailClusterSpan <= 15
|
||||
&& previousGap > gapThreshold;
|
||||
|
||||
if (!isIsolatedTail || validTimes[endIndex] <= trimBaseline) {
|
||||
break;
|
||||
}
|
||||
|
||||
endIndex = tailStartIndex - 1;
|
||||
}
|
||||
|
||||
return Math.max(validTimes[Math.max(endIndex, 0)] || 0, p995Time);
|
||||
}
|
||||
|
||||
function getEffectiveDuration(comments, explicitDuration) {
|
||||
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
|
||||
return explicitDuration;
|
||||
}
|
||||
return estimateVideoDurationFromComments(comments);
|
||||
}
|
||||
|
||||
function setBtnLoading(btn, loading) {
|
||||
@@ -646,6 +751,7 @@ async function fetchDanmuForTest(episodeId, title, source) {
|
||||
addLog('获取弹幕: ' + episodeId + ' (' + title + ')', 'info');
|
||||
danmuTestState.currentEpisodeId = episodeId;
|
||||
danmuTestState.currentTitle = title;
|
||||
danmuTestState.currentDuration = 0;
|
||||
|
||||
// 隐藏上级面板
|
||||
if (source === 'manual') {
|
||||
@@ -661,10 +767,13 @@ async function fetchDanmuForTest(episodeId, title, source) {
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const resp = await fetch(buildApiUrl('/api/v2/comment/' + episodeId + "?format=json"));
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
const data = await fetch(buildApiUrl('/api/v2/comment/' + episodeId + "?format=json&duration=true"))
|
||||
.then(resp => {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
});
|
||||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(2);
|
||||
const durationSeconds = Number((data && data.videoDuration) || 0);
|
||||
|
||||
if (!data.comments || data.comments.length === 0) {
|
||||
customAlert('该剧集暂无弹幕数据');
|
||||
@@ -679,6 +788,7 @@ async function fetchDanmuForTest(episodeId, title, source) {
|
||||
danmuTestState.allComments = data.comments;
|
||||
danmuTestState.currentFilter = 'all';
|
||||
danmuTestState.displayedCount = 0;
|
||||
danmuTestState.currentDuration = durationSeconds;
|
||||
|
||||
// 重建结果区内容:auto模式只有导出按钮,manual模式有返回+导出
|
||||
let toolbarHtml = '<div class="danmu-result-toolbar">';
|
||||
@@ -706,8 +816,8 @@ async function fetchDanmuForTest(episodeId, title, source) {
|
||||
'</div>';
|
||||
|
||||
applyDanmuFilter();
|
||||
renderDanmuStats(data, elapsed, title);
|
||||
renderDanmuHeatmap(data.comments);
|
||||
renderDanmuStats(data, elapsed, title, durationSeconds);
|
||||
renderDanmuHeatmap(data.comments, durationSeconds);
|
||||
renderDanmuList();
|
||||
|
||||
setTimeout(() => resultArea.scrollIntoView({ behavior: 'smooth', block: 'start' }), 10);
|
||||
@@ -764,12 +874,11 @@ async function exportDanmu(format) {
|
||||
// =====================
|
||||
// 弹幕统计
|
||||
// =====================
|
||||
function renderDanmuStats(data, elapsed, title) {
|
||||
function renderDanmuStats(data, elapsed, title, durationSeconds) {
|
||||
const comments = data.comments;
|
||||
const count = comments.length;
|
||||
|
||||
// 用P99分位数作为有效时长
|
||||
const maxTime = getEffectiveDuration(comments);
|
||||
const maxTime = getEffectiveDuration(comments, durationSeconds);
|
||||
|
||||
// 平均密度
|
||||
const durationMin = maxTime / 60;
|
||||
@@ -817,9 +926,9 @@ function renderDanmuStats(data, elapsed, title) {
|
||||
// =====================
|
||||
// 弹幕热力图
|
||||
// =====================
|
||||
function renderDanmuHeatmap(comments) {
|
||||
function renderDanmuHeatmap(comments, durationSeconds) {
|
||||
const container = document.getElementById('danmu-heatmap');
|
||||
const maxTime = getEffectiveDuration(comments);
|
||||
const maxTime = getEffectiveDuration(comments, durationSeconds);
|
||||
if (maxTime === 0) { container.innerHTML = '<p style="color:#999;">无数据</p>'; return; }
|
||||
|
||||
const barCount = Math.min(60, Math.max(20, Math.ceil(maxTime / 30)));
|
||||
|
||||
+7
-7
@@ -305,7 +305,9 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
const queryFormat = url.searchParams.get('format');
|
||||
const videoUrl = url.searchParams.get('url');
|
||||
const segmentFlagParam = url.searchParams.get('segmentflag');
|
||||
const durationParam = url.searchParams.get('duration');
|
||||
const segmentFlag = segmentFlagParam === 'true' || segmentFlagParam === '1';
|
||||
const includeDuration = durationParam === 'true' || durationParam === '1';
|
||||
|
||||
// ⚠️ 限流设计说明:
|
||||
// 1. 先检查缓存,缓存命中时直接返回,不计入限流次数
|
||||
@@ -318,8 +320,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
const cachedComments = getCommentCache(videoUrl);
|
||||
if (cachedComments !== null) {
|
||||
log("info", `[Rate Limit] Cache hit for URL: ${videoUrl}, skipping rate limit check`);
|
||||
const responseData = { count: cachedComments.length, comments: cachedComments };
|
||||
return formatDanmuResponse(responseData, queryFormat);
|
||||
return getCommentByUrl(videoUrl, queryFormat, segmentFlag, includeDuration);
|
||||
}
|
||||
|
||||
// 缓存未命中,执行限流检查(如果 rateLimitMaxRequests > 0 则启用限流)
|
||||
@@ -354,7 +355,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
}
|
||||
|
||||
// 通过URL获取弹幕
|
||||
return getCommentByUrl(videoUrl, queryFormat, segmentFlag);
|
||||
return getCommentByUrl(videoUrl, queryFormat, segmentFlag, includeDuration);
|
||||
}
|
||||
|
||||
// 否则通过commentId获取弹幕
|
||||
@@ -374,8 +375,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
const cachedComments = getCommentCache(urlForComment);
|
||||
if (cachedComments !== null) {
|
||||
log("info", `[Rate Limit] Cache hit for URL: ${urlForComment}, skipping rate limit check`);
|
||||
const responseData = { count: cachedComments.length, comments: cachedComments };
|
||||
return formatDanmuResponse(responseData, queryFormat);
|
||||
return getComment(path, queryFormat, segmentFlag, clientIp, includeDuration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
log("info", `[Rate Limit] IP ${clientIp} request count: ${recentRequests.length}/${globals.rateLimitMaxRequests}`);
|
||||
}
|
||||
|
||||
return getComment(path, queryFormat, segmentFlag, clientIp);
|
||||
return getComment(path, queryFormat, segmentFlag, clientIp, includeDuration);
|
||||
}
|
||||
|
||||
// POST /api/v2/segmentcomment - 接收segment类的JSON请求体
|
||||
@@ -694,4 +694,4 @@ export async function netlifyHandler(event, context) {
|
||||
}
|
||||
|
||||
// 为了测试导出 handleRequest
|
||||
export { handleRequest};
|
||||
export { handleRequest};
|
||||
+176
-1
@@ -32,7 +32,9 @@ 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"
|
||||
import { Globals } from "./configs/globals.js";
|
||||
import { addEpisode } from "./utils/cache-util.js";
|
||||
import { Segment, SegmentListResponse } from "./models/dandan-model.js"
|
||||
|
||||
// Mock Request class for testing
|
||||
class MockRequest {
|
||||
@@ -104,6 +106,179 @@ test('worker.js API endpoints', async (t) => {
|
||||
assert(title === "宇宙Marry Me?" && season == 2 && episode == 8, `Expected title === "宇宙Marry Me?" && season == 2 && episode == 8, but got ${title} ${season} ${episode}`);
|
||||
});
|
||||
|
||||
await t.test('GET /api/v2/comment/:id?format=json&duration=true should return segment duration and reuse comment cache', async () => {
|
||||
Globals.init({});
|
||||
Globals.animes = [];
|
||||
Globals.episodeIds = [];
|
||||
Globals.episodeNum = 10001;
|
||||
Globals.commentCache = new Map();
|
||||
|
||||
const originalTencentGetComments = TencentSource.prototype.getComments;
|
||||
let commentRequestCount = 0;
|
||||
let durationRequestCount = 0;
|
||||
|
||||
TencentSource.prototype.getComments = async function(url, plat, segmentFlag) {
|
||||
if (segmentFlag) {
|
||||
durationRequestCount++;
|
||||
return {
|
||||
type: 'qq',
|
||||
segmentList: [
|
||||
{ type: 'qq', segment_start: 0, segment_end: 60, url: 'mock-1' },
|
||||
{ type: 'qq', segment_start: 60, segment_end: 2760, url: 'mock-2' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
commentRequestCount++;
|
||||
return [
|
||||
{ p: '12.3,1,16777215,qq', m: '测试弹幕1' },
|
||||
{ p: '45.6,1,16777215,qq', m: '测试弹幕2' }
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
const episode = addEpisode('https://v.qq.com/x/cover/a/b.html', '【qq】测试样例');
|
||||
const req = new MockRequest(urlPrefix + '/api/v2/comment/' + episode.id + '?format=json&duration=true', { method: 'GET' });
|
||||
const res = await handleRequest(req);
|
||||
const body = await parseResponse(res);
|
||||
const cachedRes = await handleRequest(req);
|
||||
const cachedBody = await parseResponse(cachedRes);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.videoDuration, 2760);
|
||||
assert.equal(body.count, 2);
|
||||
assert.equal(body.comments.length, 2);
|
||||
assert.equal(cachedRes.status, 200);
|
||||
assert.equal(cachedBody.videoDuration, 2760);
|
||||
assert.equal(commentRequestCount, 1);
|
||||
assert.equal(durationRequestCount, 2);
|
||||
assert.equal(Globals.commentCache.size, 1);
|
||||
} finally {
|
||||
TencentSource.prototype.getComments = originalTencentGetComments;
|
||||
Globals.episodeIds = [];
|
||||
Globals.commentCache = new Map();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('GET /api/v2/comment/:id?format=json&duration=true should use merged max duration', async () => {
|
||||
Globals.init({});
|
||||
Globals.animes = [];
|
||||
Globals.episodeIds = [];
|
||||
Globals.episodeNum = 10001;
|
||||
Globals.commentCache = new Map();
|
||||
|
||||
const originalTencentGetComments = TencentSource.prototype.getComments;
|
||||
const originalIqiyiGetComments = IqiyiSource.prototype.getComments;
|
||||
const originalYoukuGetComments = YoukuSource.prototype.getComments;
|
||||
|
||||
TencentSource.prototype.getComments = async function(url, plat, segmentFlag) {
|
||||
if (segmentFlag) {
|
||||
return {
|
||||
type: 'qq',
|
||||
segmentList: [
|
||||
{ type: 'qq', segment_start: 0, segment_end: 2760, url: 'mock-qq' }
|
||||
]
|
||||
};
|
||||
}
|
||||
return [
|
||||
{ p: '12.3,1,16777215,qq', m: '腾讯弹幕' }
|
||||
];
|
||||
};
|
||||
|
||||
IqiyiSource.prototype.getComments = async function(url, plat, segmentFlag) {
|
||||
if (segmentFlag) {
|
||||
return {
|
||||
type: 'qiyi',
|
||||
segmentList: [
|
||||
{ type: 'qiyi', segment_start: 0, segment_end: 1200, url: 'mock-qiyi-1' },
|
||||
{ type: 'qiyi', segment_start: 1200, segment_end: 2682, url: 'mock-qiyi-2' }
|
||||
]
|
||||
};
|
||||
}
|
||||
return [
|
||||
{ p: '15.0,1,16777215,qiyi', m: '爱奇艺弹幕' }
|
||||
];
|
||||
};
|
||||
|
||||
YoukuSource.prototype.getComments = async function(url, plat, segmentFlag) {
|
||||
if (segmentFlag) {
|
||||
return {
|
||||
type: 'youku',
|
||||
segmentList: [
|
||||
{ type: 'youku', segment_start: 0, segment_end: 1800, url: 'mock-youku-1' },
|
||||
{ type: 'youku', segment_start: 1800, segment_end: 3000, url: 'mock-youku-2' }
|
||||
]
|
||||
};
|
||||
}
|
||||
return [
|
||||
{ p: '18.0,1,16777215,youku', m: '优酷弹幕' }
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
const episode = addEpisode(
|
||||
'tencent:https://v.qq.com/x/cover/a/b.html$$$iqiyi:https://www.iqiyi.com/v_test.html$$$youku:https://v.youku.com/v_show/id_test.html',
|
||||
'【qq&qiyi&youku】合并测试'
|
||||
);
|
||||
const req = new MockRequest(urlPrefix + '/api/v2/comment/' + episode.id + '?format=json&duration=true', { method: 'GET' });
|
||||
const res = await handleRequest(req);
|
||||
const body = await parseResponse(res);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.videoDuration, 3000);
|
||||
assert.ok(Array.isArray(body.comments));
|
||||
} finally {
|
||||
TencentSource.prototype.getComments = originalTencentGetComments;
|
||||
IqiyiSource.prototype.getComments = originalIqiyiGetComments;
|
||||
YoukuSource.prototype.getComments = originalYoukuGetComments;
|
||||
Globals.episodeIds = [];
|
||||
Globals.commentCache = new Map();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('GET /api/v2/comment/:id?format=json&duration=true should prefer explicit duration field', async () => {
|
||||
Globals.init({});
|
||||
Globals.animes = [];
|
||||
Globals.episodeIds = [];
|
||||
Globals.episodeNum = 10001;
|
||||
Globals.commentCache = new Map();
|
||||
|
||||
const originalBilibiliGetComments = BilibiliSource.prototype.getComments;
|
||||
BilibiliSource.prototype.getComments = async function(url, plat, segmentFlag) {
|
||||
if (segmentFlag) {
|
||||
return new SegmentListResponse({
|
||||
type: 'bilibili1',
|
||||
duration: 1312.76,
|
||||
segmentList: [
|
||||
{ type: 'bilibili1', segment_start: 0, segment_end: 360, url: 'mock-bili-1' },
|
||||
{ type: 'bilibili1', segment_start: 360, segment_end: 720, url: 'mock-bili-2' },
|
||||
{ type: 'bilibili1', segment_start: 720, segment_end: 1080, url: 'mock-bili-3' },
|
||||
{ type: 'bilibili1', segment_start: 1080, segment_end: 1440, url: 'mock-bili-4' }
|
||||
]
|
||||
});
|
||||
}
|
||||
return [
|
||||
{ p: '20.0,1,16777215,bilibili1', m: 'B站弹幕1' },
|
||||
{ p: '30.0,1,16777215,bilibili1', m: 'B站弹幕2' }
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
const episode = addEpisode('https://www.bilibili.com/bangumi/play/ep_test.html', '【bilibili】测试样例');
|
||||
const req = new MockRequest(urlPrefix + '/api/v2/comment/' + episode.id + '?format=json&duration=true', { method: 'GET' });
|
||||
const res = await handleRequest(req);
|
||||
const body = await parseResponse(res);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.videoDuration, 1312.76);
|
||||
assert.equal(body.count, 2);
|
||||
} finally {
|
||||
BilibiliSource.prototype.getComments = originalBilibiliGetComments;
|
||||
Globals.episodeIds = [];
|
||||
Globals.commentCache = new Map();
|
||||
}
|
||||
});
|
||||
|
||||
// await t.test('Test ai cilent', async () => {
|
||||
// const ai = new AIClient({
|
||||
// apiKey: 'xxxxxxxxxxxxxxxxxxxxx',
|
||||
|
||||
Reference in new issue
Block a user