From fe4a96f50b0b399ae09aa8d69424dd1472a0a1ac Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:01:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=A0=20related=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=BA=9F=E5=BC=83=E5=B0=86=E5=90=88=E5=B9=B6=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=9A=84=E8=B7=A8=E6=BA=90=E6=97=B6=E9=97=B4=E8=BD=B4=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=8A=9F=E8=83=BD=E6=94=B9=E5=9B=9E=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=AE=97=E6=B3=95=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- README.md | 20 +-- danmu_api/apis/dandan-api.js | 3 + danmu_api/utils/merge-util.js | 227 ++++++++++++++++++++++------------ 3 files changed, 163 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index b000f70..87a2dc7 100644 --- a/README.md +++ b/README.md @@ -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 ### 贡献者 diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 32017c6..2911eeb 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -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) { diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 2b3dbf0..3c8872d 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -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>} results - 各源弹幕数组(与 sourceNames/realIds 同序) - * @param {Array} sourceNames - 源名数组(如 ['dandan', 'bilibili']) - * @param {Array} realIds - 对应的 ID 数组(与 sourceNames 同序) - * @param {Object} [dandanShifts={}] - Dandan提供的精确偏移字典,如 { 'bilibili:bilibili.com/bangumi/play/ep1551029': 48 } + * 跨源时间轴对齐:以 dandan 为基准,对其他源计算并应用全局偏移 + * 采用最大匹配率策略: maxCount / min(dandanCount, sourceCount) + * @param {Array>} results - 各源弹幕数组 + * @param {Array} sourceNames - 源名数组 + * @param {Array} realIds - 对应的 ID 数组 + * @param {number} [minMatchRatio=0.8] - 最小匹配率阈值,默认 80% + * @param {number} [offsetThreshold=1] - 最小触发偏移阈值(秒),默认 1秒 * @returns {Array>} 对齐后的各源弹幕数组 */ -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(); +}