增加弹弹第三方弹幕源实时拉取功能,用于覆盖掉弹弹服务器内缓存的旧弹幕 (#220)

* 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:实时拉取默认设置为关闭
This commit is contained in:
Elegy233 authored and GitHub committed 2026-03-04 22:40:15 +08:00
1 parent 0616a03d86
commit a656308fc2
7 files changed
+337 -159

No files matched your search

+1
View File
@@ -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` |
+6
View File
@@ -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)
+17 -2
View File
@@ -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. 合并数据
+4 -2
View File
@@ -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(), // 剧集标题正则过滤
+218 -23
View File
@@ -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,
};
});
}
}
+40 -26
View File
@@ -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);
+51 -106
View File
@@ -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<Array<Object>>} results - 各源弹幕数组(与 sourceNames/realIds 同序)
* @param {Array<string>} sourceNames - 源名数组(如 ['dandan', 'bilibili'])
* @param {Array<string>} 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<Array<Object>>} 对齐后的各源弹幕数组
*/
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();
}