增加弹弹第三方弹幕源实时拉取功能,用于覆盖掉弹弹服务器内缓存的旧弹幕 (#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:
1 parent
0616a03d86
commit
a656308fc2
7 files changed
+337
-159
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. 合并数据
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user