优化弹幕测试界面的视频时长获取 (#239)

* 优化弹幕测试页时长获取

变更内容:
- 优先从评论接口和源字段提取测试页视频时长
- 补充不同来源时长回退与测试页展示逻辑
- 更新相关文档与测试用例

* 移除测试页时长界面改动

变更内容:
- 删除接口调试页面中附带时长参数与展示逻辑
- 回退测试页说明文档中的时长处理描述
- 保留评论接口时长能力与相关测试用例

* 恢复接口调试页时长参数

变更内容:
- 恢复接口调试页面中的duration参数与时长展示逻辑
- 恢复UI说明文档中的时长处理描述
- 保持评论接口视频时长能力与测试页联动

* 调整测试页时长展示细节

变更内容:让 comment JSON 的 videoDuration 字段固定排在返回体开头
变更内容:接口调试页在 xml 格式下隐藏附带时长开关并在切换时清空该参数
变更内容:版本号更新为 1.17.1
This commit is contained in:
lilixu3 authored and GitHub committed 2026-03-18 00:34:24 +08:00
1 parent cdbea1b77d
commit f04403962f
9 files changed
+496 -81

No files matched your search

+4 -1
View File
@@ -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`
> 注意:
+115 -8
View File
@@ -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) {
// 处理异常
+3 -2
View File
@@ -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 对象 ----
+3 -2
View File
@@ -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
View File
@@ -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
});
}
+1 -1
View File
@@ -84,7 +84,7 @@ UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性:
- 弹幕热力图:按时间段可视化弹幕密度分布
- 弹幕列表:支持全部/滚动/顶部/底部过滤,显示颜色和类型标签,懒加载每次100条
- 导出功能:支持导出JSON和XML格式弹幕文件
- 异常时间处理:使用P99分位数剔除异常时间点
- 时长处理:优先请求 `/api/v2/comment/:commentId?format=json&duration=true` 返回的 `videoDuration`,没有真实时长时再回退到尾部裁剪算法
- 视图导航优化:搜索结果/剧集详情/弹幕详情逐级切换,带返回按钮和加载动画
### 4. 推送弹幕
+128 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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',