Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 75 additions & 61 deletions danmu_api/apis/dandan-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "../utils/cache-util.js";
import { resolveFavoriteForSearchKeyword } from "../utils/favorite-util.js";
import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js";
import { resolveOffset, resolveOffsetRule, applyOffset } from "../utils/offset-util.js";
import { resolveOffset, resolveOffsetRule, applyOffset, stripLinkOffset } from "../utils/offset-util.js";
import { filterMappingQualifierCandidates, filterMappingTargetCandidates, resolveAutoMatchMapping } from "../utils/auto-match-mapping-util.js";
import {
extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces,
Expand Down Expand Up @@ -178,9 +178,21 @@ 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);
// 单链接直接返回其时长
if (!url.includes(MERGE_DELIMITER)) {
return await resolveUrlDuration(url);
}

// 合并链接的时长取各子链接偏移后时间轴末端的最大值,而非各子链接时长的简单取大
const linkMetas = extractMergedUrls(url).map(stripLinkOffset);
const durations = await Promise.all(linkMetas.map((meta) => resolveUrlDuration(meta.cleanUrl)));
// 复用 applyOffset 的偏移语义计算各子链接末端,确保返回的合并时长与弹幕实际落点完全一致
return linkMetas.reduce((maxEnd, meta, index) => {
const sourceDuration = durations[index];
if (!(sourceDuration > 0)) return maxEnd;
const end = applyOffset([{ t: sourceDuration }], meta.offset, { usePercent: meta.percent, videoDuration: sourceDuration })[0].t;
return end > maxEnd ? end : maxEnd;
}, 0);
} catch (error) {
log('warn', `[system] [duration] 获取时长失败: ${error.message}`);
return 0;
Expand Down Expand Up @@ -614,7 +626,7 @@ async function searchAnimeBody(url, preferAnimeId = null, preferSource = null, d
// 多链接合并解析:空格分隔的多个 URL → 聚合弹幕
const urlRegex = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(:\d+)?(\/[^\s]*)?$/;
const spaceSeparatedUrls = queryTitle.split(/\s+/).filter(u => {
const cleanUrl = u.replace(/@%?-?\d+(?:\.\d+)?$/, '');
const cleanUrl = stripLinkOffset(u).cleanUrl;
return urlRegex.test(cleanUrl);
});
if (spaceSeparatedUrls.length >= 2) {
Expand All @@ -636,7 +648,7 @@ async function searchAnimeBody(url, preferAnimeId = null, preferSource = null, d
} else if (source === 'bahamut') {
titles.push(`【bahamut】 BahaSn${singleUrl.match(/sn=(\d+)/)?.[1] || '?'}`);
} else {
const pt = await sourceLogContext.run(toLogSourceName(source), () => getPageTitle(singleUrl));
const pt = await sourceLogContext.run(toLogSourceName(source), () => getPageTitle(stripLinkOffset(singleUrl).cleanUrl));
titles.push(`【${source}】 ${pt}`);
}
}
Expand Down Expand Up @@ -736,8 +748,8 @@ async function searchAnimeBody(url, preferAnimeId = null, preferSource = null, d
} else if (platform === 'hongguo') {
pageTitle = '红果短剧';
} else {
// 将源标识符统一映射到日志标签规范名称
pageTitle = await sourceLogContext.run(toLogSourceName(platform), () => getPageTitle(queryTitle));
// 将源标识符统一映射到日志标签规范名称;取标题前剥离 @偏移 后缀,避免带偏移的链接请求失败
pageTitle = await sourceLogContext.run(toLogSourceName(platform), () => getPageTitle(stripLinkOffset(queryTitle).cleanUrl));
}

const links = [{
Expand Down Expand Up @@ -1710,14 +1722,14 @@ function resolveSourceAndRealId(url) {
// Animeko: bgm.tv/bangumi.tv/chii.in/bangumi.lol/ep/xxx → animeko:xxx(@offset)
const bgmMatch = url.match(/(?:bgm\.tv|bangumi\.tv|bangumi\.lol|chii\.in)\/ep\/(\d+)/);
if (bgmMatch) {
const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/);
return { source: 'animeko', realId: bgmMatch[1] + (offsetMatch ? offsetMatch[0] : '') };
const { offset, percent } = stripLinkOffset(url);
return { source: 'animeko', realId: bgmMatch[1] + (offset !== 0 ? `@${offset}${percent ? '%' : ''}` : '') };
}
// Bahamut: ani.gamer.com.tw/animeVideo.php?sn=xxx → bahamut:xxx(@offset)
const bahaMatch = url.match(/ani\.gamer\.com\.tw\/animeVideo\.php\?sn=(\d+)/);
if (bahaMatch) {
const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/);
return { source: 'bahamut', realId: bahaMatch[1] + (offsetMatch ? offsetMatch[0] : '') };
const { offset, percent } = stripLinkOffset(url);
return { source: 'bahamut', realId: bahaMatch[1] + (offset !== 0 ? `@${offset}${percent ? '%' : ''}` : '') };
}
// 其他平台:直接传递完整 URL
const source = detectPlatformFromUrl(url);
Expand Down Expand Up @@ -2359,20 +2371,10 @@ async function fetchMergedComments(url, animeTitle, commentId) {
let realId = part.substring(firstColonIndex + 1);

// 提取链接尾部偏移值(@100/@-50 秒数偏移,@%30/@%-11 百分比偏移)
let manualOffset = 0;
let manualOffsetPercent = false;
const percentMatch = realId.match(/@%(-?\d+(?:\.\d+)?)$/);
if (percentMatch) {
manualOffset = parseFloat(percentMatch[1]);
manualOffsetPercent = true;
realId = realId.substring(0, realId.length - percentMatch[0].length);
} else {
const offsetMatch = realId.match(/@(-?\d+(?:\.\d+)?)$/);
if (offsetMatch) {
manualOffset = parseFloat(offsetMatch[1]);
realId = realId.substring(0, realId.length - offsetMatch[0].length);
}
}
const linkMeta = stripLinkOffset(realId);
const manualOffset = linkMeta.offset;
const manualOffsetPercent = linkMeta.percent;
realId = linkMeta.cleanUrl;

if (sourceName !== 'hanjutv') {
return {
Expand Down Expand Up @@ -2608,22 +2610,12 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu
const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null;

// 提取单链接偏移值(@秒数 / @%百分比)
let singleUrlOffset = 0;
let singleUrlOffsetPercent = false;
let cleanUrl = url;
const percentMatch = url.match(/@%(-?\d+(?:\.\d+)?)$/);
if (percentMatch) {
singleUrlOffset = parseFloat(percentMatch[1]);
singleUrlOffsetPercent = true;
cleanUrl = url.substring(0, url.length - percentMatch[0].length);
log("info", `[system] [LogVar-API] 检测到链接百分比偏移: ${singleUrlOffset}s`);
} else {
const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/);
if (offsetMatch) {
singleUrlOffset = parseFloat(offsetMatch[1]);
cleanUrl = url.substring(0, url.length - offsetMatch[0].length);
log("info", `[system] [LogVar-API] 检测到链接偏移: ${singleUrlOffset}s`);
}
const linkMeta = stripLinkOffset(url);
const singleUrlOffset = linkMeta.offset;
const singleUrlOffsetPercent = linkMeta.percent;
const cleanUrl = linkMeta.cleanUrl;
if (singleUrlOffset !== 0) {
log("info", `[system] [LogVar-API] 检测到链接${singleUrlOffsetPercent ? '百分比' : ''}偏移: ${singleUrlOffset}s`);
}

if (url && url.includes(MERGE_DELIMITER)) {
Expand Down Expand Up @@ -2696,8 +2688,8 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu
}
}

// 单链接偏移值应用
if (singleUrlOffset !== 0 && danmus && Array.isArray(danmus) && danmus.length > 0) {
// 单链接偏移值应用(合并链接已在 fetchMergedComments 中按来源分别应用,此处仅处理单链接)
if (!(url && url.includes(MERGE_DELIMITER)) && singleUrlOffset !== 0 && danmus && Array.isArray(danmus) && danmus.length > 0) {
if (singleUrlOffsetPercent) {
const maxTime = Math.max(...danmus.map(d => parseFloat(String(d.p).split(',')[0]) || 0), 0);
danmus = applyOffset(danmus, singleUrlOffset, { usePercent: true, videoDuration: maxTime || 1 });
Expand Down Expand Up @@ -2850,45 +2842,67 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includ
let danmus = [];
const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null;

// 提取单链接偏移值(@秒数 / @%百分比)
const linkMeta = stripLinkOffset(url);
const singleUrlOffset = linkMeta.offset;
const singleUrlOffsetPercent = linkMeta.percent;
const cleanUrl = linkMeta.cleanUrl;
if (singleUrlOffset !== 0) {
log("info", `[system] [LogVar-API] 检测到链接${singleUrlOffsetPercent ? '百分比' : ''}偏移: ${singleUrlOffset}s`);
}

// 根据URL域名判断平台并获取弹幕
if (url.includes('.qq.com')) {
danmus = await sourceLogContext.run('tencent', () => tencentSource.getComments(url, "qq", segmentFlag));
danmus = await sourceLogContext.run('tencent', () => tencentSource.getComments(cleanUrl, "qq", segmentFlag));
} else if (url.includes('.iqiyi.com')) {
danmus = await sourceLogContext.run('iqiyi', () => iqiyiSource.getComments(url, "qiyi", segmentFlag));
danmus = await sourceLogContext.run('iqiyi', () => iqiyiSource.getComments(cleanUrl, "qiyi", segmentFlag));
} else if (url.includes('.mgtv.com')) {
danmus = await sourceLogContext.run('mango', () => mangoSource.getComments(url, "imgo", segmentFlag));
danmus = await sourceLogContext.run('mango', () => mangoSource.getComments(cleanUrl, "imgo", segmentFlag));
} else if (url.includes('.bilibili.com') || url.includes('b23.tv')) {
// 如果是 b23.tv 短链接,先解析为完整 URL
if (url.includes('b23.tv')) {
url = await sourceLogContext.run('bilibili', () => bilibiliSource.resolveB23Link(url));
let resolvedUrl = cleanUrl;
if (resolvedUrl.includes('b23.tv')) {
resolvedUrl = await sourceLogContext.run('bilibili', () => bilibiliSource.resolveB23Link(resolvedUrl));
}
danmus = await sourceLogContext.run('bilibili', () => bilibiliSource.getComments(url, "bilibili1", segmentFlag));
danmus = await sourceLogContext.run('bilibili', () => bilibiliSource.getComments(resolvedUrl, "bilibili1", segmentFlag));
} else if (url.includes('.youku.com')) {
danmus = await sourceLogContext.run('youku', () => youkuSource.getComments(url, "youku", segmentFlag));
danmus = await sourceLogContext.run('youku', () => youkuSource.getComments(cleanUrl, "youku", segmentFlag));
} else if (url.includes('.miguvideo.com')) {
danmus = await sourceLogContext.run('migu', () => miguSource.getComments(url, "migu", segmentFlag));
danmus = await sourceLogContext.run('migu', () => miguSource.getComments(cleanUrl, "migu", segmentFlag));
} else if (url.includes('.sohu.com')) {
danmus = await sourceLogContext.run('sohu', () => sohuSource.getComments(url, "sohu", segmentFlag));
danmus = await sourceLogContext.run('sohu', () => sohuSource.getComments(cleanUrl, "sohu", segmentFlag));
} else if (url.includes('.le.com')) {
danmus = await sourceLogContext.run('leshi', () => leshiSource.getComments(url, "leshi", segmentFlag));
danmus = await sourceLogContext.run('leshi', () => leshiSource.getComments(cleanUrl, "leshi", segmentFlag));
} else if (url.includes('.douyin.com') || url.includes('.ixigua.com')) {
danmus = await sourceLogContext.run('xigua', () => xiguaSource.getComments(url, "xigua", segmentFlag));
danmus = await sourceLogContext.run('xigua', () => xiguaSource.getComments(cleanUrl, "xigua", segmentFlag));
} else if (url.includes('.mddcloud.com.cn')) {
danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getComments(url, "maiduidui", segmentFlag));
danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getComments(cleanUrl, "maiduidui", segmentFlag));
} else if (url.includes('.yfsp.tv')) {
danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getComments(url, "aiyifan", segmentFlag));
} else if (isHongguoPlayerUrl(url)) {
danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(url, "hongguo", segmentFlag));
danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getComments(cleanUrl, "aiyifan", segmentFlag));
} else if (isHongguoPlayerUrl(cleanUrl)) {
danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(cleanUrl, "hongguo", segmentFlag));
} else {
// 如果不是已知平台,尝试第三方弹幕服务器
const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i;
if (urlPattern.test(url)) {
danmus = await sourceLogContext.run('other', () => otherSource.getComments(url, "other_server", segmentFlag));
if (urlPattern.test(cleanUrl)) {
danmus = await sourceLogContext.run('other', () => otherSource.getComments(cleanUrl, "other_server", segmentFlag));
}
}

log("info", `[system] [LogVar-API] Successfully fetched ${danmus.length} comments from URL`);

// 单链接偏移值应用
if (singleUrlOffset !== 0 && danmus && Array.isArray(danmus) && danmus.length > 0) {
if (singleUrlOffsetPercent) {
const maxTime = Math.max(...danmus.map(d => parseFloat(String(d.p).split(',')[0]) || 0), 0);
danmus = applyOffset(danmus, singleUrlOffset, { usePercent: true, videoDuration: maxTime || 1 });
log("info", `[system] [LogVar-API] 应用链接百分比偏移 ${singleUrlOffset}s (时长=${maxTime}s)`);
} else {
danmus = applyOffset(danmus, singleUrlOffset);
log("info", `[system] [LogVar-API] 应用链接偏移 ${singleUrlOffset}s`);
}
}

// 缓存弹幕结果
if (danmus.length > 0) {
setCommentCache(cacheKey, danmus);
Expand Down
20 changes: 19 additions & 1 deletion danmu_api/utils/offset-util.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { normalizeSpaces } from './common-util.js';

// 弹幕时间偏移独立模块
// 职责:解析偏移规则、匹配偏移量、应用偏移到弹幕
// 职责:解析链接 @偏移 与偏移规则、匹配偏移量、应用偏移到弹幕

// 来源→平台别名映射(source 名和 platform 名不一致时扩展匹配)
const SOURCE_ALIASES = {
Expand Down Expand Up @@ -263,3 +263,21 @@ export function applyOffset(danmus, offsetSeconds, options = {}) {
return updated;
});
}

/**
* 从播放链接尾部解析 @偏移 后缀(@秒数 / @%百分比),返回剥离后的洁净链接与偏移参数
* @param {string} rawUrl 原始链接
* @returns {{cleanUrl: string, offset: number, percent: boolean}} 洁净链接、偏移秒数、是否百分比
*/
export function stripLinkOffset(rawUrl) {
const url = String(rawUrl ?? '');
const percentMatch = url.match(/@%(-?\d+(?:\.\d+)?)$/);
if (percentMatch) {
return { cleanUrl: url.substring(0, url.length - percentMatch[0].length), offset: parseFloat(percentMatch[1]), percent: true };
}
const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/);
if (offsetMatch) {
return { cleanUrl: url.substring(0, url.length - offsetMatch[0].length), offset: parseFloat(offsetMatch[1]), percent: false };
}
return { cleanUrl: url, offset: 0, percent: false };
}
25 changes: 23 additions & 2 deletions danmu_api/worker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import test from 'node:test';
import assert from 'node:assert';
import { handleRequest } from './worker.js';
import { extractTitleSeasonEpisode, getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, buildSearchAnimeUrl } from "./apis/dandan-api.js";
import { stripLinkOffset, applyOffset } from "./utils/offset-util.js";
import { handleFavoriteRefresh } from './apis/favorite-api.js';
import { handleClearCache } from './apis/system-api.js';
import { getRedisCaches, getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry, updateRedisCaches } from "./utils/redis-util.js";
Expand Down Expand Up @@ -1168,6 +1169,28 @@ test('worker.js API endpoints', async (t) => {
});
});

await t.test('stripLinkOffset 解析 @偏移 后缀(@秒数 / @%百分比 / 无偏移 / 合并链接仅取末段)', async () => {
assert.deepEqual(stripLinkOffset('https://x.com/v/1'), { cleanUrl: 'https://x.com/v/1', offset: 0, percent: false });
assert.deepEqual(stripLinkOffset('https://x.com/v/1@3197'), { cleanUrl: 'https://x.com/v/1', offset: 3197, percent: false });
assert.deepEqual(stripLinkOffset('https://x.com/v/1@%50'), { cleanUrl: 'https://x.com/v/1', offset: 50, percent: true });
assert.deepEqual(stripLinkOffset('https://x.com/v/1@-50'), { cleanUrl: 'https://x.com/v/1', offset: -50, percent: false });
// 合并链接仅从整条 URL 尾部取末段子链接的 @偏移
const merged = 'https://x.com/v/A$$$https://x.com/v/B@3197';
assert.deepEqual(stripLinkOffset(merged), { cleanUrl: 'https://x.com/v/A$$$https://x.com/v/B', offset: 3197, percent: false });
// 容错:非字符串入参不抛错
assert.deepEqual(stripLinkOffset(null), { cleanUrl: '', offset: 0, percent: false });
});

await t.test('applyOffset 应用 @偏移 的端点语义(绝对 = 时间+偏移,百分比端点 = 最大时间+偏移)', async () => {
const danmus = [{ p: '10,1,16777215,b' }, { p: '20,1,16777215,b' }];
// 绝对偏移:每条弹幕时间整体平移 offset 秒
assert.deepEqual(applyOffset(danmus, 50).map(d => d.p), ['60.00,1,16777215,b', '70.00,1,16777215,b']);
// 百分比偏移:按时间轴缩放,scaleRatio=(maxTime+offset)/maxTime,端点 maxTime → maxTime+offset
assert.deepEqual(applyOffset(danmus, 50, { usePercent: true, videoDuration: 20 }).map(d => d.p), ['35.00,1,16777215,b', '70.00,1,16777215,b']);
// 合并时长端点一致性:单链接时长 3266、偏移 3197 时,合并时间轴末端为 6463
assert.equal(applyOffset([{ t: 3266 }], 3197, { usePercent: false, videoDuration: 3266 })[0].t, 6463);
});

// 测试 Bangumi Data 本地检索结果的同源去重
await t.test('dedupeBangumiSearchResults should dedupe same-source results and skip tmdb', () => {
const makeResult = (siteKey, siteId, titles) => ({ matchedSiteKey: siteKey, siteId, titles });
Expand Down Expand Up @@ -1196,8 +1219,6 @@ test('worker.js API endpoints', async (t) => {
assert.equal(crossSite.length, 2, `Expected crossSite.length === 2, but got ${crossSite.length}`);
});

});

// 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 = [];
Expand Down
Loading