因 related 接口废弃将合并工具的跨源时间轴对齐功能改回本地算法 (#275)

* merge-util.js:因 related 接口废弃将跨源时间轴对齐功能改回本地算法

天塌了(

* README.md:更新平台列表添加特别感谢栏

* merge-util.js:更新跨源时间轴对齐算法

* dandan-api.js:重写跨源时间轴对齐调用函数

* merge-util.js:精简跨源时间轴对齐算法

* merge-util.js:为弹弹豁免匹配率同时修复缺集导致的集映射错误

修复弹弹因为从零开始的异世界生活第四季因为集刮削错误缺2~4集导致的集映射错误

---------

Co-authored-by: huangxd- <649924757@qq.com>
This commit is contained in:
Elegy233andhuangxd- authored and GitHub committed 2026-04-11 14:01:38 +08:00
1 parent 77ecdcf233
commit fe4a96f50b
3 files changed
+163 -87

No files matched your search

+12 -8
View File
@@ -519,7 +519,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| renren | renren |
| hanjutv | hanjutv |
| bahamut | bahamut |
| dandan | dandan |
| dandan | [弹弹play](https://www.dandanplay.com/) |
| animeko | [animeko](https://github.com/open-ani/animeko) |
| custom | custom |
@@ -662,9 +662,15 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
- TMDB源在SOURCE_ORDER添加tmdb的同时,需要添加TMDB_API_KEY环境变量
- 弹幕分片下载请求已加入重试机制,重试次数为1次
- 如果同时配置了本地缓存和upstash redis缓存和本地redis缓存,优先级为本地redis > upstash redis缓存 > 本地缓存
- 弹弹play源用的是第三方接口,感谢开源项目 [danmaku-anywhere](https://github.com/Mr-Quin/danmaku-anywhere)
- 有任何问题,如部署/环境变量配置等,可通过deepwiki对本项目进行提问,链接入口:https://deepwiki.com/huangxd-/danmu_api,其中项目内容一般每周刷新一次
### 部署完成后在播放器填写后弹幕未生效自主排查步骤
以API示例 `http://192.168.1.7:9321/87654321` 为例(默认为87654321的情况下也可以不带token)
1. 首先确认你的api部署成功 访问 `http://192.168.1.7:9321/87654321` 有json输出
2. 检查你在播放器的填写是否正确,有无多余空格等
3. 播放器请求后,查看 `http://192.168.1.7:9321/87654321/api/logs` 日志,看请求是否有报错,比如有用户在自己软路由上搭建,但走了全局代理,导致人人等访问不了,请确保走直连
4. 如果你播放的影片片名不规范,很可能搜不到,请确保片名规范
### 关联项目
[喂饭教程1:danmu_api vercel 自动同步部署方案 - 永远保持最新版本!实时同步原作者更新](https://github.com/xiaoyao20084321/log-var-danmu-deployment-guide)
@@ -674,12 +680,10 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
[喂饭教程4:使用Vercel搭建万能反向代理,部署后请绑定自定义域名使用](https://github.com/souying/vercel-api-proxy)
### 部署完成后在播放器填写后弹幕未生效自主排查步骤
以API示例 `http://192.168.1.7:9321/87654321` 为例(默认为87654321的情况下也可以不带token)
1. 首先确认你的api部署成功 访问 `http://192.168.1.7:9321/87654321` 有json输出
2. 检查你在播放器的填写是否正确,有无多余空格等
3. 播放器请求后,查看 `http://192.168.1.7:9321/87654321/api/logs` 日志,看请求是否有报错,比如有用户在自己软路由上搭建,但走了全局代理,导致人人等访问不了,请确保走直连
4. 如果你播放的影片片名不规范,很可能搜不到,请确保片名规范
### 特别感谢
- 开源项目 [danmaku-anywhere](https://github.com/Mr-Quin/danmaku-anywhere) 提供的[弹弹play开放平台](https://doc.dandanplay.com/open/)接口
- 开源项目 [animeko](https://github.com/open-ani/animeko) 提供的弹幕API
### 贡献者
<a href="https://github.com/huangxd-/danmu_api/graphs/contributors">
+3
View File
@@ -1496,6 +1496,9 @@ async function fetchMergedComments(url, animeTitle, commentId) {
// 等待所有源请求完成
const results = await Promise.all(tasks);
// 调用以dandan为基准的跨源时间轴对齐函数(仅当存在 dandan 源时执行)
alignSourceTimelines(results, sourceNames, realIds);
// 按来源分别应用弹幕时间偏移(对齐后、合并前)
if (globals.danmuOffsetRules?.length > 0 && animeTitle && commentId) {
+148 -79
View File
@@ -860,7 +860,8 @@ function checkDateMatch(dateA, dateB, isDub = false) {
* @returns {boolean} 是否允许合并
*/
function isMergeRatioValid(mergedCount, totalA, totalB, sourceA, sourceB, isAnyCollection = false) {
if (sourceA === 'animeko' || sourceB === 'animeko') return true;
if (/^(dandan|animeko)$/i.test(sourceA) || /^(dandan|animeko)$/i.test(sourceB)) return true;
if (isAnyCollection) {
const minTotal = Math.min(totalA, totalB);
if (minTotal > 0 && (mergedCount / minTotal) > 0.5) return true;
@@ -1923,8 +1924,6 @@ async function processMergeTask(params) {
let accumulatedCount = 0;
for (let s = 1; s < seasonNum; s++) accumulatedCount += (seasonLengthMap.get(s) || 0);
// 移除 -2 缓冲。既然使用 Mode 策略得到了较准确的长度,直接信任该长度。
// 这样可以避免 S2 的第 1 集错误地回溯匹配到 S1 的最后几集。
let safeAccumulated = accumulatedCount;
if (safeAccumulated >= collectionLinks.length) {
@@ -1974,16 +1973,69 @@ async function processMergeTask(params) {
let mergedCount = 0;
const redundantS = identifyRedundantTitle(derivedMatch.links, derivedMatch.animeTitle, secSource);
// 提取当前的共识集数差 (Consensus Shift)
let consensusShift = null;
const shiftCounts = new Map();
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
const pIndex = k + offset;
const pIdx = k + offset;
if (pIdx >= 0 && pIdx < filteredPLinksWithIndex.length) {
const pLinkItem = filteredPLinksWithIndex[pIdx];
const sLinkItem = filteredMLinksWithIndex[k];
const infoP = extractEpisodeInfo(getTempTitle(pLinkItem.link.title || pLinkItem.link.name, redundantP), currentPrimarySource);
const infoS = extractEpisodeInfo(getTempTitle(sLinkItem.link.title || sLinkItem.link.name, redundantS), secSource);
if (infoP.num !== null && infoS.num !== null && !infoP.isSpecial && !infoS.isSpecial) {
const diff = infoP.num - infoS.num;
shiftCounts.set(diff, (shiftCounts.get(diff) || 0) + 1);
}
}
}
let maxShiftCount = 0;
for (const [diff, count] of shiftCounts.entries()) {
if (count > maxShiftCount) {
maxShiftCount = count;
consensusShift = diff;
}
}
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
let pIndex = k + offset;
const sourceLinkItem = filteredMLinksWithIndex[k];
const sourceLink = sourceLinkItem.link;
const sTitleShort = sourceLink.name || sourceLink.title || `Index ${k}`;
const orphanItem = { link: sourceLink, originalIndex: sourceLinkItem.originalIndex, relativeIndex: pIndex, info: null };
const cleanTitleS = getTempTitle(sourceLink.title, redundantS);
const cleanTitleS = getTempTitle(sourceLink.title || sourceLink.name, redundantS);
orphanItem.info = extractEpisodeInfo(cleanTitleS, secSource);
// 智能数值对齐:破解源缺集导致的数组错位,并为落单集计算精确小数排序
if (consensusShift !== null && orphanItem.info.num !== null && !orphanItem.info.isSpecial) {
const targetNum = orphanItem.info.num + consensusShift;
const exactMatchIndex = filteredPLinksWithIndex.findIndex(pItem => {
const pTitle = getTempTitle(pItem.link.title || pItem.link.name, redundantP);
const pInfo = extractEpisodeInfo(pTitle, currentPrimarySource);
return pInfo.num === targetNum && !pInfo.isSpecial;
});
if (exactMatchIndex !== -1) {
pIndex = exactMatchIndex;
orphanItem.relativeIndex = pIndex;
} else {
pIndex = -1; // 理论存在的集数在主源找不到,强制设为落单
let closestIdx = -0.5;
for (let i = 0; i < filteredPLinksWithIndex.length; i++) {
const pItem = filteredPLinksWithIndex[i];
const pTitle = getTempTitle(pItem.link.title || pItem.link.name, redundantP);
const pInfo = extractEpisodeInfo(pTitle, currentPrimarySource);
if (pInfo.num !== null && !pInfo.isSpecial && pInfo.num < targetNum) {
closestIdx = i;
}
}
orphanItem.relativeIndex = closestIdx + (k * 0.001) + 0.1;
}
} else {
orphanItem.relativeIndex = pIndex !== -1 ? pIndex : (k + offset);
}
if (pIndex >= 0 && pIndex < filteredPLinksWithIndex.length) {
const originalPIndex = filteredPLinksWithIndex[pIndex].originalIndex;
const targetLink = derivedAnime.links[originalPIndex];
@@ -2034,7 +2086,7 @@ async function processMergeTask(params) {
mergedCount++;
pendingMutations.push({ linkIndex: originalPIndex, newUrl: newMergedUrl, newTitle: newMergedTitle });
} else {
mappingEntries.push({ idx: pIndex, text: ` [落单] (主源越界) <-> ${sTitleShort}` });
mappingEntries.push({ idx: orphanItem.relativeIndex, text: ` [落单] (主源越界) <-> ${sTitleShort}` });
orphanedEpisodes.push(orphanItem);
}
}
@@ -2582,97 +2634,90 @@ export function mergeDanmakuList(listA, listB) {
}
/**
* 跨源时间轴对齐:应用 dandan related 接口下发的准确偏移量实现对齐
* @param {Array<Array<Object>>} results - 各源弹幕数组(与 sourceNames/realIds 同序)
* @param {Array<string>} sourceNames - 源名数组(如 ['dandan', 'bilibili'])
* @param {Array<string>} realIds - 对应的 ID 数组(与 sourceNames 同序)
* @param {Object} [dandanShifts={}] - Dandan提供的精确偏移字典,如 { 'bilibili:bilibili.com/bangumi/play/ep1551029': 48 }
* 跨源时间轴对齐:以 dandan 为基准,对其他源计算并应用全局偏移
* 采用最大匹配率策略: maxCount / min(dandanCount, sourceCount)
* @param {Array<Array<Object>>} results - 各源弹幕数组
* @param {Array<string>} sourceNames - 源名数组
* @param {Array<string>} realIds - 对应的 ID 数组
* @param {number} [minMatchRatio=0.8] - 最小匹配率阈值,默认 80%
* @param {number} [offsetThreshold=1] - 最小触发偏移阈值(秒),默认 1秒
* @returns {Array<Array<Object>>} 对齐后的各源弹幕数组
*/
export function alignSourceTimelines(results, sourceNames, realIds, dandanShifts = {}) {
const dandanIndex = sourceNames.findIndex(name => name === 'dandan');
if (dandanIndex === -1 || !results[dandanIndex] || results[dandanIndex].length === 0) {
export function alignSourceTimelines(results, sourceNames, realIds, minMatchRatio = 0.8, offsetThreshold = 1) {
const dandanIndex = sourceNames.indexOf('dandan');
if (dandanIndex === -1 || !results[dandanIndex]?.length) {
log("info", "[Merge][AlignTimeline] 无 dandan 源或无数据,跳过时间轴对齐");
return results;
}
// 核心标识提取函数:滤除干扰因素,提取唯一特征以供比对
const getCoreIdentifier = (targetStr, sName) => {
if (sName === 'bahamut') {
const match = targetStr.match(/sn=(\d+)/) || targetStr.match(/\d+$/);
return match ? (match[1] || match[0]) : targetStr;
}
let core = targetStr.replace(/^https?:\/\/(www\.)?/, '');
if (sName === 'bilibili' || sName === 'bilibili1') {
if (/\/combine\?/.test(core)) {
return core.replace(/#.*/, '');
}
const pMatch = core.match(/\b(p=\d+)\b/);
core = core.replace(/\?.*/, '');
if (pMatch) {
core += `?${pMatch[1]}`;
}
return core;
}
return core.replace(/\?.*/, '');
};
const dandanList = results[dandanIndex];
const dandanTotalCount = dandanList.length;
const dandanTextMap = new Map();
// 遍历其他来源进行对齐
for (let idx = 0; idx < results.length; idx++) {
dandanList.forEach(dd => {
const text = normalizeText(getDanmuText(dd));
const time = getDanmuTime(dd);
if (text && (!dandanTextMap.has(text) || time < dandanTextMap.get(text))) {
dandanTextMap.set(text, time);
}
});
results.forEach((list, idx) => {
const sourceName = sourceNames[idx];
const realId = realIds[idx];
const list = results[idx];
if (sourceName === 'dandan' || !list?.length) return;
if (sourceName === 'dandan' || !Array.isArray(list) || list.length === 0) {
continue;
}
const offsetCounts = new Map();
const parsedCache = [];
let matchCount = 0;
const coreMId = getCoreIdentifier(realId, sourceName);
let appliedShift = undefined;
list.forEach(danmu => {
const text = normalizeText(getDanmuText(danmu));
const time = getDanmuTime(danmu);
parsedCache.push({ danmu, time });
// 遍历 dandanShifts 寻找匹配当前核心特征的精确偏移量
for (const [key, shift] of Object.entries(dandanShifts)) {
const prefix = sourceName + ':';
if (key.startsWith(prefix)) {
const shiftCoreUrl = key.substring(prefix.length);
// 核心特征双向包含比对
if (shiftCoreUrl.includes(coreMId) || coreMId.includes(shiftCoreUrl)) {
appliedShift = shift;
break;
}
if (text && dandanTextMap.has(text)) {
matchCount++;
const offset = Math.round(time - dandanTextMap.get(text));
offsetCounts.set(offset, (offsetCounts.get(offset) || 0) + 1);
}
});
let bestOffset = 0, maxCount = 0;
offsetCounts.forEach((count, offset) => {
if (count > maxCount) { maxCount = count; bestOffset = offset; }
});
const minCount = Math.min(dandanTotalCount, list.length);
const effectiveRatio = maxCount / minCount;
const consensusRatio = matchCount > 0 ? maxCount / matchCount : 0;
if ((matchCount / minCount) < minMatchRatio || effectiveRatio < 0.05 || consensusRatio < 0.15) {
log("info", `[Merge][AlignTimeline] ${sourceName}:${realIds[idx]} 匹配率或集中度过低 (有效:${(effectiveRatio*100).toFixed(1)}%, 集中度:${(consensusRatio*100).toFixed(1)}%),跳过对齐`);
return;
}
// 仅根据 dandan related 接口提供的精确偏移量进行对齐,未命中则跳过
if (appliedShift !== undefined) {
log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 应用 dandan API 精确偏移量 ${appliedShift}s`);
if (Math.abs(bestOffset) < offsetThreshold) {
log("info", `[Merge][AlignTimeline] ${sourceName}:${realIds[idx]} 最佳偏移 ${bestOffset}s 低于阈值,无需对齐`);
return;
}
for (let i = 0; i < list.length; i++) {
const danmu = list[i];
const time = getDanmuTime(danmu);
// 原本 time 是基准,shift表示加上该偏差到达 Dandan 时间轴
const targetTime = Math.max(0, time + appliedShift);
log("info", `[Merge][AlignTimeline] ${sourceName}:${realIds[idx]} 应用偏移 ${bestOffset}s (获 ${maxCount} 票)`);
// 替换位移
if (danmu.p && typeof danmu.p === 'string') {
const firstComma = danmu.p.indexOf(',');
if (firstComma !== -1) {
danmu.p = targetTime.toFixed(2) + danmu.p.substring(firstComma);
}
}
if (danmu.t !== undefined && danmu.t !== null) {
danmu.t = targetTime;
}
if (typeof danmu.progress === 'number') {
danmu.progress = Math.round(targetTime * 1000);
}
parsedCache.forEach(({ danmu, time }) => {
const targetTime = Math.max(0, time - bestOffset);
if (typeof danmu.p === 'string') {
danmu.p = danmu.p.replace(/^[^,]+(?=,)/, targetTime.toFixed(2));
}
} else {
log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 未找到相关 API 偏移量数据,跳过时间轴对齐`);
}
}
if (danmu.t != null) {
danmu.t = targetTime;
}
if (typeof danmu.progress === 'number') {
danmu.progress = Math.round(targetTime * 1000);
}
});
});
// 直接返回修改后的原始数组,不再产生深拷贝开销
return results;
}
@@ -2692,3 +2737,27 @@ function getDanmuTime(danmu) {
}
return 0;
}
/**
* 获取弹幕文本
* @param {Object} danmu
* @returns {string}
*/
function getDanmuText(danmu) {
if (danmu) {
if (typeof danmu.m === 'string') return danmu.m;
if (typeof danmu.text === 'string') return danmu.text;
if (typeof danmu.content === 'string') return danmu.content;
}
return '';
}
/**
* 文本标准化
* @param {string} text
* @returns {string}
*/
function normalizeText(text) {
if (!text || typeof text !== 'string') return '';
return text.replace(/[\s.,!?"'(){}\[\]<>;:,。!?、“”‘’()【】《》;:~~]/g, '').toLowerCase();
}