因 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:
1 parent
77ecdcf233
commit
fe4a96f50b
3 files changed
+163
-87
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user