From a656308fc2e3ef84c84ebc7b78b623f9ecc5ed51 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:40:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BC=B9=E5=BC=B9=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E5=BC=B9=E5=B9=95=E6=BA=90=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=8B=89=E5=8F=96=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=94=A8=E4=BA=8E?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=8E=89=E5=BC=B9=E5=BC=B9=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E5=86=85=E7=BC=93=E5=AD=98=E7=9A=84=E6=97=A7=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dandan.js:增加related接口,实现拉取绑定的第三方数据源 同时传出related接口的偏移值和绑定链接给合并工具的跨源时间轴对齐使用 * dandan-api.js:挂载dandan源传出的相关参数,同时更新源标签的标记方式 * merge-util.js:接收来自dandan源准确的偏移值和链接信息,替换掉低精度计算 * danmu-util.js:基于准确的源标签做更加完美的相关处理 * envs.js:增加弹弹第三方数据源实时拉取开关,默认开启 * systemsettings.js:增加一个默认值为true的变量保持开启状态 * .env.example:增加弹弹第三方数据源实时拉取开关说明 * Add REAL_TIME_PULL_DANDAN option to README * .env.example:统一弹弹第三方数据源实时拉取说明 * .env.example:将第三方数据源统称为第三方弹幕源 * envs.js:将第三方数据源统称为第三方弹幕源 * README.md:将第三方数据源统称为第三方弹幕源 * merge-util.js:去除已经没有使用的旧函数 * systemsettings.js:REAL_TIME_PULL_DANDAN不再保持true * dandan.js:related接口根据开关动态请求 只在REAL_TIME_PULL_DANDAN实时拉取开启状态或者启用了MERGE_SOURCE_PAIRS合并功能才请求 * envs.js:实时拉取默认设置为关闭 * .env.example:实时拉取默认设置为关闭 * README.md:实时拉取默认设置为关闭 --- README.md | 1 + config/.env.example | 6 + danmu_api/apis/dandan-api.js | 19 ++- danmu_api/configs/envs.js | 6 +- danmu_api/sources/dandan.js | 241 ++++++++++++++++++++++++++++++---- danmu_api/utils/danmu-util.js | 66 ++++++---- danmu_api/utils/merge-util.js | 157 +++++++--------------- 7 files changed, 337 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index de4300e..664acd0 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | +| REAL_TIME_PULL_DANDAN | 【可选】dandan第三方弹幕源实时拉取开关,默认为`false`(关闭),可选值:`true`、`false`,用于在获取dandan源弹幕时重新拉取绑定的第三方弹幕源弹幕,覆盖dandan服务器内缓存的旧弹幕,实际拉取时需要在SOURCE_ORDER中先开启对应的源(dandan平台允许绑定的源:bilibili, bahamut, iqiyi, youku, tencent, imgo) | | SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,renren,hanjutv,bahamut,dandan,animeko,custom` | | PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, renren, hanjutv, bahamut, dandan, animeko, custom` | | MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko,imgo`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,renren,hanjutv,bahamut,dandan,animeko` | diff --git a/config/.env.example b/config/.env.example index f4add15..6717cb3 100644 --- a/config/.env.example +++ b/config/.env.example @@ -71,6 +71,12 @@ VOD_REQUEST_TIMEOUT=10000 # 默认值:8 YOUKU_CONCURRENCY=8 +# ==================== 弹弹配置 ==================== +# 弹弹第三方数据源实时拉取开关(可选值:true、false) +# 默认值:false(关闭) +# 说明:开启后在获取弹弹源弹幕时重新拉取绑定的第三方数据源弹幕,覆盖弹弹服务器缓存的旧弹幕,实际拉取时需要在SOURCE_ORDER中开启对应的源(弹弹平台允许绑定的源:bilibili, bahamut, iqiyi, youku, tencent, imgo) +REAL_TIME_PULL_DANDAN=false + # ==================== 源排序配置 ==================== # 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, animeko, custom) diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 77e78cf..f5c23d3 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -1269,8 +1269,19 @@ async function fetchMergedComments(url) { if (sourceInstance) { try { // 获取原始数据 -> 格式化 - const raw = await sourceInstance.getEpisodeDanmu(realId); + const raw = await sourceInstance.getEpisodeDanmu(realId, parts); const formatted = sourceInstance.formatComments(raw); + + // 给合并工具里的每一条弹幕打上独立的原始源标签 + if (formatted && Array.isArray(formatted)) { + formatted.forEach(item => item._sourceLabel = sourceName); + } + + // 提取并挂载 dandan 源传出的精确偏移量 + if (raw && raw.relatedShifts) { + formatted.relatedShifts = raw.relatedShifts; + } + stats[sourceName] = formatted.length; return formatted; } catch (e) { @@ -1303,8 +1314,12 @@ async function fetchMergedComments(url) { return firstColonIndex === -1 ? '' : part.substring(firstColonIndex + 1); }); + // 提取精确偏移集合 + const dandanIndex = sourceNames.indexOf('dandan'); + const dandanShifts = (results[dandanIndex] && results[dandanIndex].relatedShifts) ? results[dandanIndex].relatedShifts : {}; + // 执行对齐函数 - alignSourceTimelines(results, sourceNames, realIds); + alignSourceTimelines(results, sourceNames, realIds, dandanShifts); } // 3. 合并数据 diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index f78738e..d1ae884 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -369,6 +369,7 @@ export class Envs { // 源配置 'SOURCE_ORDER': { category: 'source', type: 'multi-select', options: this.ALLOWED_SOURCES, description: '源排序配置,默认360,vod,renren,hanjutv' }, + 'MERGE_SOURCE_PAIRS': { category: 'source', type: 'multi-select', options: this.MERGE_ALLOWED_SOURCES, description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,允许多组,允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源。\n格式:源1&源2&源3 ,多组用逗号分隔。\n示例:dandan&animeko&bahamut,bilibili&animeko,dandan' }, 'OTHER_SERVER': { category: 'source', type: 'text', description: '第三方弹幕服务器,默认https://api.danmu.icu' }, 'CUSTOM_SOURCE_API_URL': { category: 'source', type: 'text', description: '自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源' }, 'VOD_SERVERS': { category: 'source', type: 'text', description: 'VOD站点配置,格式:名称@URL,名称@URL,默认金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top' }, @@ -376,7 +377,7 @@ export class Envs { 'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 }, 'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' }, 'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 }, - 'MERGE_SOURCE_PAIRS': { category: 'source', type: 'multi-select', options: this.MERGE_ALLOWED_SOURCES, description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,允许多组,允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源。\n格式:源1&源2&源3 ,多组用逗号分隔。\n示例:dandan&animeko&bahamut,bilibili&animeko,dandan' }, + 'REAL_TIME_PULL_DANDAN': { category: 'source', type: 'boolean', description: '弹弹第三方弹幕源实时拉取开关,默认为false(关闭),可选值:true、false' }, // 匹配配置 'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置,可以配置自动匹配时的优选平台。\n当配置合并平台的时候,可以指定期望的合并源,\n示例:一个结果返回了"dandan&bilibili1&animeko"和"youku"时,\n当配置"youku"时返回"youku" \n当配置"dandan&animeko"时返回"dandan&bilibili1&animeko"' }, @@ -428,6 +429,7 @@ export class Envs { token: this.get('TOKEN', '87654321', 'string', true), // token,默认为87654321 adminToken: this.get('ADMIN_TOKEN', '', 'string', true), // admin token,用于系统管理访问控制 sourceOrderArr: this.resolveSourceOrder(), // 源排序 + mergeSourcePairs: this.resolveMergeSourcePairs(), // 源合并配置,用于将源合并获取 otherServer: this.get('OTHER_SERVER', 'https://api.danmu.icu', 'string'), // 第三方弹幕服务器 customSourceApiUrl: this.get('CUSTOM_SOURCE_API_URL', '', 'string', true), // 自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源 vodServers: this.resolveVodServers(), // vod站点配置,格式:名称@URL,名称@URL @@ -435,7 +437,7 @@ export class Envs { vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '10000', 'string'), // vod超时时间(默认10秒) bilibliCookie: this.get('BILIBILI_COOKIE', '', 'string', true), // b站cookie youkuConcurrency: Math.min(this.get('YOUKU_CONCURRENCY', 8, 'number'), 16), // 优酷并发配置 - mergeSourcePairs: this.resolveMergeSourcePairs(), // 源合并配置,用于将源合并获取 + realTimePullDandan: this.get('REAL_TIME_PULL_DANDAN', false, 'boolean'), // 弹弹第三方数据源实时拉取开关 platformOrderArr: this.resolvePlatformOrder(), // 自动匹配优选平台 animeTitleFilter: this.resolveAnimeTitleFilter(), // 剧名正则过滤 episodeTitleFilter: this.resolveEpisodeTitleFilter(), // 剧集标题正则过滤 diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index 00b7386..57d20ca 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -6,6 +6,19 @@ import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized } from "../utils/zh-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js"; +import TencentSource from "./tencent.js"; +import IqiyiSource from "./iqiyi.js"; +import MangoSource from "./mango.js"; +import BilibiliSource from "./bilibili.js"; +import YoukuSource from "./youku.js"; +import BahamutSource from "./bahamut.js"; + +const tencentSource = new TencentSource(); +const iqiyiSource = new IqiyiSource(); +const mangoSource = new MangoSource(); +const bilibiliSource = new BilibiliSource(); +const youkuSource = new YoukuSource(); +const bahamutSource = new BahamutSource(); // ===================== // 获取弹弹play弹幕 @@ -321,33 +334,140 @@ export default class DandanSource extends BaseSource { return tmpAnimes; } - async getEpisodeDanmu(id) { + // 接收 mergedSources 参数,包含所有参与合并的具体源链接信息,用于避免重复获取 + async getEpisodeDanmu(id, mergedSources = []) { let allDanmus = []; + let relatedShifts = {}; // 存储传给合并工具的精确偏移值,格式: { 'sourceName:coreUrl': shift } + const stats = {}; // 统计各源弹幕数量 try { - const resp = await httpGet(`https://api.danmaku.weeblify.app/ddp/v1?path=%2Fv2%2Fcomment%2F${id}%3Ffrom%3D0%26withRelated%3Dtrue%26chConvert%3D0`, { + // 获取 dandan 弹幕 + const dandanPromise = httpGet(`https://api.danmaku.weeblify.app/ddp/v1?path=%2Fv2%2Fcomment%2F${id}%3Ffrom%3D0%26withRelated%3Dtrue%26chConvert%3D0`, { headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, retries: 1, - }); + }).catch(e => { log('error', `dandan base comments error: ${e.message}`); return null; }); - // 将当前请求的 episodes 拼接到总数组 - if (resp.data && resp.data.comments) { - allDanmus = resp.data.comments; + // 根据功能开关决定是否请求 related 关联数据,避免未开启功能时产生无效的网络开销 + // 判定条件:全局开启了实时拉取,或全局开启了合并功能,或当前请求明确处于合并管线中 + let relatedPromise = Promise.resolve(null); + if (globals.realTimePullDandan || (globals.mergeSourcePairs && globals.mergeSourcePairs.length > 0) || (mergedSources && mergedSources.length > 0)) { + relatedPromise = httpGet(`https://api.danmaku.weeblify.app/ddp/v1?path=/v2/related/${id}`, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + retries: 1, + }).catch(e => { log('error', `dandan related data error: ${e.message}`); return null; }); + } + + const [resp, relatedResp] = await Promise.all([dandanPromise, relatedPromise]); + + if (resp && resp.data && resp.data.comments) { + allDanmus = resp.data.comments; + stats['dandan'] = allDanmus.length; + } else { + stats['dandan'] = 0; + } + + // 处理第三方关联源 + if (relatedResp && relatedResp.data && relatedResp.data.relateds) { + const relatedTasks = []; + + // 核心标识提取函数:滤除干扰因素,提取唯一特征以供比对 + const getCoreIdentifier = (targetStr, sName) => { + // 巴哈姆特专属逻辑:提取纯数字ID + if (sName === 'bahamut') { + const match = targetStr.match(/sn=(\d+)/) || targetStr.match(/\d+$/); + return match ? (match[1] || match[0]) : targetStr; + } + // 常规平台逻辑:统一剥离 http/https协议、www.前缀,并截断 ? 后面的所有查询参数 + return targetStr.replace(/^https?:\/\/(www\.)?/, '').split('?')[0]; + }; + + for (const rel of relatedResp.data.relateds) { + const url = rel.url; + const shift = rel.shift || 0; + const sourceInfo = this.parseRelatedUrl(url); + + if (!sourceInfo) continue; + + const { sourceName } = sourceInfo; + const coreUrl = getCoreIdentifier(url, sourceName); + + // 构建唯一键存储偏移量,包含平台与核心标识,防止同平台多链接导致数据覆盖 + relatedShifts[`${sourceName}:${coreUrl}`] = shift; + + // 拦截:判断用户是否开启实时拉取功能 + if (!globals.realTimePullDandan) { + continue; + } + + // 拦截:判断用户是否开启该源 + if (!globals.sourceOrderArr.includes(sourceName)) { + continue; + } + + // 拦截:判断当前关联的具体链接是否已被合并工具明确包含,支持同源多链接的精细区分 + const isAlreadyMerged = mergedSources.some(part => { + const firstColonIndex = part.indexOf(':'); + if (firstColonIndex === -1) return false; + const mSource = part.substring(0, firstColonIndex); + const mId = part.substring(firstColonIndex + 1); + + // 来源标识必须一致 + if (mSource !== sourceName) return false; + + const coreMId = getCoreIdentifier(mId, mSource); + + // 核心特征双向包含比对 + return coreUrl.includes(coreMId) || coreMId.includes(coreUrl); + }); + + if (isAlreadyMerged) { + log("info", `[Dandan] 链接 ${url} 已被合并工具包含,交由合并工具处理,跳过原生实时拉取`); + continue; + } + + // 执行本地实时拉取,并应用偏移,同时统计返回的弹幕数量 + log("info", `[Dandan] 触发第三方源实时拉取: ${sourceName} - ${url} (偏移: ${shift}s)`); + relatedTasks.push( + this.pullRealTimeDanmu(sourceName, url, shift).then(comments => { + stats[sourceName] = (stats[sourceName] || 0) + comments.length; + return comments; + }) + ); + } + + // 并发等待所有第三方弹幕请求 + if (relatedTasks.length > 0) { + const extraDanmusArrays = await Promise.all(relatedTasks); + for (const extra of extraDanmusArrays) { + if (extra && Array.isArray(extra) && extra.length > 0) { + allDanmus = allDanmus.concat(extra); + } + } + + // 汇总日志:仅在产生实际拉取任务时,输出拉取总数与原生基础数据 + const totalCount = allDanmus.length; + const dandanCount = stats['dandan'] || 0; + log("info", `[Dandan] 实时拉取原始数据完成: 现总计 ${totalCount} 条 (原始弹幕数:${dandanCount})`); + } } - return allDanmus; } catch (error) { - // 捕获请求中的错误 - log("error", "fetchDandanEpisodeDanmu error:", { + log("error", "getEpisodeDanmu error:", { message: error.message, name: error.name, stack: error.stack, }); - return allDanmus; // 返回已收集的 episodes } + + // 挂载精确偏移量字典,以便外层截获 + allDanmus.relatedShifts = relatedShifts; + return allDanmus; } async getEpisodeDanmuSegments(id) { @@ -368,19 +488,94 @@ export default class DandanSource extends BaseSource { return this.getEpisodeDanmu(segment.url); } +// 解析 Related 接口中的 URL 对应内部源标识 + parseRelatedUrl(url) { + if (!url) return null; + if (url.includes('bilibili.com')) return { sourceName: 'bilibili' }; + if (url.includes('gamer.com.tw')) return { sourceName: 'bahamut' }; + if (url.includes('iqiyi.com')) return { sourceName: 'iqiyi' }; + if (url.includes('youku.com')) return { sourceName: 'youku' }; + if (url.includes('qq.com')) return { sourceName: 'tencent' }; + if (url.includes('mgtv.com')) return { sourceName: 'imgo' }; + return null; + } + + // 独立出来的实时拉取与偏移应用方法 + async pullRealTimeDanmu(sourceName, url, shift) { + try { + let comments = []; + let sourceInstance = null; + let platName = sourceName; // 映射标准平台名称用于去重工具 + + // 匹配对应的源实例,并处理标准平台名 + if (sourceName === 'tencent') { sourceInstance = tencentSource; platName = 'qq'; } + else if (sourceName === 'iqiyi') { sourceInstance = iqiyiSource; platName = 'qiyi'; } + else if (sourceName === 'imgo') { sourceInstance = mangoSource; platName = 'imgo'; } + else if (sourceName === 'bilibili') { sourceInstance = bilibiliSource; platName = 'bilibili1'; } + else if (sourceName === 'youku') { sourceInstance = youkuSource; platName = 'youku'; } + else if (sourceName === 'bahamut') { sourceInstance = bahamutSource; platName = 'bahamut'; } + + if (sourceInstance) { + // 调用底层的 getEpisodeDanmu + const raw = await sourceInstance.getEpisodeDanmu(url); + comments = sourceInstance.formatComments(raw) || []; + } + + // 应用偏移量,并打上特殊标记 + if (comments && comments.length > 0) { + for (const c of comments) { + if (shift !== 0) { + if (c.p && typeof c.p === 'string') { + const parts = c.p.split(','); + const time = parseFloat(parts[0]); + if (!isNaN(time)) { + parts[0] = Math.max(0, time + shift).toFixed(2); + c.p = parts.join(','); + } + } + if (c.t !== undefined && c.t !== null) { + c.t = Math.max(0, Number(c.t) + shift); + } + if (typeof c.progress === 'number') { + c.progress = Math.max(0, c.progress + Math.round(shift * 1000)); + } + } + // 打上免二次解析的标记 + c.isRealTimePulled = true; + // 将标准平台标识传给外部,供 danmu-util 组装标签 + c.realTimeSource = platName; + } + } + return comments; + } catch (error) { + log("error", `[Dandan] 实时拉取 ${sourceName} 失败: ${error.message}`); + return []; + } + } + formatComments(comments) { - return comments.map(c => ({ - cid: c.cid, - p: `${c.p.replace(/([A-Za-z]+)([0-9a-fA-F]{6})/, (_, platform, hexColor) => { - // 转换 hexColor 为十进制颜色值 - const r = parseInt(hexColor.substring(0, 2), 16); - const g = parseInt(hexColor.substring(2, 4), 16); - const b = parseInt(hexColor.substring(4, 6), 16); - const decimalColor = r * 256 * 256 + g * 256 + b; - return `${platform}${decimalColor}`; - })}`, - // 根据 globals.danmuSimplifiedTraditional 控制是否繁转简 - m: globals.danmuSimplifiedTraditional === 'simplified' ? simplized(c.m) : c.m, - })); + return comments.map(c => { + // 已经被实时抓取的其它源弹幕,略过复杂的 Dandan 转换,进行繁转简处理 + if (c.isRealTimePulled) { + if (globals.danmuSimplifiedTraditional === 'simplified' && c.m) { + return { ...c, m: simplized(c.m) }; + } + return c; + } + + return { + cid: c.cid, + p: `${c.p.replace(/([A-Za-z]+)([0-9a-fA-F]{6})/, (_, platform, hexColor) => { + // 转换 hexColor 为十进制颜色值 + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + const decimalColor = r * 256 * 256 + g * 256 + b; + return `${platform}${decimalColor}`; + })}`, + // 根据 globals.danmuSimplifiedTraditional 控制是否繁转简 + m: globals.danmuSimplifiedTraditional === 'simplified' ? simplized(c.m) : c.m, + }; + }); } } diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index eed3b75..bca50f6 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -11,29 +11,13 @@ import { traditionalized } from './zh-util.js'; * 对弹幕进行分组、去重和计数处理 * @param {Array} filteredDanmus 已过滤屏蔽词的弹幕列表 * @param {number} n 分组时间间隔(分钟),0表示不分组(除非多源合并强制去重) + * @param {boolean} isMultiSource 是否为多源弹幕 * @returns {Array} 处理后的弹幕列表 */ -export function groupDanmusByMinute(filteredDanmus, n) { - // 解析弹幕来源标签以确定合并源数量,用于智能去重 - // 检查第一条弹幕的 p 属性结尾的 [source] 标签 - let sourceCount = 1; - if (filteredDanmus.length > 0 && filteredDanmus[0].p) { - const pStr = filteredDanmus[0].p; - const match = pStr.match(/\[([^\]]*)\]$/); - if (match && match[1]) { - // 支持半角 '&' 和全角 '&' 分隔符 - sourceCount = match[1].split(/[&&]/).length; - } - } - - // 如果检测到多源合并,输出日志提示 - if (sourceCount > 1) { - log("info", `[Smart Deduplication] Detected multi-source merged danmaku (${sourceCount} sources). Applying smart count adjustment.`); - } - +export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) { // 特殊逻辑:如果未开启分组(n=0)且为单源,直接返回原始数据 // 若为多源,即使n=0也强制执行精确时间点去重,以消除源之间的重复数据 - if (n === 0 && sourceCount === 1) { + if (n === 0 && !isMultiSource) { return filteredDanmus.map(danmu => ({ ...danmu, t: danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0]) @@ -71,7 +55,8 @@ export function groupDanmusByMinute(filteredDanmus, n) { earliestT: danmu.t, cid: danmu.cid, p: danmu.p, - like: 0 // 初始化like字段 + like: 0, // 初始化like字段 + sources: new Set() // 收集当前具体弹幕内容的真实独立来源 }; } acc[message].count += 1; @@ -79,6 +64,16 @@ export function groupDanmusByMinute(filteredDanmus, n) { acc[message].earliestT = Math.min(acc[message].earliestT, danmu.t); // 合并like字段,如果是undefined则视为0 acc[message].like += (danmu.like !== undefined ? danmu.like : 0); + + // 提取当前弹幕的来源并加入集合中,建立弹幕内容与平台的精确映射 + if (danmu.p) { + const match = danmu.p.match(/\[([^\]]*)\]$/); + if (match && match[1]) { + match[1].split(/[&&]/).forEach(s => { + if (s.trim()) acc[message].sources.add(s.trim()); + }); + } + } return acc; }, {}); @@ -86,14 +81,19 @@ export function groupDanmusByMinute(filteredDanmus, n) { return Object.keys(groupedByMessage).map(message => { const data = groupedByMessage[message]; - // 计算显示计数:总次数除以源数量,四舍五入 - // 过滤因多源合并产生的自然重复 - let displayCount = Math.round(data.count / sourceCount); + // 以当前这句弹幕实际跨越的独立平台数作为除数,进行局部精准降噪,保留单平台内真实的重复计数 + let localSourceCount = Math.max(1, data.sources.size); + let displayCount = Math.round(data.count / localSourceCount); + if (displayCount < 1) displayCount = 1; + // 将收集到的所有真实独立来源重新拼装回 p 属性标签中 + const combinedSources = Array.from(data.sources).join('&'); + const newP = data.p.replace(/\[([^\]]*)\]$/, `[${combinedSources}]`); + return { cid: data.cid, - p: data.p, + p: newP, // 仅当计算后的逻辑计数大于1时才显示 "x N" m: displayCount > 1 ? `${message}\u200Ax\u200A${displayCount}` : message, t: data.earliestT, @@ -187,6 +187,7 @@ export function limitDanmusByCount(filteredDanmus, danmuLimit) { export function convertToDanmakuJson(contents, platform) { let danmus = []; let cidCounter = 1; + let isMultiSource = false; // 用于记录当前弹幕集合是否为多源组合 // 统一处理输入为数组 let items = []; @@ -260,11 +261,24 @@ export function convertToDanmakuJson(contents, platform) { m = item.m; } + // 优先使用弹幕自带的 _sourceLabel(应对合并工具),其次是外部传入的宏观 platform + let currentPlatform = item._sourceLabel || platform; + + // 如果存在实时拉取的副源标签,安全追加 + if (item.realTimeSource && !currentPlatform.includes(item.realTimeSource)) { + currentPlatform = `${currentPlatform}&${item.realTimeSource}`; + } + + // 在组装字符串时,顺带通过符号检测判定当前是否为多源组合数据 + if (!isMultiSource && /[&&]/.test(currentPlatform)) { + isMultiSource = true; + } + attributes = [ time, mode, color, - `[${platform}]` + `[${currentPlatform}]` ].join(","); danmus.push({ p: attributes, m, cid: cidCounter++, like: item?.like }); @@ -297,7 +311,7 @@ export function convertToDanmakuJson(contents, platform) { // 按n分钟内去重 log("info", `去重分钟数: ${globals.groupMinute}`); - const groupedDanmus = groupDanmusByMinute(filteredDanmus, globals.groupMinute); + const groupedDanmus = groupDanmusByMinute(filteredDanmus, globals.groupMinute, isMultiSource); // 处理点赞数 const likeDanmus = handleDanmusLike(groupedDanmus); diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 4602ef3..09e639a 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -216,8 +216,6 @@ const SEASON_PATTERNS = [ { regex: /[^0-9](\d)$/, prefix: 'S', useCleaned: true } ]; -const NORMALIZE_REGEX = /[\s\u3000-\u303f\uff00-\uffef]/g; - // ========================================== // 3. 基础文本处理工具 (Utilities) // ========================================== @@ -2399,32 +2397,28 @@ export function mergeDanmakuList(listA, listB) { } /** - * 跨源时间轴对齐:以 dandan 为基准,对其他源计算并应用全局偏移 - * 要求副源弹幕与基准源的文本重合度达到指定比例(默认80%)才启用对齐 + * 跨源时间轴对齐:应用 dandan related 接口下发的准确偏移量实现对齐 * @param {Array>} results - 各源弹幕数组(与 sourceNames/realIds 同序) * @param {Array} sourceNames - 源名数组(如 ['dandan', 'bilibili']) * @param {Array} realIds - 对应的 ID 数组(与 sourceNames 同序) - * @param {number} [minMatchRatio=0.8] - 最少匹配比例,低于此比例不应用对齐 - * @param {number} [offsetThreshold=3] - 偏移量阈值(秒),绝对值大于此值才应用全局偏移 + * @param {Object} [dandanShifts={}] - Dandan提供的精确偏移字典,如 { 'bilibili:bilibili.com/bangumi/play/ep1551029': 48 } * @returns {Array>} 对齐后的各源弹幕数组 */ -export function alignSourceTimelines(results, sourceNames, realIds, minMatchRatio = 0.8, offsetThreshold = 3) { +export function alignSourceTimelines(results, sourceNames, realIds, dandanShifts = {}) { const dandanIndex = sourceNames.findIndex(name => name === 'dandan'); if (dandanIndex === -1 || !results[dandanIndex] || results[dandanIndex].length === 0) { log("info", "[Merge][AlignTimeline] 无 dandan 源或无数据,跳过时间轴对齐"); return results; } - - // 提取基准时间表 - const dandanMap = new Map(); - for (const danmu of results[dandanIndex]) { - const text = normalizeText(getDanmuText(danmu)); - if (!text) continue; - const time = getDanmuTime(danmu); - if (!dandanMap.has(text) || time < dandanMap.get(text)) { - dandanMap.set(text, time); + + // 核心标识提取函数,保持与 dandan 源内部一致的去噪匹配逻辑 + const getCoreIdentifier = (targetStr, sName) => { + if (sName === 'bahamut') { + const match = targetStr.match(/sn=(\d+)/) || targetStr.match(/\d+$/); + return match ? (match[1] || match[0]) : targetStr; } - } + return targetStr.replace(/^https?:\/\/(www\.)?/, '').split('?')[0]; + }; // 遍历其他来源进行对齐 for (let idx = 0; idx < results.length; idx++) { @@ -2435,77 +2429,49 @@ export function alignSourceTimelines(results, sourceNames, realIds, minMatchRati if (sourceName === 'dandan' || !Array.isArray(list) || list.length === 0) { continue; } - - const sourceMap = new Map(); - const parsedCache = new Array(list.length); - - for (let i = 0; i < list.length; i++) { - const danmu = list[i]; - const text = normalizeText(getDanmuText(danmu)); - const time = getDanmuTime(danmu); - parsedCache[i] = { danmu, text, time }; - - if (text && (!sourceMap.has(text) || time < sourceMap.get(text))) { - sourceMap.set(text, time); - } - } - - if (sourceMap.size === 0) continue; - - const offsetCounts = new Map(); - let matchCount = 0; - - for (const [text, sourceTime] of sourceMap) { - if (dandanMap.has(text)) { - const dandanTime = dandanMap.get(text); - const offset = Math.round((sourceTime - dandanTime) * 100) / 100; - offsetCounts.set(offset, (offsetCounts.get(offset) || 0) + 1); - matchCount++; - } - } - - // 校验匹配比例 - const matchRatio = matchCount / sourceMap.size; - if (matchRatio < minMatchRatio) { - log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 匹配率过低 (${(matchRatio * 100).toFixed(1)}% < ${(minMatchRatio * 100).toFixed(1)}%),跳过时间轴对齐`); - continue; - } - - // 寻找众数偏移量 - let bestOffset = 0; - let maxCount = 0; - for (const [offset, count] of offsetCounts) { - if (count > maxCount) { - maxCount = count; - bestOffset = offset; - } - } - - if (Math.abs(bestOffset) < offsetThreshold) { - log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 最佳偏移量 ${bestOffset}s 过小,跳过时间轴对齐`); - continue; - } - - log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 应用时间轴偏移 ${bestOffset}s(匹配率: ${(matchRatio * 100).toFixed(1)}%,${maxCount} 票)`); - - for (let i = 0; i < parsedCache.length; i++) { - const { danmu, text, time } = parsedCache[i]; - const targetTime = (text && dandanMap.has(text)) - ? dandanMap.get(text) - : Math.max(0, time - bestOffset); - - if (danmu.p && typeof danmu.p === 'string') { - const firstComma = danmu.p.indexOf(','); - if (firstComma !== -1) { - danmu.p = targetTime.toFixed(2) + danmu.p.substring(firstComma); + + const coreMId = getCoreIdentifier(realId, sourceName); + let appliedShift = undefined; + + // 遍历 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 (danmu.t !== undefined && danmu.t !== null) { - danmu.t = targetTime; - } - if (typeof danmu.progress === 'number') { - danmu.progress = Math.round(targetTime * 1000); + } + + // 仅根据 dandan related 接口提供的精确偏移量进行对齐,未命中则跳过 + if (appliedShift !== undefined) { + log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 应用 dandan API 精确偏移量 ${appliedShift}s`); + + 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); + + // 替换位移 + 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); + } } + } else { + log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 未找到相关 API 偏移量数据,跳过时间轴对齐`); } } @@ -2529,24 +2495,3 @@ function getDanmuTime(danmu) { } return 0; } - -/** - * 获取弹幕文本,兼容 dandan (m) 与其他源 (content) - * @param {Object} danmu - * @returns {string} - */ -function getDanmuText(danmu) { - if (typeof danmu.m === 'string') return danmu.m; - if (typeof danmu.content === 'string') return danmu.content; - return ''; -} - -/** - * 文本标准化:去除空格、标点、转小写 - * @param {string} text - * @returns {string} - */ -function normalizeText(text) { - if (typeof text !== 'string') return ''; - return text.replace(NORMALIZE_REGEX, '').toLowerCase(); -}