diff --git a/README.md b/README.md index 3edc2ac..83f0cdc 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/config/.env.example b/config/.env.example index 0ae10e0..362deb3 100644 --- a/config/.env.example +++ b/config/.env.example @@ -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= # ==================== 剧集标题过滤 ==================== diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 30ce9fc..68153f8 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -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: '平台排序配置' }, diff --git a/danmu_api/sources/animeko.js b/danmu_api/sources/animeko.js index 28511c5..035680d 100644 --- a/danmu_api/sources/animeko.js +++ b/danmu_api/sources/animeko.js @@ -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; } /** diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index 81188b9..b2fea57 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -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); -} \ No newline at end of file +} diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 1982236..c838a26 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -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);