From f04403962fde8bcde5c5c367d127947b4a65cb4c Mon Sep 17 00:00:00 2001 From: lilixu3 Date: Wed, 18 Mar 2026 00:34:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BC=B9=E5=B9=95=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=95=8C=E9=9D=A2=E7=9A=84=E8=A7=86=E9=A2=91=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E8=8E=B7=E5=8F=96=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化弹幕测试页时长获取 变更内容: - 优先从评论接口和源字段提取测试页视频时长 - 补充不同来源时长回退与测试页展示逻辑 - 更新相关文档与测试用例 * 移除测试页时长界面改动 变更内容: - 删除接口调试页面中附带时长参数与展示逻辑 - 回退测试页说明文档中的时长处理描述 - 保留评论接口时长能力与相关测试用例 * 恢复接口调试页时长参数 变更内容: - 恢复接口调试页面中的duration参数与时长展示逻辑 - 恢复UI说明文档中的时长处理描述 - 保持评论接口视频时长能力与测试页联动 * 调整测试页时长展示细节 变更内容:让 comment JSON 的 videoDuration 字段固定排在返回体开头 变更内容:接口调试页在 xml 格式下隐藏附带时长开关并在切换时清空该参数 变更内容:版本号更新为 1.17.1 --- README.md | 5 +- danmu_api/apis/dandan-api.js | 123 +++++++++++++++++++-- danmu_api/models/dandan-model.js | 5 +- danmu_api/sources/bilibili.js | 5 +- danmu_api/sources/sohu.js | 99 ++++++++++------- danmu_api/ui/README.md | 2 +- danmu_api/ui/js/apitest.js | 147 +++++++++++++++++++++---- danmu_api/worker.js | 14 +-- danmu_api/worker.test.js | 177 ++++++++++++++++++++++++++++++- 9 files changed, 496 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 0cbe508..372e3e4 100644 --- a/README.md +++ b/README.md @@ -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` > 注意: diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index d1a31d6..647e810 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -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) { // 处理异常 diff --git a/danmu_api/models/dandan-model.js b/danmu_api/models/dandan-model.js index 85e029b..9727ed7 100644 --- a/danmu_api/models/dandan-model.js +++ b/danmu_api/models/dandan-model.js @@ -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 对象 ---- diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 5c7b817..66cb35f 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -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 }); } diff --git a/danmu_api/sources/sohu.js b/danmu_api/sources/sohu.js index 455957e..97ce6d3 100644 --- a/danmu_api/sources/sohu.js +++ b/danmu_api/sources/sohu.js @@ -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 }); } diff --git a/danmu_api/ui/README.md b/danmu_api/ui/README.md index fa3c377..523be1e 100644 --- a/danmu_api/ui/README.md +++ b/danmu_api/ui/README.md @@ -84,7 +84,7 @@ UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性: - 弹幕热力图:按时间段可视化弹幕密度分布 - 弹幕列表:支持全部/滚动/顶部/底部过滤,显示颜色和类型标签,懒加载每次100条 - 导出功能:支持导出JSON和XML格式弹幕文件 -- 异常时间处理:使用P99分位数剔除异常时间点 +- 时长处理:优先请求 `/api/v2/comment/:commentId?format=json&duration=true` 返回的 `videoDuration`,没有真实时长时再回退到尾部裁剪算法 - 视图导航优化:搜索结果/剧集详情/弹幕详情逐级切换,带返回按钮和加载动画 ### 4. 推送弹幕 diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index 2726c06..3a9ac04 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -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 => \`\`).join(''); } return \` -
+
@@ -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 = '
'; @@ -706,8 +816,8 @@ async function fetchDanmuForTest(episodeId, title, source) { '
'; 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 = '

无数据

'; return; } const barCount = Math.min(60, Math.max(20, Math.ceil(maxTime / 30))); diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 59cc4c1..a21c031 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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}; \ No newline at end of file +export { handleRequest}; diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index ee64f05..521cc1f 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -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',