允许源合并配置多个副源与主源合并,健壮合并工具对比函数 (#160)

* envs.js:支持解析多个合并副源

* merge-util.js:支持合并多个副源、健壮对比函数

* danmu-util.js:对合并源场景智能去重

解决dandan&bahamut等类似场景下,因为dandan本身聚合了bahamut旧弹幕数据,导致弹幕处理后全屏的无效“×2”

* animeko.js:提高结果过滤阀值,针对多季场景智能返回结果

解决用户搜索“葬送的芙莉莲 第二季”/“葬送的芙莉莲 S2E1”会返回不必要的其他季结果

* README.md:更新合并说明

* .env.example:更新合并说明

* README.md:微调合并说明

* globals.js:更新版本号为1.12.4
This commit is contained in:
Elegy233 authored and GitHub committed 2026-01-22 22:50:32 +08:00
1 parent ac1b7eb9b3
commit 2ea441b55c
6 files changed
+425 -176

No files matched your search

+1 -1
View File
@@ -373,7 +373,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,renren,hanjutv,bahamut,dandan,animeko,custom` |
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, renren, hanjutv, bahamut, dandan, animeko, custom` |
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段`,示例:`imgo&iqiyi,dandan&animeko,dandan&bahamut`, 允许多组、允许同时存在、一组配置一对,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,renren,hanjutv,bahamut,dandan,animeko` |
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`主源字段&副源字段&副源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko`, 允许多组、允许同时存在、允许配置多个副源与主源合并,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,renren,hanjutv,bahamut,dandan,animeko` |
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
| ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 |
| STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` |
+3 -3
View File
@@ -87,9 +87,9 @@ SOURCE_ORDER=360,vod,renren,hanjutv
# ==================== 源合并配置 ====================
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, animeko)
# 默认值:空
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、一组配置一对
# 格式:源字段&源字段
# 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&animeko,dandan&bahamut
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、允许配置多个副源与主源合并
# 格式:主源字段&副源字段&副源字段
# 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&bahamut&animeko
# MERGE_SOURCE_PAIRS=
# ==================== 剧集标题过滤 ====================
+25 -14
View File
@@ -140,28 +140,39 @@ export class Envs {
* 解析源合并配置
* 从环境变量 MERGE_SOURCE_PAIRS 获取配置
* 支持使用分号或逗号分隔多组配置
* 格式示例: bilibili&animeko,animeko&dandan
* @returns {Array} 合并配置数组 [{primary: 'animeko', secondary: 'bilibili'}, ...]
* 支持一主多从配置,第一个为主源,后续为副源
* 格式示例: bilibili&animeko, dandan&animeko&bahamut
* @returns {Array} 合并配置数组 [{primary: 'dandan', secondaries: ['animeko', 'bahamut']}, ...]
*/
static resolveMergeSourcePairs() {
const config = this.get('MERGE_SOURCE_PAIRS', '', 'string');
if (!config) return [];
// 使用正则同时支持分号(;)和逗号(,)作为分隔符
// 使用正则同时支持分号(;)和逗号(,)作为配置组的分隔符
return config.split(/[,;]/)
.map(pair => {
.map(group => {
// 过滤空字符串
if (!pair || !pair.includes('&')) return null;
if (!group || !group.includes('&')) return null;
const [primary, secondary] = pair.split('&').map(s => s.trim());
// 按 & 分割,第一个是主源,剩余的是副源列表
const parts = group.split('&').map(s => s.trim()).filter(s => s);
// 仅当两个源都在 MERGE_ALLOWED_SOURCES 允许列表中时才生效
if (primary && secondary &&
this.MERGE_ALLOWED_SOURCES.includes(primary) &&
this.MERGE_ALLOWED_SOURCES.includes(secondary)) {
return { primary, secondary };
}
return null;
if (parts.length < 2) return null;
const primary = parts[0];
const secondaries = parts.slice(1);
// 验证主源是否在允许列表中
if (!this.MERGE_ALLOWED_SOURCES.includes(primary)) return null;
// 过滤有效的副源,且排除主源本身(防止自我合并)
const validSecondaries = secondaries.filter(sec =>
sec !== primary && this.MERGE_ALLOWED_SOURCES.includes(sec)
);
if (validSecondaries.length === 0) return null;
return { primary, secondaries: validSecondaries };
})
.filter(Boolean);
}
@@ -269,7 +280,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: 'text', description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,格式:源字段&源字段,示例:dandan&animeko,dandan&bahamut,\n目前允许合并的源字段有:tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,animeko' },
'MERGE_SOURCE_PAIRS': { category: 'source', type: 'text', description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,支持多源链式合并,第一个为主源。\n格式:源1&源2&源3,多组用逗号分隔。\n示例:dandan&animeko&bahamut, bilibili&animeko\n目前允许的源:tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,renren,hanjutv,bahamut,dandan,animeko' },
// 匹配配置
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置' },
+65 -4
View File
@@ -87,19 +87,45 @@ export default class AnimekoSource extends BaseSource {
}
}
/**
* 从文本中提取明确的季度数字
* @param {string} text 标题文本
* @returns {number|null} 季度数字,未找到返回 null
*/
getExplicitSeasonNumber(text) {
if (!text) return null;
const cleanText = simplized(text);
// 1. 匹配阿拉伯数字 (S2, Season 2, 第2季)
// 排除 S01 或 第1季,因为通常第一季不带标号,需要特殊处理
const arabicMatch = cleanText.match(/(?:^|\s|\[|\(|(|【)(?:Season|S|第)\s*(\d+)(?:\s*季|期|部|Season|\]|\)|)|】)?/i);
if (arabicMatch && arabicMatch[1]) {
return parseInt(arabicMatch[1], 10);
}
// 2. 匹配中文数字 (第二季)
const cnNums = {'一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '七':7, '八':8, '九':9, '十':10};
const cnMatch = cleanText.match(/第([一二三四五六七八九十]+)[季期部]/);
if (cnMatch && cnNums[cnMatch[1]]) {
return cnNums[cnMatch[1]];
}
return null;
}
/**
* 过滤搜索结果
* 计算关键词与标题(含中文名、原名及别名)的相似度,剔除低相关度结果
* 包含基础相似度过滤和智能季度匹配逻辑
* @param {Array} list 原始 API 返回结果列表
* @param {string} keyword 用户搜索关键词
* @returns {Array} 过滤后的结果列表
*/
filterSearchResults(list, keyword) {
const threshold = 0.4; // 相似度阈值
const threshold = 0.6; // 相似度阈值
const normalizedKeyword = simplized(keyword).toLowerCase().trim();
return list.filter(item => {
// 收集所有可能的标题用于比对
// 1. 基础相似度过滤 (获取所有潜在相关结果)
const candidates = list.filter(item => {
const titles = new Set();
if (item.name) titles.add(item.name);
if (item.name_cn) titles.add(item.name_cn);
@@ -126,6 +152,41 @@ export default class AnimekoSource extends BaseSource {
return maxScore >= threshold;
});
if (candidates.length === 0) return [];
// 2. 智能季度匹配逻辑
// 尝试从关键词中提取目标季度
const targetSeason = this.getExplicitSeasonNumber(keyword);
// 规则1: 如果关键词包含明确的季度信息(且大于1,排除S1干扰),则执行严格匹配
if (targetSeason !== null && targetSeason > 1) {
log("info", `[Animeko] 检测到指定季度搜索: 第 ${targetSeason} 季`);
const strictMatches = candidates.filter(item => {
// 尝试从结果标题中提取季度,如果提取不到,默认为第 1 季
const seasonInName = this.getExplicitSeasonNumber(item.name);
const seasonInCn = this.getExplicitSeasonNumber(item.name_cn);
// 只要任一标题匹配季度即可
// 注意:如果标题中没有季度标识(返回null),我们视为第1季
const itemSeason = (seasonInName !== null ? seasonInName : (seasonInCn !== null ? seasonInCn : 1));
return itemSeason === targetSeason;
});
// 规则3: 如果有符合条件的结果,返回所有符合项
if (strictMatches.length > 0) {
return strictMatches;
}
// 规则2: 如果包含季度信息但找不到对应结果,返回最优选(第1个)
log("info", `[Animeko] 未找到第 ${targetSeason} 季对应条目,回退至最优结果`);
return [candidates[0]];
}
// 规则1(反向): 如果关键词不包含季度信息,走原原本本的逻辑 (返回所有高相似度结果)
return candidates;
}
/**
+45 -13
View File
@@ -6,9 +6,33 @@ import { jsonResponse, xmlResponse } from "./http-util.js";
// danmu处理相关函数
// =====================
/**
* 对弹幕进行分组、去重和计数处理
* @param {Array} filteredDanmus 已过滤屏蔽词的弹幕列表
* @param {number} n 分组时间间隔(分钟),0表示不分组(除非多源合并强制去重)
* @returns {Array} 处理后的弹幕列表
*/
export function groupDanmusByMinute(filteredDanmus, n) {
// 如果 n 为 0,直接返回原始数据
if (n === 0) {
// 解析弹幕来源标签以确定合并源数量,用于智能去重
// 检查第一条弹幕的 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.`);
}
// 特殊逻辑:如果未开启分组(n=0)且为单源,直接返回原始数据
// 若为多源,即使n=0也强制执行精确时间点去重,以消除源之间的重复数据
if (n === 0 && sourceCount === 1) {
return filteredDanmus.map(danmu => ({
...danmu,
t: danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0])
@@ -16,29 +40,30 @@ export function groupDanmusByMinute(filteredDanmus, n) {
}
// 按 n 分钟分组
const groupedByMinute = filteredDanmus.reduce((acc, danmu) => {
const groupedByTime = filteredDanmus.reduce((acc, danmu) => {
// 获取时间:优先使用 t 字段,如果没有则使用 p 的第一个值
const time = danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0]);
// 计算分组(每 n 分钟一组,向下取整)
const group = Math.floor(time / (n * 60));
// 确定分组键:n=0时使用精确时间(保留2位小数),否则使用分钟索引
const groupKey = n === 0 ? time.toFixed(2) : Math.floor(time / (n * 60));
// 初始化分组
if (!acc[group]) {
acc[group] = [];
if (!acc[groupKey]) {
acc[groupKey] = [];
}
// 添加到对应分组
acc[group].push({ ...danmu, t: time });
acc[groupKey].push({ ...danmu, t: time });
return acc;
}, {});
// 处理每组的弹幕
const result = Object.keys(groupedByMinute).map(group => {
const danmus = groupedByMinute[group];
const result = Object.keys(groupedByTime).map(key => {
const danmus = groupedByTime[key];
// 按消息内容分组
const groupedByMessage = danmus.reduce((acc, danmu) => {
const message = danmu.m.split(' X')[0]; // 提取原始消息(去除 Xn 后缀)
const message = danmu.m.split(' X')[0].trim(); // 提取原始消息(去除 Xn 后缀)
if (!acc[message]) {
acc[message] = {
count: 0,
@@ -56,10 +81,17 @@ export function groupDanmusByMinute(filteredDanmus, n) {
// 转换为结果格式
return Object.keys(groupedByMessage).map(message => {
const data = groupedByMessage[message];
// 计算显示计数:总次数除以源数量,四舍五入
// 过滤因多源合并产生的自然重复
let displayCount = Math.round(data.count / sourceCount);
if (displayCount < 1) displayCount = 1;
return {
cid: data.cid,
p: data.p,
m: data.count > 1 ? `${message} x ${data.count}` : message,
// 仅当计算后的逻辑计数大于1时才显示 "x N"
m: displayCount > 1 ? `${message} x ${displayCount}` : message,
t: data.earliestT
};
});
@@ -383,4 +415,4 @@ export function formatDanmuResponse(danmuData, queryFormat) {
// 默认返回 JSON
return jsonResponse(danmuData);
}
}
+286 -141
View File
@@ -221,7 +221,7 @@ function extractSeasonMarkers(title, typeDesc = '') {
{ regex: /s(\d+)/, prefix: 'S' },
{ regex: /part\s*(\d+)/, prefix: 'P' },
{ regex: /(ova|oad)/, val: 'OVA' },
{ regex: /(剧场版|movie|film)/, val: 'MOVIE' },
{ regex: /(剧场版|movie|film|电影)/, val: 'MOVIE' },
{ regex: /(续篇|续集)/, val: 'SEQUEL' },
{ regex: /sp/, val: 'SP' },
{ regex: /[^0-9](\d)$/, prefix: 'S' }
@@ -239,7 +239,7 @@ function extractSeasonMarkers(title, typeDesc = '') {
});
// 从 Type 字段中补全标记
if (type.includes('剧场版') || type.includes('movie') || type.includes('film')) markers.add('MOVIE');
if (type.includes('剧场版') || type.includes('movie') || type.includes('film') || type.includes('电影')) markers.add('MOVIE');
if (type.includes('ova') || type.includes('oad')) markers.add('OVA');
if (type.includes('sp') || type.includes('special')) markers.add('SP');
@@ -474,8 +474,9 @@ export function findSecondaryMatch(primaryAnime, secondaryList) {
/**
* 提取集数信息
* 增强正则以支持紧凑格式,并预先进行去噪清洗
* 同时判断该集是否属于特殊集 (Special/OVA/Season标识等)
* @param {string} title 分集标题
* @returns {Object} { isMovie: boolean, num: number|null }
* @returns {Object} { isMovie: boolean, num: number|null, isSpecial: boolean }
*/
function extractEpisodeInfo(title) {
// 使用 cleanText 移除干扰前缀和地区文字
@@ -485,8 +486,11 @@ function extractEpisodeInfo(title) {
const isMovie = /剧场版|movie|film/i.test(t);
let num = null;
// 2. 判断是否是特殊集 (S1, O1, SP, Special)
// 区别于 EP29 或 第29集 这种正片
const isSpecial = /^(s|o|sp|special)\d/i.test(t);
// 2. 提取数字
// 3. 提取数字
// 策略 A: 强前缀 (EP, O, S, Part, 第)
const strongPrefixMatch = t.match(/(?:ep|o|s|part|第)\s*(\d+(\.\d+)?)/i);
@@ -501,7 +505,25 @@ function extractEpisodeInfo(title) {
}
}
return { isMovie, num };
return { isMovie, num, isSpecial };
}
/**
* 判断集标题是否属于特定的特殊类型(Opening/Ending/Interview/Bloopers)
* 用于实现特殊集的独立匹配逻辑
* @param {string} title 集标题
* @returns {string|null} 特殊类型标识 ('opening' | 'ending' | 'interview' | 'Bloopers' | null)
*/
function getSpecialEpisodeType(title) {
if (!title) return null;
const t = title.toLowerCase();
if (t.includes('opening')) return 'opening';
if (t.includes('ending')) return 'ending';
if (t.includes('interview')) return 'interview';
if (t.includes('Bloopers')) return 'Bloopers';
return null;
}
/**
@@ -541,11 +563,33 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) {
let bestOffset = 0;
let maxScore = -999;
// 计算主源和副源的正片起始集数(忽略特殊集)
// 用于计算相对集数偏移量,解决不同命名规范的对齐问题
let minNormalA = null;
let minNormalB = null;
for (const item of primaryLinks) {
const info = extractEpisodeInfo(item.link.title);
if (info.num !== null && !info.isSpecial) {
if (minNormalA === null || info.num < minNormalA) minNormalA = info.num;
}
}
for (const item of secondaryLinks) {
const info = extractEpisodeInfo(item.link.title);
if (info.num !== null && !info.isSpecial) {
if (minNormalB === null || info.num < minNormalB) minNormalB = info.num;
}
}
// 只有当双方都有正片集数时,才计算季度偏移量
const seasonShift = (minNormalA !== null && minNormalB !== null) ? (minNormalA - minNormalB) : null;
// 限制滑动范围 (假设差异 +/- 15 集)
const maxShift = Math.min(Math.max(primaryLinks.length, secondaryLinks.length), 15);
for (let offset = -maxShift; offset <= maxShift; offset++) {
let totalTextScore = 0;
let rawTextScoreSum = 0; // 记录原始文本相似度总和,用于一致性验证
let matchCount = 0;
let numericDiffs = new Map();
@@ -564,8 +608,35 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) {
pairScore -= 5.0; // 强惩罚
}
// 2. 文本相似度 (使用 calculateSimilarity 自动清洗对比)
pairScore += calculateSimilarity(titleA, titleB);
// 1.1 特殊集类型惩罚/奖励 (Opening/Ending/Interview/Bloopers)
const specialTypeA = getSpecialEpisodeType(titleA);
const specialTypeB = getSpecialEpisodeType(titleB);
if (specialTypeA || specialTypeB) {
if (specialTypeA !== specialTypeB) {
pairScore -= 10.0;
} else {
pairScore += 3.0;
}
}
// 1.2 集类型一致性奖励 (Type Consistency Bonus)
// 优先匹配同类型集数(同为正片或同为特殊集)
if (infoA.isSpecial === infoB.isSpecial) {
pairScore += 3.0;
}
// 1.3 相对集数对齐奖励 (Start-of-Season Alignment Bonus)
// 基于首集差异动态计算偏移量,处理不同源的集数命名习惯差异
if (seasonShift !== null && !infoA.isSpecial && !infoB.isSpecial) {
if ((infoA.num - infoB.num) === seasonShift) {
pairScore += 5.0; // 极强奖励
}
}
// 2. 文本相似度
const sim = calculateSimilarity(titleA, titleB);
pairScore += sim;
rawTextScoreSum += sim;
// 3. 数字完全匹配加分
// 如果提取出的数字完全相等 (Diff=0),给予高额加分
@@ -598,15 +669,24 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) {
}
const consistencyRatio = maxFrequency / matchCount;
if (consistencyRatio > 0.6) {
finalScore += 2.0; // 一致性奖励
const avgRawTextScore = rawTextScoreSum / matchCount;
// 仅当文本相似度达标时才给予一致性奖励,防止数字凑巧对齐但内容不符
if (consistencyRatio > 0.6 && avgRawTextScore > 0.33) {
finalScore += 2.0;
}
// 6. 覆盖率权重 (Coverage Weight)
// 每多匹配一集,给予额外加分 (上限 1.5),防止巧合匹配
// 6. 覆盖率权重
const coverageBonus = Math.min(matchCount * 0.15, 1.5);
finalScore += coverageBonus;
// 7. 绝对数字匹配累积奖励
// 确保数字完全一致的匹配拥有最高优先级
const zeroDiffCount = numericDiffs.get("0.0000") || 0;
if (zeroDiffCount > 0) {
finalScore += zeroDiffCount * 2.0;
}
// 选择逻辑
if (finalScore > maxScore) {
maxScore = finalScore;
@@ -624,30 +704,31 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) {
* 通过哈希组合 ID 并映射到 10亿~21亿 区间,避免溢出并减少冲突
* @param {string|number} id1 原始ID 1
* @param {string|number} id2 原始ID 2
* @param {string} salt 盐值(通常为配置组签名,用于区分不同合并组)
* @returns {number} 安全的 Int32 ID
*/
function generateSafeMergedId(id1, id2) {
const str = `${id1}_${id2}`;
function generateSafeMergedId(id1, id2, salt = '') {
// 将 salt 加入哈希计算字符串中,确保唯一性
const str = `${id1}_${id2}_${salt}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
// 取绝对值并映射到 1,000,000,000 (10亿) ~ 2,147,483,647 (int32 max) 之间
// 这样既保证了是正数,又避开了通常较小的原始 ID,同时确保不超过客户端 int32 限制
return (Math.abs(hash) % 1000000000) + 1000000000;
}
/**
* 执行源合并逻辑
* 遍历配置的源对,合并符合条件的动画条目及其集数
* 遍历配置的源配置组,支持一主多从的链式合并
* @param {Array} curAnimes 当前所有的动画条目列表
*/
export async function applyMergeLogic(curAnimes) {
const pairs = globals.mergeSourcePairs;
if (!pairs || pairs.length === 0) return;
const groups = globals.mergeSourcePairs; // 此时已是 {primary, secondaries[]} 结构
if (!groups || groups.length === 0) return;
log("info", `[Merge] 启动源合并策略,配置: ${JSON.stringify(pairs)}`);
log("info", `[Merge] 启动源合并策略,配置: ${JSON.stringify(groups)}`);
// 获取过滤正则 (直接从 globals 获取,支持 RegExp 或字符串)
let epFilter = globals.episodeTitleFilter;
@@ -656,135 +737,199 @@ export async function applyMergeLogic(curAnimes) {
}
const newMergedAnimes = [];
const usedBaseAnimeIds = new Set();
const mergedSecondaryAnimes = new Set();
const usedBaseAnimeIds = new Set(); // 记录被主源使用的ID
const mergedSecondaryAnimes = new Set(); // 记录被副源使用的对象
// 全局去重签名集合,用于防止不同配置组生成完全相同的内容
// 签名格式:PrimaryID|SecID1|SecID2...
const generatedSignatures = new Set();
for (const { primary, secondaries } of groups) {
// 生成当前配置组的唯一指纹 (例如 "dandan&animeko&bahamut")
const groupFingerprint = `${primary}&${secondaries.join('&')}`;
for (const { primary, secondary } of pairs) {
const primaryItems = curAnimes.filter(a => a.source === primary && !a._isMerged);
const secondaryItems = curAnimes.filter(a => a.source === secondary && !a._isMerged);
if (primaryItems.length === 0 || secondaryItems.length === 0) continue;
// 如果该主源没有数据,直接跳过整个组
if (primaryItems.length === 0) continue;
// 对每个主源动漫条目进行处理
for (const pAnime of primaryItems) {
const match = findSecondaryMatch(pAnime, secondaryItems);
if (match) {
const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId));
const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
if (!cachedPAnime?.links || !cachedMatch?.links) {
log("warn", `[Merge] 数据不完整,跳过合并: ${pAnime.animeTitle}`);
continue;
}
const derivedAnime = JSON.parse(JSON.stringify(cachedPAnime));
// 1. 预过滤 (保留 index 映射)
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter);
derivedAnime.links = filteredPLinksWithIndex.map(item => item.link);
const filteredMLinksWithIndex = filterEpisodes(cachedMatch.links, epFilter);
// 2. 计算最佳对齐偏移量
const offset = findBestAlignmentOffset(filteredPLinksWithIndex, filteredMLinksWithIndex);
if (offset !== 0) {
log("info", `[Merge] 集数自动对齐: Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`);
}
// 生成安全的 int32 ID,防止客户端溢出错误
derivedAnime.animeId = generateSafeMergedId(pAnime.animeId, match.animeId);
derivedAnime.bangumiId = String(derivedAnime.animeId);
let mergedCount = 0;
const mappingEntries = []; // 用于存储所有的映射记录(包含匹配和未匹配)
const matchedPIndices = new Set(); // 记录已被副源匹配的主源索引
// 3. 执行合并 (应用偏移量) - 以副源为驱动
for (let i = 0; i < filteredMLinksWithIndex.length; i++) {
const pIndex = i + offset;
const sourceLink = filteredMLinksWithIndex[i].link;
const sTitleShort = sourceLink.name || sourceLink.title || `Index ${i}`;
if (pIndex >= 0 && pIndex < derivedAnime.links.length) {
// [匹配成功]
const targetLink = derivedAnime.links[pIndex];
const pTitleShort = targetLink.name || targetLink.title || `Index ${pIndex}`;
const idA = sanitizeUrl(targetLink.url);
const idB = sanitizeUrl(sourceLink.url);
const newUrl = `${primary}:${idA}${MERGE_DELIMITER}${secondary}:${idB}`;
targetLink.url = newUrl;
// 记录映射日志
mappingEntries.push({
idx: pIndex,
text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}`
});
matchedPIndices.add(pIndex);
// 修改分集标题
if (targetLink.title) {
let sLabel = secondary;
if (sourceLink.title) {
const sMatch = sourceLink.title.match(/^【([^】\d]+)(?:\d*)】/);
if (sMatch) {
sLabel = sMatch[1].trim();
}
}
targetLink.title = targetLink.title.replace(
/^【([^】\d]+)(\d*)】/, // 捕获主源当前的显示名(如qq)和可能的数字
(match, pLabel, pNum) => `【${pLabel.trim()}${pNum}${DISPLAY_CONNECTOR}${sLabel}】`
);
}
mergedCount++;
} else {
// [副源落单] (主源越界)
mappingEntries.push({
idx: pIndex, // 使用虚拟索引进行排序
text: ` [落单] (主源越界) <-> ${sTitleShort}`
});
}
}
// 4. 检查主源是否有落单集数
for (let j = 0; j < derivedAnime.links.length; j++) {
if (!matchedPIndices.has(j)) {
const targetLink = derivedAnime.links[j];
const pTitleShort = targetLink.name || targetLink.title || `Index ${j}`;
mappingEntries.push({
idx: j,
text: ` [落单] ${pTitleShort} <-> (副源缺失)`
});
}
}
// 按集数顺序排序日志
mappingEntries.sort((a, b) => a.idx - b.idx);
derivedAnime.animeTitle = derivedAnime.animeTitle.replace(`from ${primary}`, `from ${primary}${DISPLAY_CONNECTOR}${secondary}`);
derivedAnime.source = primary;
addAnime(derivedAnime);
newMergedAnimes.push(derivedAnime);
usedBaseAnimeIds.add(pAnime.animeId);
mergedSecondaryAnimes.add(match);
const logTitleA = cachedPAnime.animeTitle.replace(/\s*from\s+.*$/i, '');
const logTitleB = cachedMatch.animeTitle.replace(/\s*from\s+.*$/i, '');
log("info", `[Merge] 关联成功: [${primary}] ${logTitleA} <-> [${secondary}] ${logTitleB} (共合并 ${mergedCount} 集) (MergedID: ${derivedAnime.animeId})`);
// 打印详细的集数映射日志
if (mappingEntries.length > 0) {
log("info", `[Merge] 集映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`);
}
const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId));
if (!cachedPAnime?.links) {
log("warn", `[Merge] 主源数据不完整,跳过: ${pAnime.animeTitle}`);
continue;
}
}
// 提前获取主源标题用于日志显示(去除 from 后缀)
const logTitleA = pAnime.animeTitle.replace(/\s*from\s+.*$/i, '');
// 创建一个衍生对象,作为合并的基础容器
let derivedAnime = JSON.parse(JSON.stringify(cachedPAnime));
// 记录本次实际成功合并的副源名称和ID,用于生成动态标题和去重签名
const actualMergedSources = [];
const contentSignatureParts = [pAnime.animeId]; // 签名的第一部分是主源ID
let hasMergedAny = false; // 标记是否成功合并过至少一个副源
// 遍历所有副源,依次尝试合入 derivedAnime
for (const secSource of secondaries) {
// 从当前所有animes中找出该副源的列表
const secondaryItems = curAnimes.filter(a => a.source === secSource && !a._isMerged);
if (secondaryItems.length === 0) continue;
// 寻找匹配
const match = findSecondaryMatch(pAnime, secondaryItems);
if (match) {
const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
if (!cachedMatch?.links) continue;
// 获取副源标题用于日志
const logTitleB = cachedMatch.animeTitle.replace(/\s*from\s+.*$/i, '');
// 1. 预过滤 (保留 index 映射)
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter);
const filteredMLinksWithIndex = filterEpisodes(cachedMatch.links, epFilter);
// 2. 计算最佳对齐偏移量
const offset = findBestAlignmentOffset(filteredPLinksWithIndex, filteredMLinksWithIndex);
if (offset !== 0) {
log("info", `[Merge] 集数自动对齐 (${secSource}): Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`);
}
// 更新 ID (传入 groupFingerprint 作为 salt,保证不同配置组基础ID不同)
derivedAnime.animeId = generateSafeMergedId(derivedAnime.animeId, match.animeId, groupFingerprint);
derivedAnime.bangumiId = String(derivedAnime.animeId);
let mergedCount = 0;
const mappingEntries = []; // 用于存储映射日志
const matchedPIndices = new Set(); // 记录已被匹配的主源索引,用于后续检查落单
// 3. 执行合并 (应用偏移量) - 以当前副源为驱动
for (let i = 0; i < filteredMLinksWithIndex.length; i++) {
const pIndex = i + offset;
const sourceLink = filteredMLinksWithIndex[i].link;
const sTitleShort = sourceLink.name || sourceLink.title || `Index ${i}`;
if (pIndex >= 0 && pIndex < derivedAnime.links.length) {
// [匹配成功] (需进一步校验)
const targetLink = derivedAnime.links[pIndex];
const pTitleShort = targetLink.name || targetLink.title || `Index ${pIndex}`;
// 3.1 特殊集匹配校验 (Opening/Ending/Interview)
const specialP = getSpecialEpisodeType(targetLink.title);
const specialS = getSpecialEpisodeType(sourceLink.title);
if (specialP !== specialS) {
mappingEntries.push({
idx: pIndex,
text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (特殊集类型不匹配)`
});
continue;
}
// 执行 ID 合并
const idB = sanitizeUrl(sourceLink.url);
let currentUrl = targetLink.url;
const secPart = `${secSource}:${idB}`;
// 如果 targetLink.url 还没有任何合并标记,确保主源前缀存在
if (!currentUrl.includes(MERGE_DELIMITER)) {
if (!currentUrl.startsWith(primary + ':')) {
currentUrl = `${primary}:${currentUrl}`;
}
}
targetLink.url = `${currentUrl}${MERGE_DELIMITER}${secPart}`;
mappingEntries.push({
idx: pIndex,
text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}`
});
matchedPIndices.add(pIndex);
// 修改分集标题
if (targetLink.title) {
let sLabel = secSource;
if (sourceLink.title) {
const sMatch = sourceLink.title.match(/^【([^】\d]+)(?:\d*)】/);
if (sMatch) sLabel = sMatch[1].trim();
}
targetLink.title = targetLink.title.replace(
/^【([^】]+)】/,
(match, content) => `【${content}${DISPLAY_CONNECTOR}${sLabel}】`
);
}
mergedCount++;
} else {
// [副源落单]
mappingEntries.push({
idx: pIndex,
text: ` [落单] (主源越界) <-> ${sTitleShort}`
});
}
}
// 4. 检查主源是否有落单集数
for (let j = 0; j < derivedAnime.links.length; j++) {
if (!matchedPIndices.has(j)) {
const targetLink = derivedAnime.links[j];
const pTitleShort = targetLink.name || targetLink.title || `Index ${j}`;
mappingEntries.push({
idx: j,
text: ` [落单] ${pTitleShort} <-> (副源缺失或被略过)`
});
}
}
// 排序并打印日志
log("info", `[Merge] 关联成功: [${primary}] ${logTitleA} <-> [${secSource}] ${logTitleB} (本次合并 ${mergedCount} 集)`);
if (mappingEntries.length > 0) {
mappingEntries.sort((a, b) => a.idx - b.idx);
log("info", `[Merge] [${secSource}] 映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`);
}
// 记录成功的合并信息
mergedSecondaryAnimes.add(match);
hasMergedAny = true;
actualMergedSources.push(secSource); // 记录实际合并成功的源名称
contentSignatureParts.push(match.animeId); // 记录实际合并成功的源ID
}
} // end for secondaries
// 如果成功合并了至少一个副源
if (hasMergedAny) {
// --- 去重检查开始 ---
// 生成内容签名 (例如: 12345|67890|54321)
const signature = contentSignatureParts.join('|');
if (generatedSignatures.has(signature)) {
log("info", `[Merge] 检测到重复的合并结果 (Signature: ${signature}),已自动隐去冗余条目。`);
continue;
}
generatedSignatures.add(signature);
// --- 去重检查结束 ---
// 使用 actualMergedSources 生成标题,只显示真正合并成功的源
const joinedSources = actualMergedSources.join(DISPLAY_CONNECTOR);
derivedAnime.animeTitle = derivedAnime.animeTitle.replace(`from ${primary}`, `from ${primary}${DISPLAY_CONNECTOR}${joinedSources}`);
derivedAnime.source = primary;
addAnime(derivedAnime);
newMergedAnimes.push(derivedAnime);
// 标记原始主源已被合并替代
usedBaseAnimeIds.add(pAnime.animeId);
}
} // end for primaryItems
}
curAnimes.push(...newMergedAnimes);