From e84b3b01df4762983aa8a8de78623ac5a318cfc5 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:50:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=90=88=E5=B9=B6=E6=BA=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=9A=84=E6=99=BA=E8=83=BD=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=90=88=E5=B9=B6=EF=BC=8C=E9=80=82=E9=85=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E4=BC=98=E9=80=89=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BD=BF=E7=94=A8=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * envs.js:为平台排序支持合并源 同时更改MERGE_SOURCE_PAIRS为multi-select,为后续前端更新做准备 * dandan-api.js:更新平台排序相关函数,更改排序配置决定函数适配复杂的合并源场景 应该大概没有删减什么,新的决定函数是命中计数权重算法 * merge-util.js:合并逻辑增加主源轮替功能,实现结果智能合并,不再硬性要求主源 a&b&c&d合并,a无结果拿b、c、d对比合并,b无结果拿c、d…… * components.css.js:增加合并模式相关样式 * systemsettings.js:增加multi-select合并模式相关逻辑 * README.md:更新源合并配置说明 * .env.example:更新源合并相关说明 * animeko.js:增加自动降级服务器地址 * animeko.js:降低相似度阈值到0.6(撤回更改) * animeko.js:支持剧集翻页、过滤结果前先清洗文本 * merge-util.js:健壮对比函数 1.修复“犬夜叉 超越时代的思念”意外与“犬夜叉”匹配,新增主副标题的结构检测。 2.修复"犬夜叉 超越时代的思念(2001)【动漫】from youku",不能与"犬夜叉 穿越时代的思念(2001)【剧场版】from dandan"匹配,新增主副标题如果匹配上可以无视剧场版标识冲突。 3.新增验证合并覆盖率是否合规,如果成功合并集远远小于总集则视为异常驳回关联。 让我看看还有什么问题.jpg * animeko.js:得利于清洗文本,过滤使用的相似度阀值可以提高到0.9 * bilibili.js:条目原名也扔给common-util工具对比(修复使用条目原名搜索出来的结果会被过滤掉) 话说我在做animeko源的时候完全忘了有这个工具… 改天重构调用一下吧 * animeko.js:修复清洗文本太极端的问题 * merge-util.js:调整主副标题结构冲突为扣分而不是抛弃,合并覆盖率验证豁免animeko 没想到意外这么快来,"判处勇者刑(2026)【动漫】from bilibili" 与 "判处勇者刑 惩罚勇者9004队刑务纪录(2026)【TV动画】from animeko" 只是翻译问题但是因为主副标题冲突被禁止合并了,然后因为animeko通常包含未放送集数(并且无法获取状态)所以需要豁免合并覆盖率 * README.md:手动同步上游 * merge-util.js:增加季度信息的统一映射 震惊之前竟然没做 * animeko.js:还原更改,挪到新的PR专项优化 * bilibili.js:还原更改,挪到新的PR专项优化 * merge-util.js:全方面优化,支持未匹配上的集也返回 燃尽了,有一点点堆成屎山了之后再重构吧先把逻辑全部做出来确保准确率()感谢“间谍过家家”这个番,几乎全平台有,发现了大量平台差异与季度差异 * merge-util.js:优化集提取正则 * dandan-api.js:修复当评分相同时后遍历到的结果会无条件覆盖先遍历到的结果 * dandan-api.js:去除没匹上就跳过检查集 * dandan-api.js:重新实现,同步修复季匹配错误问题函数 * merge-util.js:支持字母后缀的季度差分,例如小林家的龙女仆S、某科学的超电磁炮S、某科学的超电磁炮T --- README.md | 4 +- config/.env.example | 7 +- danmu_api/apis/dandan-api.js | 241 ++- danmu_api/configs/envs.js | 20 +- danmu_api/ui/css/components.css.js | 182 +++ danmu_api/ui/js/systemsettings.js | 386 ++++- danmu_api/utils/merge-util.js | 2180 ++++++++++++++++++++++------ 7 files changed, 2439 insertions(+), 581 deletions(-) diff --git a/README.md b/README.md index efe85fc..a51124f 100644 --- a/README.md +++ b/README.md @@ -372,8 +372,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | 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` | | SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko,custom` | -| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` | -| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`主源字段&副源字段&副源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko`, 允许多组、允许同时存在、允许配置多个副源与主源合并,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko` | +| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` | +| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko`, 允许多组、允许同时存在、允许多源,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,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 898b78c..fcde783 100644 --- a/config/.env.example +++ b/config/.env.example @@ -81,14 +81,15 @@ SOURCE_ORDER=360,vod,renren,hanjutv # 平台优先级排序(用于自动匹配) # 默认值:空(自动匹配) -# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom +# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko +# 说明:平台排序配置,可以配置自动匹配时的优选平台,当配置合并平台的时候可以指定期望的合并源 # PLATFORM_ORDER= # ==================== 源合并配置 ==================== # 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko) # 默认值:空 -# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、允许配置多个副源与主源合并 -# 格式:主源字段&副源字段&副源字段 +# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许同时存在、允许多源,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环 +# 格式:源字段&源字段&源字段 # 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&bahamut&animeko # MERGE_SOURCE_PAIRS= diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 95902a7..298697c 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -398,16 +398,63 @@ function extractEpisodeNumberFromTitle(episodeTitle) { return null; } +/** + * 计算平台匹配得分 (新增函数 - 用于支持合并源模糊匹配和杂质过滤) + * @param {string} candidatePlatform 候选平台字符串 (e.g., "bilibili&dandan") + * @param {string} targetPlatform 目标配置字符串 (e.g., "bilibili1&dandan") + * @returns {number} 得分:越高越好,0表示不匹配 + */ +function getPlatformMatchScore(candidatePlatform, targetPlatform) { + if (!candidatePlatform || !targetPlatform) return 0; + + // 预处理:按 & 分割,转小写,去空格 + const cParts = candidatePlatform.split('&').map(s => s.trim().toLowerCase()).filter(s => s); + const tParts = targetPlatform.split('&').map(s => s.trim().toLowerCase()).filter(s => s); + + let matchCount = 0; + + // 计算交集:统计有多少个目标平台在候选平台中存在 + // 使用 includes 进行模糊匹配,解决部分平台名称差异问题 + for (const tPart of tParts) { + const isFound = cParts.some(cPart => + cPart === tPart || + (cPart.includes(tPart) && tPart.length > 2) || + (tPart.includes(cPart) && cPart.length > 2) + ); + if (isFound) { + matchCount++; + } + } + + if (matchCount === 0) return 0; + + // 评分公式:基于命中数计算权重,其次考虑候选长度(越短越好,即杂质越少分越高) + // 示例: Target="bilibili" + // Candidate="bilibili" -> Match=1, Len=1 -> 1000 - 1 = 999 (Best) + // Candidate="animeko&bilibili" -> Match=1, Len=2 -> 1000 - 2 = 998 (Valid but lower score) + return (matchCount * 1000) - cParts.length; +} + +// 辅助函数:从标题中提取来源平台列表 (新增函数 - 适配合并源标题格式) +function extractPlatformFromTitle(title) { + const match = title.match(/from\s+([a-zA-Z0-9&]+)/i); + return match ? match[1] : null; +} + // 根据集数匹配episode(优先使用集标题中的集数,其次使用episodeNumber,最后使用数组索引) function findEpisodeByNumber(filteredEpisodes, targetEpisode, platform = null) { if (!filteredEpisodes || filteredEpisodes.length === 0) { return null; } - // 如果指定了平台,先过滤出该平台的集数 + // 如果指定了平台,先过滤出该平台的集数 (修改点:使用 getPlatformMatchScore 支持模糊匹配) let platformEpisodes = filteredEpisodes; if (platform) { - platformEpisodes = filteredEpisodes.filter(ep => extractEpisodeTitle(ep.episodeTitle) === platform); + platformEpisodes = filteredEpisodes.filter(ep => { + const epTitlePlatform = extractEpisodeTitle(ep.episodeTitle); + // 使用评分机制判断是否匹配,只要有分就保留 + return getPlatformMatchScore(epTitlePlatform, platform) > 0; + }); } if (platformEpisodes.length === 0) { @@ -442,97 +489,139 @@ function findEpisodeByNumber(filteredEpisodes, targetEpisode, platform = null) { } async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId) { - let resAnime; - let resEpisode; - if (season && episode) { - // 判断剧集 - const normalizedTitle = normalizeSpaces(title); - for (const anime of searchData.animes) { - const animeIsNotPrefer = + // 定义最佳匹配结果容器 + let bestRes = { + anime: null, + episode: null, + score: -9999 // 初始分数为极低值 + }; + + const normalizedTitle = normalizeSpaces(title); + + // 遍历所有搜索结果,寻找最佳匹配 + for (const anime of searchData.animes) { + // 偏好过滤 + const animeIsNotPrefer = globals.rememberLastSelect && preferAnimeId && String(anime.bangumiId) !== String(preferAnimeId) && String(anime.animeId) !== String(preferAnimeId); - if (animeIsNotPrefer) continue; - if (normalizeSpaces(anime.animeTitle).includes(normalizedTitle)) { - // 年份匹配优先于季匹配 - if (!matchYear(anime, year)) { - log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`); - continue; - } - - let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); - const bangumiRes = await getBangumi(originBangumiUrl.pathname); - const bangumiData = await bangumiRes.json(); - log("info", "判断剧集", bangumiData); + if (animeIsNotPrefer) continue; - // 过滤集标题正则条件的 episode + let isMatch = false; + + // 1. 标题/年份匹配检查 + if (season && episode) { + // 剧集模式 + if (normalizeSpaces(anime.animeTitle).includes(normalizedTitle)) { + // 年份匹配优先于季匹配 + if (!matchYear(anime, year)) { + log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`); + continue; + } + + // 年份匹配通过后,再判断season + const animeIsPrefer = + globals.rememberLastSelect && + preferAnimeId && + (String(anime.bangumiId) === String(preferAnimeId) || + String(anime.animeId) === String(preferAnimeId)); + + if (matchSeason(anime, title, season) || animeIsPrefer) { + isMatch = true; + } + } + } else { + // 电影模式 + const animeTitle = anime.animeTitle.split("(")[0].trim(); + if (animeTitle === title) { + // 年份匹配检查 + if (!matchYear(anime, year)) { + log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`); + continue; + } + isMatch = true; + } + } + + if (!isMatch) continue; + + // 2. 获取剧集详情 (无条件获取,确保数据完整性) + let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); + const bangumiRes = await getBangumi(originBangumiUrl.pathname); + const bangumiData = await bangumiRes.json(); + + // 输出匹配分数及原始数据日志 + log("info", "判断剧集", `Anime: ${anime.animeTitle}`); + log("info", bangumiData); + + let matchedEpisode = null; + + if (season && episode) { + // 剧集模式逻辑 const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => { return !globals.episodeTitleFilter.test(episode.episodeTitle); }); - - // 过滤集标题一致的 episode,且保留首次出现的集标题的 episode const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes); + log("info", "过滤后的集标题", filteredEpisodes.map(episode => episode.episodeTitle)); - // 年份匹配通过后,再判断season - const animeIsPrefer = - globals.rememberLastSelect && - preferAnimeId && - (String(anime.bangumiId) === String(preferAnimeId) || - String(anime.animeId) === String(preferAnimeId)); - if (matchSeason(anime, title, season) || animeIsPrefer) { - // 使用新的集数匹配策略 - const matchedEpisode = findEpisodeByNumber(filteredEpisodes, episode, platform); - if (matchedEpisode) { - resEpisode = matchedEpisode; - resAnime = anime; - break; - } + // 匹配集数 (注意:findEpisodeByNumber 已增强支持模糊平台匹配) + matchedEpisode = findEpisodeByNumber(filteredEpisodes, episode, platform); + } else { + // 电影模式逻辑 + if (bangumiData.bangumi.episodes.length > 0) { + if (platform) { + // 在剧集列表中寻找匹配特定平台的资源 + const targetEp = bangumiData.bangumi.episodes.find(ep => { + const epTitlePlatform = extractEpisodeTitle(ep.episodeTitle); + return getPlatformMatchScore(epTitlePlatform, platform) > 0; + }); + + if (targetEp) { + matchedEpisode = targetEp; + } + } else { + matchedEpisode = bangumiData.bangumi.episodes[0]; + } } - } } - } else { - // 判断电影 - for (const anime of searchData.animes) { - const animeIsNotPrefer = - globals.rememberLastSelect && - preferAnimeId && - String(anime.bangumiId) !== String(preferAnimeId) && - String(anime.animeId) !== String(preferAnimeId); - if (animeIsNotPrefer) continue; - const animeTitle = anime.animeTitle.split("(")[0].trim(); - if (animeTitle === title) { - // 年份匹配优先 - if (!matchYear(anime, year)) { - log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`); - continue; + + // 3. 匹配结果处理与评分比较 + if (matchedEpisode) { + // 计算当前匹配的得分 + const actualPlatform = extractPlatformFromTitle(anime.animeTitle) || anime.source; + let currentScore = 0; + + if (platform) { + // 如果指定了平台偏好,计算匹配得分 + currentScore = getPlatformMatchScore(actualPlatform, platform); + } else { + // 如果没有指定平台偏好,默认为 1 + currentScore = 1; + } + + // 比较并更新最佳结果 + // 逻辑:如果有更好的分数,或者之前没有匹配到任何结果,则更新 + if (currentScore > bestRes.score) { + bestRes = { + anime: anime, + episode: matchedEpisode, + score: currentScore + }; + } + + // 如果没有指定平台偏好 (platform 为空),则保持原版行为: + // 找到第一个符合条件的就立刻返回,不进行后续比较 + if (!platform) { + break; } - let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); - const bangumiRes = await getBangumi(originBangumiUrl.pathname); - const bangumiData = await bangumiRes.json(); - log("info", bangumiData); - - if (platform) { - const firstIndex = bangumiData.bangumi.episodes.findIndex(episode => extractEpisodeTitle(episode.episodeTitle) === platform); - const indexCount = bangumiData.bangumi.episodes.filter(episode => extractEpisodeTitle(episode.episodeTitle) === platform).length; - if (indexCount > 0) { - resEpisode = bangumiData.bangumi.episodes[firstIndex]; - resAnime = anime; - break; - } - } else { - if (bangumiData.bangumi.episodes.length > 0) { - resEpisode = bangumiData.bangumi.episodes[0]; - resAnime = anime; - break; - } - } - } + // 如果指定了平台偏好,则继续循环查找是否有得分更高的源(最小杂质匹配) } } - return {resEpisode, resAnime}; + + return { resEpisode: bestRes.episode, resAnime: bestRes.anime }; } async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime) { diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index a87df18..938ddd7 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -123,13 +123,25 @@ export class Envs { /** * 解析平台排序 + * 支持单个平台或通过&连接的组合平台(如 bilibili1&dandan) * @returns {Array} 平台排序数组 */ static resolvePlatformOrder() { - const orderArr = this.get('PLATFORM_ORDER', '', 'string') + const rawOrder = this.get('PLATFORM_ORDER', '', 'string'); + + const orderArr = rawOrder .split(',') .map(s => s.trim()) - .filter(s => this.ALLOWED_PLATFORMS.includes(s)); + .filter(item => { + if (!item) return false; + // 如果包含 &,则分割校验每一部分是否有效 + if (item.includes('&')) { + const parts = item.split('&').map(p => p.trim()); + return parts.every(p => this.ALLOWED_PLATFORMS.includes(p)); + } + // 单个平台直接校验 + return this.ALLOWED_PLATFORMS.includes(item); + }); this.accessedEnvVars.set('PLATFORM_ORDER', orderArr); @@ -303,10 +315,10 @@ 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: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,支持多源链式合并,第一个为主源。\n格式:源1&源2&源3,多组用逗号分隔。\n示例:dandan&animeko&bahamut, bilibili&animeko\n目前允许的源:tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko' }, + 'MERGE_SOURCE_PAIRS': { category: 'source', type: 'multi-select', options: this.MERGE_ALLOWED_SOURCES, description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,允许多组,允许多源,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源。\n格式:源1&源2&源3,多组用逗号分隔。\n示例:dandan&animeko&bahamut,bilibili&animeko' }, // 匹配配置 - 'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置' }, + '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”' }, 'EPISODE_TITLE_FILTER': { category: 'match', type: 'text', description: '剧集标题过滤规则' }, 'ENABLE_EPISODE_FILTER': { category: 'match', type: 'boolean', description: '集标题过滤开关' }, 'STRICT_TITLE_MATCH': { category: 'match', type: 'boolean', description: '严格标题匹配模式' }, diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js index debf2a8..d72c677 100644 --- a/danmu_api/ui/css/components.css.js +++ b/danmu_api/ui/css/components.css.js @@ -712,6 +712,7 @@ export const componentsCssContent = /* css */ ` display: inline-block; margin-left: 10px; } + /* Bilibili Cookie 编辑器样式 */ .bili-cookie-editor { display: flex; @@ -758,4 +759,185 @@ export const componentsCssContent = /* css */ ` width: 100%; } } + +/* 多选标签与合并模式相关样式 */ +.selected-tag { + display: inline-flex; + align-items: center; + gap: 8px; + background: #667eea; + color: white; + padding: 8px 12px; + border-radius: 20px; + cursor: move; + user-select: none; + transition: all 0.3s; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + max-width: 100%; + height: auto; + white-space: normal; + word-break: break-all; + line-height: 1.4; +} + +.merge-mode-controls { + display: flex; + align-items: center; + gap: 10px; + margin: 10px 0; +} + +.merge-mode-btn { + padding: 6px 12px; + background: #f8f9fa; + border: 1px solid #ddd; + border-radius: 20px; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.3s; + color: #666; +} + +.merge-mode-btn.active { + background: #e3f2fd; + border-color: #2196f3; + color: #2196f3; + font-weight: 500; +} + +.staging-area { + display: none; + background: #e3f2fd; + border: 2px dashed #90caf9; + border-radius: 8px; + padding: 10px; + margin-bottom: 15px; + flex-wrap: wrap; + gap: 8px; + align-items: center; + min-height: 52px; + position: relative; + transition: all 0.3s; +} + +.staging-area.active { + display: flex; + animation: slideDown 0.3s; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.staging-area::before { + content: '合并组暂存区:'; + color: #1976d2; + font-size: 12px; + font-weight: bold; + margin-right: 5px; +} + +.staging-tag { + background: white; + color: #1976d2; + border: 1px solid #bbdefb; + padding: 4px 10px; + border-radius: 15px; + font-size: 13px; + display: flex; + align-items: center; + gap: 5px; + cursor: move; + user-select: none; + max-width: 100%; + word-break: break-all; +} + +.staging-tag.drag-over { + background: #bbdefb; + border-color: #2196f3; + transform: scale(1.05); +} + +.staging-tag.dragging { + opacity: 0.5; + transform: scale(0.95); + background: #e3f2fd; +} + +.staging-tag .remove-btn { + color: #ef5350; + cursor: pointer; + font-weight: bold; + font-size: 14px; +} + +.staging-separator { + color: #999; + font-weight: bold; +} + +.confirm-merge-btn { + margin-left: auto; + background: #4caf50; + color: white; + border: none; + border-radius: 50%; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + transition: all 0.2s; +} + +.confirm-merge-btn:hover { + background: #43a047; + transform: scale(1.1); +} + +.confirm-merge-btn:disabled { + background: #ccc; + cursor: not-allowed; + transform: none; +} + +.available-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.available-tag { + padding: 6px 12px; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + user-select: none; +} + +.available-tag:hover { + background: #f0f0f0; + border-color: #bbb; +} + +.available-tag.disabled { + opacity: 0.5; + cursor: not-allowed; + background: #f5f5f5; + color: #aaa; + pointer-events: none; + border-color: #eee; + box-shadow: none; +} `; diff --git a/danmu_api/ui/js/systemsettings.js b/danmu_api/ui/js/systemsettings.js index 2f128a1..89bbd74 100644 --- a/danmu_api/ui/js/systemsettings.js +++ b/danmu_api/ui/js/systemsettings.js @@ -1,5 +1,9 @@ // language=JavaScript export const systemSettingsJsContent = /* javascript */ ` +// 全局变量定义 +let isMergeMode = false; +let stagingTags = []; + // 显示清理缓存确认模态框 function showClearCacheModal() { document.getElementById('clear-cache-modal').classList.add('active'); @@ -384,6 +388,7 @@ function renderValueInput(item) { const container = document.getElementById('value-input-container'); const type = item ? item.type : document.getElementById('value-type').value; const value = item ? item.value : ''; + const currentKey = item ? item.key : document.getElementById('env-key').value; if (type === 'boolean') { // 布尔开关 @@ -454,6 +459,13 @@ function renderValueInput(item) { // 确保value是字符串类型后再进行split操作 const stringValue = typeof value === 'string' ? value : String(value || ''); const selectedValues = stringValue ? stringValue.split(',').map(v => v.trim()).filter(v => v) : []; + + // 检查是否为 SOURCE_ORDER,如果是则不显示合并模式 + const shouldShowMergeMode = currentKey === 'MERGE_SOURCE_PAIRS' || currentKey === 'PLATFORM_ORDER'; + + // 每次渲染时重置合并模式状态 + isMergeMode = false; + stagingTags = []; const optionsInput = item ? '' : \`
@@ -475,12 +487,27 @@ function renderValueInput(item) {
\`).join('')} + + \${shouldShowMergeMode ? \` +
+
+ 🔗️ 开启合并模式 +
+
+ 开启后点击下方选项将添加到暂存区,组合后点击 √ 确认 +
+
+ +
+ +
+ \` : ''} +
\${options.map(opt => { - const isSelected = selectedValues.includes(opt); return \` -
\${opt}
@@ -491,6 +518,8 @@ function renderValueInput(item) { \`; // 设置拖动事件 + // 立即执行一次状态检查,确保已选项变灰 + setTimeout(updateTagStates, 0); setupDragAndDrop(); } else if (type === 'map') { @@ -618,11 +647,71 @@ function updateTagOptions() { \`).join(''); } +// 统一的状态检查函数 +function updateTagStates() { + // 确保 DOM 元素存在,防止在渲染过程中被调用出错 + const keyInput = document.getElementById('env-key'); + if (!keyInput) return; + + const currentKey = keyInput.value; + const isMergeSourcePairs = currentKey === 'MERGE_SOURCE_PAIRS'; + + // 1. 获取当前暂存区中的Token (防止同组内重复) + const stagingTokens = new Set(stagingTags); + + // 2. 获取已确认的 Selected Tags (仅在非合并模式下需要检查) + const selectedTagElements = Array.from(document.querySelectorAll('.selected-tag')); + + // 3. 更新所有可选项的状态 + const availableTags = document.querySelectorAll('.available-tag'); + availableTags.forEach(tag => { + const value = tag.dataset.value; + let shouldDisable = false; + + if (isMergeMode) { + // [合并模式逻辑] + // 只要不在当前的暂存区中,就可以选(允许 bilibili&a 和 bilibili&b) + // 也就是说,我们完全不检查 selectedTagElements + if (stagingTokens.has(value)) { + shouldDisable = true; + } + } else { + // [普通模式逻辑] + // 只要已经被选了,就禁用 (精准匹配) + const isAlreadySelected = selectedTagElements.some(el => el.dataset.value === value); + if (isAlreadySelected) { + shouldDisable = true; + } + + // 特殊情况:如果是 MERGE_SOURCE_PAIRS 但没开合并模式,且还没被选,也禁用(强迫用户开开关) + if (isMergeSourcePairs && !isAlreadySelected) { + shouldDisable = true; + } + } + + if (shouldDisable) { + tag.classList.add('disabled'); + } else { + tag.classList.remove('disabled'); + } + }); +} + // 添加已选标签 function addSelectedTag(element) { - if (element.classList.contains('disabled')) return; - const value = element.dataset.value; + + if (isMergeMode) { + if (!stagingTags.includes(value)) { + stagingTags.push(value); + renderStagingArea(); + updateTagStates(); // 立即更新状态 (该选项变灰,防止同组重复) + } + return; + } + + if (element.classList.contains('disabled')) return; + const container = document.getElementById('selected-tags'); // 移除empty类 @@ -639,35 +728,21 @@ function addSelectedTag(element) { \`; container.appendChild(tag); - - // 禁用可选项 - element.classList.add('disabled'); - - // 重新设置拖动事件,确保新添加的标签也能拖动和删除 + updateTagStates(); // 立即更新状态 setupDragAndDrop(); } // 移除已选标签 function removeSelectedTag(button) { const tag = button.parentElement; - const value = tag.dataset.value; - const container = document.getElementById('selected-tags'); - - // 移除标签 tag.remove(); - // 如果没有标签了,添加empty类 + const container = document.getElementById('selected-tags'); if (container.children.length === 0) { container.classList.add('empty'); } - // 启用对应的可选项 - const availableTag = document.querySelector(\`.available-tag[data-value="\${value}"]\`); - if (availableTag) { - availableTag.classList.remove('disabled'); - } - - // 重新设置拖动事件,确保其他标签仍然可以拖动 + updateTagStates(); // 移除后立即释放状态 setupDragAndDrop(); } @@ -675,19 +750,275 @@ function removeSelectedTag(button) { function updateMultiOptions() { const input = document.getElementById('multi-options'); const options = input.value.split(',').map(s => s.trim()).filter(s => s); - const selectedValues = Array.from(document.querySelectorAll('.selected-tag')) - .map(el => el.dataset.value); const container = document.getElementById('available-tags'); container.innerHTML = options.map(opt => { - const isSelected = selectedValues.includes(opt); return \` -
\${opt}
\`; }).join(''); + + updateTagStates(); // 初始化时更新状态 +} + +// 切换合并模式 +function toggleMergeMode() { + isMergeMode = !isMergeMode; + const btn = document.getElementById('merge-mode-toggle'); + const stagingArea = document.getElementById('staging-area'); + + if (isMergeMode) { + btn.classList.add('active'); + btn.innerHTML = '⛓‍💥 合并模式已开启,点击关闭'; + stagingArea.classList.add('active'); + renderStagingArea(); + } else { + btn.classList.remove('active'); + btn.innerHTML = '🔗️ 点击开启合并模式'; + stagingArea.classList.remove('active'); + stagingTags = []; + } + + // 切换模式时立即刷新所有可选项状态 + updateTagStates(); +} + +// 渲染暂存区 +function renderStagingArea() { + const container = document.getElementById('staging-area'); + const confirmBtn = container.querySelector('.confirm-merge-btn'); + + while (container.firstChild && container.firstChild !== confirmBtn) { + container.removeChild(container.firstChild); + } + + if (stagingTags.length === 0) { + const hint = document.createElement('span'); + hint.textContent = '请点击下方选项进行组合...'; + hint.style.color = '#666'; + hint.style.fontSize = '12px'; + container.insertBefore(hint, confirmBtn); + confirmBtn.disabled = true; + } else { + stagingTags.forEach((tag, index) => { + if (index > 0) { + const sep = document.createElement('span'); + sep.className = 'staging-separator'; + sep.textContent = '&'; + container.insertBefore(sep, confirmBtn); + } + const tagEl = document.createElement('div'); + tagEl.className = 'staging-tag'; + tagEl.draggable = true; + tagEl.dataset.value = tag; + tagEl.dataset.index = index; + tagEl.innerHTML = \`\${tag}×\`; + container.insertBefore(tagEl, confirmBtn); + }); + confirmBtn.disabled = false; + setupStagingDragAndDrop(); + } +} + +// 从暂存区移除 +function removeFromStaging(index) { + stagingTags.splice(index, 1); + renderStagingArea(); + updateTagStates(); // 移除后刷新状态 +} + +// 确认添加合并组 +function confirmMergeGroup() { + if (stagingTags.length === 0) return; + const groupValue = stagingTags.join('&'); + const container = document.getElementById('selected-tags'); + container.classList.remove('empty'); + + const tag = document.createElement('div'); + tag.className = 'selected-tag'; + tag.draggable = true; + tag.dataset.value = groupValue; + tag.innerHTML = \`\${groupValue}\`; + + container.appendChild(tag); + setupDragAndDrop(); + + stagingTags = []; // 清空暂存区 + renderStagingArea(); + updateTagStates(); // 关键:确认后立即重新计算所有可选项的禁用状态 (重置为可用) +} + +// 设置暂存区拖放功能 +function setupStagingDragAndDrop() { + const container = document.getElementById('staging-area'); + const tags = container.querySelectorAll('.staging-tag'); + + tags.forEach(tag => { + tag.addEventListener('dragstart', handleStagingDragStart); + tag.addEventListener('dragend', handleStagingDragEnd); + tag.addEventListener('dragover', handleStagingDragOver); + tag.addEventListener('drop', handleStagingDrop); + tag.addEventListener('dragenter', handleStagingDragEnter); + tag.addEventListener('dragleave', handleStagingDragLeave); + + tag.addEventListener('touchstart', handleStagingTouchStart); + tag.addEventListener('touchmove', handleStagingTouchMove); + tag.addEventListener('touchend', handleStagingTouchEnd); + }); +} + +let stagingDraggedElement = null; + +function handleStagingDragStart(e) { + stagingDraggedElement = this; + this.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; +} + +function handleStagingDragEnd(e) { + this.classList.remove('dragging'); + document.querySelectorAll('.staging-tag').forEach(tag => { + tag.classList.remove('drag-over'); + }); +} + +function handleStagingDragOver(e) { + if (e.preventDefault) { + e.preventDefault(); + } + e.dataTransfer.dropEffect = 'move'; + return false; +} + +function handleStagingDragEnter(e) { + if (this !== stagingDraggedElement) { + this.classList.add('drag-over'); + } +} + +function handleStagingDragLeave(e) { + this.classList.remove('drag-over'); +} + +function handleStagingDrop(e) { + if (e.stopPropagation) { + e.stopPropagation(); + } + + if (stagingDraggedElement !== this) { + const draggedIndex = parseInt(stagingDraggedElement.dataset.index); + const targetIndex = parseInt(this.dataset.index); + + const [movedItem] = stagingTags.splice(draggedIndex, 1); + stagingTags.splice(targetIndex, 0, movedItem); + + renderStagingArea(); + } + + this.classList.remove('drag-over'); + return false; +} + +function handleStagingTouchStart(e) { + if (e.target.classList.contains('remove-btn')) { + return; + } + + e.preventDefault(); + stagingDraggedElement = this; + this.classList.add('dragging'); + + this.style.transform = 'rotate(5deg)'; + this.style.opacity = '0.8'; + this.style.zIndex = '1000'; +} + +function handleStagingTouchMove(e) { + if (!stagingDraggedElement) return; + e.preventDefault(); + + const touch = e.touches[0]; + const elementRect = stagingDraggedElement.getBoundingClientRect(); + + if (!document.getElementById('staging-touch-drag-ghost')) { + const ghostElement = stagingDraggedElement.cloneNode(true); + ghostElement.id = 'staging-touch-drag-ghost'; + ghostElement.style.position = 'fixed'; + ghostElement.style.left = '0'; + ghostElement.style.top = '0'; + ghostElement.style.pointerEvents = 'none'; + ghostElement.style.zIndex = '9999'; + ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; + ghostElement.style.opacity = '0.8'; + ghostElement.style.boxSizing = 'border-box'; + ghostElement.style.width = elementRect.width + 'px'; + ghostElement.style.height = elementRect.height + 'px'; + document.body.appendChild(ghostElement); + } else { + const ghostElement = document.getElementById('staging-touch-drag-ghost'); + ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; + } + + const container = document.getElementById('staging-area'); + const tags = Array.from(container.querySelectorAll('.staging-tag')).filter(tag => tag !== stagingDraggedElement); + let targetElement = null; + + for (const tag of tags) { + const rect = tag.getBoundingClientRect(); + if (touch.clientX >= rect.left && touch.clientX <= rect.right && + touch.clientY >= rect.top && touch.clientY <= rect.bottom) { + targetElement = tag; + break; + } + } + + document.querySelectorAll('.staging-tag').forEach(tag => { + if (tag !== stagingDraggedElement) { + tag.classList.remove('drag-over'); + } + }); + + if (targetElement) { + targetElement.classList.add('drag-over'); + } +} + +function handleStagingTouchEnd(e) { + if (!stagingDraggedElement) return; + e.preventDefault(); + + const ghostElement = document.getElementById('staging-touch-drag-ghost'); + if (ghostElement) { + document.body.removeChild(ghostElement); + } + + const touch = e.changedTouches[0]; + const targetElement = document.elementFromPoint(touch.clientX, touch.clientY); + const targetTag = targetElement.closest('.staging-tag'); + + if (targetTag && targetTag !== stagingDraggedElement) { + const draggedIndex = parseInt(stagingDraggedElement.dataset.index); + const targetIndex = parseInt(targetTag.dataset.index); + + const [movedItem] = stagingTags.splice(draggedIndex, 1); + stagingTags.splice(targetIndex, 0, movedItem); + + renderStagingArea(); + } + + stagingDraggedElement.style.transform = ''; + stagingDraggedElement.style.opacity = ''; + stagingDraggedElement.style.zIndex = ''; + stagingDraggedElement.classList.remove('dragging'); + + document.querySelectorAll('.staging-tag').forEach(tag => { + tag.classList.remove('drag-over'); + }); + + stagingDraggedElement = null; } // 设置拖放功能 @@ -1148,6 +1479,11 @@ document.getElementById('env-form').addEventListener('submit', async function(e) const options = Array.from(document.querySelectorAll('.tag-option')).map(el => el.dataset.value); itemData = { key, value, description, type, options }; } else if (type === 'multi-select') { + // 如果开启了合并模式,且暂存区还有内容,自动将其视为确认添加 + if (isMergeMode && stagingTags && stagingTags.length > 0) { + confirmMergeGroup(); + } + const selectedTags = Array.from(document.querySelectorAll('.selected-tag')) .map(el => el.dataset.value); value = selectedTags.join(','); diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index c838a26..2398d7f 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -1,5 +1,5 @@ import { globals } from '../configs/globals.js'; -import { log } from './log-util.js'; +import { log as baseLog } from './log-util.js'; import { normalizeSpaces } from './common-util.js'; import { addAnime } from './cache-util.js'; import { simplized } from '../utils/zh-util.js'; @@ -13,101 +13,298 @@ export const MERGE_DELIMITER = '$$$'; // 定义前端显示的源连接符 export const DISPLAY_CONNECTOR = '&'; +// 调试级别的日志开关 false/true +const ENABLE_VERBOSE_MERGE_LOG = false; + +/** + * 覆盖当前文件的 log 定义 + * 这个函数会拦截所有 log(...) 调用,并根据内容过滤 + */ +function log(level, ...args) { + // 检查是否是需要过滤的“细碎”日志 + const isMergeCheck = typeof args[0] === 'string' && args[0].includes('[Merge-Check]'); + + if (isMergeCheck && !ENABLE_VERBOSE_MERGE_LOG) { + return; + } + + // 否则,调用原有的日志逻辑 + baseLog(level, ...args); +} + +// ========================================== +// 正则表达式预编译区 +// 将所有静态正则提取至此,避免函数重复调用时的重新编译开销 +// ========================================== + +// 纯季度/Part标识正则 (用于判断副标题是否仅仅是 S2 或 Part 3) +const REGEX_PURE_SEASON_PART = /^(?:(?:第|S(?:eason)?)\s*\d+(?:季|期|部)?|(?:Part|P|第)\s*\d+(?:部分)?)$/i; + +// 语言识别正则 +const RE_LANG_CN = /(普通话|国语|中文配音|中配|中文版|粤配|粤语)/; +const RE_LANG_JP = /(日语|日配|原版|原声)/; + +// cleanText 相关正则 (通用清洗) +const RE_NA_TAG = /(\(|(|\[)N\/A(\)|)|\])/gi; +const RE_PART_NORM = /第(\d+)部分/g; +const RE_PART_NORM_2 = /(?:Part|P)[\s.]*(\d+)/gi; +const RE_FINAL_SEASON = /(?:The\s+)?Final\s+Season/gi; +const RE_SEASON_NORM = /(?:Season|S)\s*(\d+)/gi; +const RE_CN_SEASON = /第([一二三四五六七八九十])季/g; +const RE_ROMAN_SEASON = /(\s|^)(IV|III|II|I)(\s|$)/g; +const RE_CN_DUB_VER = /(\(|(|\[)?(普通话|国语|中文配音|中配|中文)版?(\)|)|\])?/g; +const RE_JP_DUB_VER = /(\(|(|\[)?(日语|日配|原版|原声)版?(\)|)|\])?/g; +const RE_SOURCE_TAG = /【.*?】/g; +const RE_REGION_LIMIT = /(\(|(|\[)仅限.*?地区(\)|)|\])/g; +const RE_PUNCTUATION = /[!!??,,.。、~~::\-–—_]/g; +const RE_WHITESPACE = /\s+/g; + +// cleanTitleForSimilarity 相关正则 (相似度专用极简清洗) +const RE_FROM_SUFFIX = /\s*from\s+.*$/i; +const RE_PARENTHESES_CONTENT = /(\(|(|\[).*?(\)|)|\])/g; +const RE_SEASON_INFO_STRONG = /(?:season|s|第)\s*[0-9一二三四五六七八九十]+\s*(?:季|期|部(?!分))?/gi; +const RE_PART_INFO_STRONG = /(?:part|p|第)\s*\d+\s*(?:部分)?/gi; +const RE_MOVIE_KEYWORDS = /剧场版|movie|film|电影|/gi; +const RE_LANG_KEYWORDS_STRONG = /(?:中配|普通话|国语|日语|原声|粤配|粤语|日配)(?:版)?/g; +const RE_LONE_VER_CHAR = /(\s|^)版(\s|$)/g; +const RE_NON_ALPHANUM_CN = /[^\u4e00-\u9fa5a-zA-Z0-9]/g; + +// cleanEpisodeText 相关正则 (集标题清洗) +const RE_EP_SUFFIX_DIGIT = /_\d+(?=$|\s)/g; +const RE_FILE_NOISE = /_(\d{2,4})(?=\.)/g; +const RE_EP_SEASON_PREFIX = /(?:^|\s)(?:第[0-9一二三四五六七八九十]+季|S[0-9]+)(?:\s+|_)/gi; +const RE_LANG_CN_STD = /普通话|国语/g; +const RE_LANG_JP_STD = /原声|原版/g; +const RE_EP_PUNCTUATION = /[!!??,,.。、~~::\-–—]/g; + +// checkTitleSubtitleConflict 相关正则 (结构检测) +const RE_META_SUFFIX = /(\(|(|\[)(续篇|TV版|无修|未删减|完整版)(\)|)|\])/gi; +const RE_YEAR_TAG = /(\(|(|\[)\d{4}(\)|)|\]).*$/i; +const RE_SUBTITLE_SEPARATOR = /^[\s::\-–—((\[【]/; +const RE_SPACE_STRUCTURE = /.+[\s\u00A0\u3000].+/; +const RE_SPLIT_SPACES = /[\s\u00A0\u3000]+/; + +// extractSeasonMarkers 相关正则模式 (季数/类型提取) +const SEASON_PATTERNS = [ + { regex: /(?:第)?(\d+)(?:季|期|部(?!分))/, prefix: 'S' }, + { regex: /season\s*(\d+)/, prefix: 'S' }, + { regex: /s(\d+)/, prefix: 'S' }, + { regex: /part\s*(\d+)/, prefix: 'P' }, + { regex: /(ova|oad)/, val: 'OVA' }, + { regex: /(剧场版|movie|film|电影)/, val: 'MOVIE' }, + { regex: /(续篇|续集)/, val: 'SEQUEL' }, + { regex: /sp/, val: 'SP' }, + // 末尾数字检测:改为使用无 Part 的文本进行检测 + { regex: /[^0-9](\d)$/, prefix: 'S', useCleaned: true } +]; +const RE_PART_ANY = /(?:part|p)\s*\d+/gi; // 用于预处理移除Part + +// 复杂特定后缀 (高优先级检测) +// 包含: A's(S2), StrikerS(S3), ViVid(S4), SuperS(S4) +const RE_SUFFIX_SPECIFIC_MAP = [ + { regex: /(?:\s|^)A's$/i, val: 'S2' }, + { regex: /(?:\s|^)StrikerS$/i, val: 'S3' }, + { regex: /(?:\s|^)ViVid$/i, val: 'S4' }, + { regex: /(?:\s|^)SuperS$/i, val: 'S4' } +]; + +// 歧义后缀 (S, T, R, II...) +// 这些后缀含义不确定,可能是 S2, S3 或 OVA,需配合内容探测 +// 允许紧跟汉字([\u4e00-\u9fa5])或空格,且后缀后必须是结束符或标点 +const RE_SUFFIX_AMBIGUOUS = /(?:[\s\u4e00-\u9fa5]|^)(S|T|R|II|III|IV)(?=$|[\s\(\(\[【])/i; + +// 续篇标识 +const RE_SUFFIX_SEQUEL = /(?:续篇|续集|The Sequel)/i; + +// extractEpisodeInfo 相关正则 (集数提取) +const RE_DANDAN_TAG = /^【(dandan|animeko)】/i; +const RE_SPECIAL_START = /^S\d+/i; // dandan S1 check +const RE_MOVIE_CHECK = /剧场版|movie|film/i; +const RE_PV_CHECK = /(pv|trailer|预告)/i; +const RE_SPECIAL_CHECK = /^(s|o|sp|special)\d/i; +const RE_EP_SEASON_MATCH = /(?:^|\s)(?:第|S)(\d+)[季S]/i; +const RE_EP_NUM_STRATEGY_A = /(?:第|s)(\d+)[季s].*?(?:第|ep|e)(\d+)/i; +const RE_EP_NUM_STRATEGY_B = /(?:ep|o|s|part|第)\s*(\d+(\.\d+)?)(?!\s*[季期部])/i; +const RE_EP_NUM_STRATEGY_C = /(?:^|\s)(\d+(\.\d+)?)(?:话|集|\s|$)/; + +// getContentCategory 相关 (内容分类) +const RE_ANIME_KW = /(动画|动漫|日漫|国漫)/; +const RE_REAL_KW = /(电视剧|真人剧|综艺)/; +const RE_ANIMEKO_SOURCE = /animeko/i; + +// identifyRedundantTitle 相关 (冗余检测) +const RE_REDUNDANT_SEPARATOR = /[\s::~~]/; +const RE_REDUNDANT_UNSAFE_END = /[\(\(\[【::~~\-]$/; +const RE_REDUNDANT_VALID_CHARS = /[\u4e00-\u9fa5a-zA-Z]{2,}/; + +// findBestAlignmentOffset - CN Strict Mode (纯中文模式) +const RE_CN_STRICT_CORE_REMOVE = /[0-9a-zA-Z\s第季集话partEPep._\-–—::【】()()]/gi; + +// 特殊集沉底相关 +const RE_SPECIAL_SINK_TITLE = /(?:^|\s)(S\d+|SP|Special|PV|OP|ED|O\d+)(?:\s|$)/i; + +/** + * 获取文本的语言/配音类型 + * 用于识别中配、日配、原声等特征,辅助匹配优先级判断 + * @param {string} text 标题文本 + * @returns {string} 语言类型 ('CN' | 'JP' | 'Unspecified') + */ +function getLanguageType(text) { + if (!text) return 'Unspecified'; + const t = text.toLowerCase(); + if (RE_LANG_CN.test(t)) return 'CN'; + if (RE_LANG_JP.test(t)) return 'JP'; + return 'Unspecified'; +} + /** * 文本清洗工具函数 - * 将文本转为简体,并移除特定源标识、地区限制及标点符号,用于提高匹配精度 + * 将文本转为简体,移除干扰标识,并对季数、章节进行标准化处理 * @param {string} text 原始文本 - * @returns {string} 清洗后的文本 + * @returns {string} 清洗后的标准化文本 */ function cleanText(text) { if (!text) return ''; - // 1. 繁体转简体 let clean = simplized(text); - // 2. 移除源标识如 【dandan】 - clean = clean.replace(/【.*?】/g, ''); - // 3. 移除地区限制标识如 (仅限台湾地区) - clean = clean.replace(/(\(|()仅限.*?地区(\)|))/g, ''); - // 4. 移除常见标点符号 (避免 "不行!" 和 "不行。" 被判为不同) - clean = clean.replace(/[!!??,,.。、~~]/g, ' '); - return normalizeSpaces(clean).toLowerCase().trim(); + clean = clean.replace(RE_NA_TAG, ''); + clean = clean.replace(RE_PART_NORM, 'part $1'); + clean = clean.replace(RE_PART_NORM_2, 'part $1'); + + clean = clean.replace(RE_FINAL_SEASON, '最终季'); + clean = clean.replace(RE_SEASON_NORM, '第$1季'); + + const cnNums = {'一':'1', '二':'2', '三':'3', '四':'4', '五':'5', '六':'6', '七':'7', '八':'8', '九':'9', '十':'10'}; + clean = clean.replace(RE_CN_SEASON, (m, num) => `第${cnNums[num]}季`); + + clean = clean.replace(RE_ROMAN_SEASON, (match, p1, roman, p2) => { + const rMap = {'I':'1', 'II':'2', 'III':'3', 'IV':'4'}; + return `${p1}第${rMap[roman]}季${p2}`; + }); + + clean = clean.replace(RE_CN_DUB_VER, '中配版'); + clean = clean.replace(RE_JP_DUB_VER, ''); + clean = clean.replace(RE_SOURCE_TAG, ''); + clean = clean.replace(RE_REGION_LIMIT, ''); + clean = clean.replace(RE_PUNCTUATION, ' '); + + return clean.replace(RE_WHITESPACE, ' ').toLowerCase().trim(); +} + +/** + * 相似度计算专用极简清洗函数 + * 深度增强清洗能力,强力移除季数、Part、括号内容,确保 "S1" 与 "S1 Part 2" 文本一致 + * @param {string} text 原始标题 + * @returns {string} 用于相似度计算的极简字符串 + */ +function cleanTitleForSimilarity(text) { + if (!text) return ''; + let clean = simplized(text); + + clean = clean.replace(RE_SOURCE_TAG, ''); + clean = clean.replace(RE_FROM_SUFFIX, ''); + clean = clean.replace(RE_NA_TAG, ''); + clean = clean.replace(RE_PARENTHESES_CONTENT, ''); + clean = clean.replace(RE_SEASON_INFO_STRONG, ''); + clean = clean.replace(RE_PART_INFO_STRONG, ''); + clean = clean.replace(RE_MOVIE_KEYWORDS, ''); + clean = clean.replace(RE_LANG_KEYWORDS_STRONG, ''); + clean = clean.replace(RE_LONE_VER_CHAR, ''); + clean = clean.replace(RE_NON_ALPHANUM_CN, ''); + + return clean.toLowerCase(); +} + +/** + * 专用于集标题的清洗函数 + * 专门净化 "第X集_01" 等格式 + * @param {string} text 集标题 + * @returns {string} 清洗后的集标题 + */ +function cleanEpisodeText(text) { + if (!text) return ''; + let clean = simplized(text); + + clean = clean.replace(RE_EP_SUFFIX_DIGIT, ''); + clean = clean.replace(RE_FILE_NOISE, ''); + clean = clean.replace(RE_EP_SEASON_PREFIX, ' '); + clean = clean.replace(RE_SOURCE_TAG, ''); + clean = clean.replace(RE_LANG_CN_STD, '中文'); + clean = clean.replace(RE_LANG_JP_STD, '日文'); + clean = clean.replace(RE_EP_PUNCTUATION, ' '); + + return clean.replace(RE_WHITESPACE, ' ').toLowerCase().trim(); } /** * 移除标题中的所有括号内容 - * 用于提取主标题进行比对,规避副标题翻译差异(如:(※不是不可能!?) vs (※似乎可行?)) + * 用于提取主标题进行比对,规避副标题翻译差异 * @param {string} text 清洗后的文本 * @returns {string} 移除括号后的文本 */ function removeParentheses(text) { if (!text) return ''; - // 移除 () 和 () 及其内部的所有内容 - return text.replace(/(\(|().*?(\)|))/g, '').trim(); + return text.replace(RE_PARENTHESES_CONTENT, '').trim(); } /** * 清洗并提取真实的 ID/URL - * 用于从组合或带前缀的字符串中还原出原始的请求 ID + * 用于从组合字符串中还原出原始请求 ID * @param {string} urlStr 原始 URL 字符串 * @returns {string} 清洗后的 ID 或 完整 URL */ function sanitizeUrl(urlStr) { if (!urlStr) return ''; - // 去除可能存在的组合后缀,只取当前部分 let clean = String(urlStr).split(MERGE_DELIMITER)[0].trim(); - // 自动修复被错误截断协议头的 URL if (clean.startsWith('//')) { return 'https:' + clean; } - // 尝试解析 "source:id" 格式 const match = clean.match(/^([^:]+):(.+)$/); if (match) { const prefix = match[1].toLowerCase(); const body = match[2]; - // 如果前缀是 http/https,说明是原始 URL,保留 if (prefix === 'http' || prefix === 'https') { return clean; } - - // 如果 body 是 http 开头,直接返回 if (/^https?:\/\//i.test(body)) { return body; } - - // 如果 body 是 // 开头,自动补全协议 if (body.startsWith('//')) { return 'https:' + body; } - - // 普通 ID return body; } - return clean; } /** * 解析日期字符串为对象 + * 年份阈值设定为 2030 * @param {string} dateStr 日期字符串 - * @returns {Object} { year: number|null, month: number|null } + * @returns {Object} 日期对象 { year: number|null, month: number|null } */ function parseDate(dateStr) { - if (!dateStr) return { year: null, month: null }; + if (!dateStr || dateStr === 'N/A') return { year: null, month: null }; const d = new Date(dateStr); - if (isNaN(d.getTime())) return { year: null, month: null }; + const time = d.getTime(); + if (isNaN(time)) return { year: null, month: null }; + + const year = d.getFullYear(); + if (year > 2030) return { year: null, month: null }; + return { - year: d.getFullYear(), + year: year, month: d.getMonth() + 1 }; } /** * 计算编辑距离 (Levenshtein Distance) - * 用于衡量两个字符串的差异程度(对顺序敏感) + * 空间复杂度优化为 O(min(m,n)) * @param {string} s1 字符串1 * @param {string} s2 字符串2 * @returns {number} 编辑距离 @@ -115,31 +312,41 @@ function parseDate(dateStr) { function editDistance(s1, s2) { const len1 = s1.length; const len2 = s2.length; - const matrix = []; + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + // 使用两行数组代替矩阵,空间优化 + let prevRow = new Array(len2 + 1); + let currRow = new Array(len2 + 1); - for (let i = 0; i <= len1; i++) { - matrix[i] = [i]; - } for (let j = 0; j <= len2; j++) { - matrix[0][j] = j; + prevRow[j] = j; } for (let i = 1; i <= len1; i++) { + currRow[0] = i; + const char1 = s1.charCodeAt(i - 1); for (let j = 1; j <= len2; j++) { - const cost = s1.charAt(i - 1) === s2.charAt(j - 1) ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j - 1] + cost + const cost = char1 === s2.charCodeAt(j - 1) ? 0 : 1; + currRow[j] = Math.min( + currRow[j - 1] + 1, // insertion + prevRow[j] + 1, // deletion + prevRow[j - 1] + cost // substitution ); } + // Swap rows + const temp = prevRow; + prevRow = currRow; + currRow = temp; } - return matrix[len1][len2]; + + return prevRow[len2]; } /** * 计算 Dice 相似度系数 (基于字符集合) - * 用于解决长标题意译差异(如 "我怎么可能" vs "我们不可能"),对语序不敏感 + * 用于解决长标题意译差异,对语序不敏感 * @param {string} s1 字符串1 * @param {string} s2 字符串2 * @returns {number} Dice 系数 (0.0 - 1.0) @@ -147,88 +354,195 @@ function editDistance(s1, s2) { function calculateDiceSimilarity(s1, s2) { if (!s1 || !s2) return 0; - // 将字符串转换为去重的字符集合 (移除空格) - const set1 = new Set(s1.replace(/\s/g, '')); - const set2 = new Set(s2.replace(/\s/g, '')); + const set1 = new Set(s1.replace(RE_WHITESPACE, '')); + const set2 = new Set(s2.replace(RE_WHITESPACE, '')); - if (set1.size === 0 && set2.size === 0) return 1.0; - if (set1.size === 0 || set2.size === 0) return 0.0; + const size1 = set1.size; + const size2 = set2.size; + + if (size1 === 0 && size2 === 0) return 1.0; + if (size1 === 0 || size2 === 0) return 0.0; - // 计算交集大小 let intersection = 0; - for (const char of set1) { - if (set2.has(char)) { + // 优化:总是遍历较小的 Set + const [smaller, larger] = size1 < size2 ? [set1, set2] : [set2, set1]; + + for (const char of smaller) { + if (larger.has(char)) { intersection++; } } - // Dice 公式: 2 * |A∩B| / (|A| + |B|) - return (2.0 * intersection) / (set1.size + set2.size); + return (2.0 * intersection) / (size1 + size2); } /** * 计算两个字符串的综合相似度 (0.0 - 1.0) - * 结合了 编辑距离(顺序敏感)和 Dice系数(字符重合度),并预先进行清洗 + * 结合编辑距离、Dice系数和覆盖系数,取最大值 + * 引入覆盖系数解决 "判处勇者刑" vs "勇者处刑..." 问题 * @param {string} str1 字符串1 * @param {string} str2 字符串2 - * @returns {number} 相似度得分 (取多种算法的最大值) + * @returns {number} 相似度得分 */ function calculateSimilarity(str1, str2) { if (!str1 || !str2) return 0; - // 使用增强的 cleanText 进行预处理 - const s1 = cleanText(str1); - const s2 = cleanText(str2); + const s1 = cleanTitleForSimilarity(str1); + const s2 = cleanTitleForSimilarity(str2); - // 1. 精确匹配 if (s1 === s2) return 1.0; - // 2. 包含关系 (给予较高基础分) + const len1 = s1.length; + const len2 = s2.length; + const maxLen = Math.max(len1, len2); + const minLen = Math.min(len1, len2); + + // 包含关系检测 if (s1.includes(s2) || s2.includes(s1)) { - const lenRatio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); - return 0.8 + (lenRatio * 0.2); + const lenRatio = minLen / maxLen; + // 只有当长度差异不悬殊(>0.5)时,才给予包含关系的高分奖励 + if (lenRatio > 0.5) { + return 0.8 + (lenRatio * 0.2); + } } - // 3. 编辑距离得分 + // 编辑距离得分 const distance = editDistance(s1, s2); - const maxLength = Math.max(s1.length, s2.length); - const editScore = maxLength === 0 ? 1.0 : 1.0 - (distance / maxLength); + const editScore = maxLen === 0 ? 1.0 : 1.0 - (distance / maxLen); - // 4. Dice 系数得分 (处理长标题意译) - const diceScore = calculateDiceSimilarity(s1, s2); + // 预处理字符集合 (用于 Dice 和 Overlap) + const set1 = new Set(s1.replace(RE_WHITESPACE, '')); + const set2 = new Set(s2.replace(RE_WHITESPACE, '')); + const size1 = set1.size; + const size2 = set2.size; - // 返回两种算法中较高的分数 - return Math.max(editScore, diceScore); + if (size1 === 0 || size2 === 0) return 0.0; + + let intersection = 0; + const [smallerSet, largerSet] = size1 < size2 ? [set1, set2] : [set2, set1]; + for (const char of smallerSet) { + if (largerSet.has(char)) intersection++; + } + + // Dice 系数 + const diceScore = (2.0 * intersection) / (size1 + size2); + + // Overlap 系数 + let overlapScore = 0; + const minSize = Math.min(size1, size2); + if (minSize > 2) { + overlapScore = intersection / minSize; + + // 覆盖率高但长度差异大时略微扣分 + if (overlapScore > 0.6) { + const sizeRatio = minSize / Math.max(size1, size2); + if (sizeRatio < 0.6) { + overlapScore -= 0.25; + } + } + } + + return Math.max(editScore, diceScore, overlapScore); +} + +/** + * 检测主副标题结构冲突 + * @param {string} titleA 标题A + * @param {string} titleB 标题B + * @param {boolean} isDateValid 日期是否有效 + * @returns {boolean} 是否存在结构冲突 + */ +function checkTitleSubtitleConflict(titleA, titleB, isDateValid = true) { + if (!titleA || !titleB) return false; + + // 豁免:clean 后一致 + if (cleanTitleForSimilarity(titleA) === cleanTitleForSimilarity(titleB)) { + return false; + } + + const lightClean = (str) => { + if (!str) return ''; + let s = simplized(str); + s = s.replace(RE_META_SUFFIX, ''); + s = s.replace(RE_YEAR_TAG, ''); + s = s.replace(RE_SOURCE_TAG, ''); + s = s.replace(RE_FROM_SUFFIX, ''); + s = s.replace(RE_WHITESPACE, ' '); // 标准化空格 + return s.trim().toLowerCase(); + }; + + const t1 = lightClean(titleA); + const t2 = lightClean(titleB); + + if (t1 === t2) return false; + + const extractSubtitle = (fullTitle) => { + const splitters = [':', ':', ' code:', ' code:', ' season', ' part']; + for (const sep of splitters) { + const idx = fullTitle.indexOf(sep); + if (idx !== -1) return fullTitle.substring(idx).trim(); + } + // 依赖空格分割 + const spaceParts = fullTitle.split(RE_WHITESPACE); + if (spaceParts.length >= 2) { + return spaceParts.slice(1).join(' '); + } + return null; + }; + + const sub1 = extractSubtitle(t1); + const sub2 = extractSubtitle(t2); + + const [short, long] = t1.length < t2.length ? [t1, t2] : [t2, t1]; + + // 检查长标题是否包含短标题作为前缀,并跟随特定分隔符 + if (long.startsWith(short)) { + if (long.length === short.length) return false; + + const nextChar = long[short.length]; + + if (RE_SUBTITLE_SEPARATOR.test(nextChar)) { + const subtitle = long.slice(short.length).replace(RE_SUBTITLE_SEPARATOR, '').trim(); + if (!isDateValid && subtitle.length > 1) { + return true; + } + if (subtitle.length > 2) { + // 如果副标题只是 "Part 2" 则在后续 Tier 处理,这里先标记为冲突 + return true; + } + } + } + + // 双副标题差异检测 + if (sub1 && sub2) { + const sim = calculateDiceSimilarity(sub1, sub2); + if (sim < 0.2) { + return true; + } + } + + return false; } /** * 提取标题和类型中的季度/类型标识 - * 支持提取:第N季, Season N, Part N, OVA, OAD, 剧场版, 续篇, 以及末尾数字 - * 同时从 typeDesc (类型描述) 中提取特征,解决标题未写明但类型明确的情况 - * @param {string} title 标题文本 - * @param {string} typeDesc 类型描述 (可选) - * @returns {Set} 标识集合 + * 针对复杂命名规则进行特征提取,区分确定性标记与歧义标记 + * @param {string} title 标题 + * @param {string} typeDesc 类型描述 + * @returns {Set} 标识集合 (如 'S1', 'S2', 'AMBIGUOUS', 'MOVIE') */ function extractSeasonMarkers(title, typeDesc = '') { const markers = new Set(); - // 使用 cleanText 确保繁简统一 - const t = cleanText(title); + const t = cleanText(title); const type = cleanText(typeDesc || ''); - const patterns = [ - { regex: /第(\d+)[季期]/, prefix: 'S' }, - { regex: /season\s*(\d+)/, prefix: 'S' }, - { regex: /s(\d+)/, prefix: 'S' }, - { regex: /part\s*(\d+)/, prefix: 'P' }, - { regex: /(ova|oad)/, val: 'OVA' }, - { regex: /(剧场版|movie|film|电影)/, val: 'MOVIE' }, - { regex: /(续篇|续集)/, val: 'SEQUEL' }, - { regex: /sp/, val: 'SP' }, - { regex: /[^0-9](\d)$/, prefix: 'S' } - ]; + // 临时移除 Part 信息,防止 Part 数字干扰末尾季数检测 + const tWithoutParts = t.replace(RE_PART_ANY, ''); - patterns.forEach(p => { - const match = t.match(p.regex); + // 1. 标准数字季数检测 (Season 2, 第2季) + SEASON_PATTERNS.forEach(p => { + const targetText = p.useCleaned ? tWithoutParts : t; + const match = targetText.match(p.regex); if (match) { if (p.prefix) { markers.add(`${p.prefix}${parseInt(match[1])}`); @@ -238,31 +552,81 @@ function extractSeasonMarkers(title, typeDesc = '') { } }); - // 从 Type 字段中补全标记 + // 2. 检测特定复杂后缀 (高优先级,直接映射到 S2/S3/S4) + let hitSpecific = false; + for (const item of RE_SUFFIX_SPECIFIC_MAP) { + if (item.regex.test(tWithoutParts)) { + markers.add(item.val); + hitSpecific = true; + break; // 命中一个具体后缀后停止,避免重叠 + } + } + + // 3. 检测歧义后缀 (S, T, R...) + // 只有在未命中特定后缀时才检测,避免 SuperS 被拆解为 Super + S + if (!hitSpecific) { + const ambMatch = tWithoutParts.match(RE_SUFFIX_AMBIGUOUS); + if (ambMatch) { + // 不再硬性映射为 S2,而是标记为 AMBIGUOUS + // 让后续逻辑允许它与 S2, S3, S4 尝试匹配,依靠集探测定夺 + markers.add('AMBIGUOUS'); + + // 针对 II, III, IV 这种罗马数字,依然可以保留明确的季数含义以辅助判断 + const suffix = ambMatch[1].toUpperCase(); + if (suffix === 'II') markers.add('S2'); + if (suffix === 'III') markers.add('S3'); + if (suffix === 'IV') markers.add('S4'); + } + } + + // 4. 续篇标识 + if (RE_SUFFIX_SEQUEL.test(t) || type.includes('续篇')) { + markers.add('SEQUEL'); + } + + // 5. 类型检测 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'); + // 6. 中文数字季数检测 const cnNums = {'一':1, '二':2, '三':3, '四':4, '五':5, 'final': 99}; for (const [cn, num] of Object.entries(cnNums)) { if (t.includes(`第${cn}季`)) markers.add(`S${num}`); } + // 7. 默认 S1 补全逻辑 + const hasSeason = Array.from(markers).some(m => m.startsWith('S')); + const hasPart = Array.from(markers).some(m => m.startsWith('P')); + const hasAmbiguous = markers.has('AMBIGUOUS'); + const hasSequel = markers.has('SEQUEL'); + + // 如果有 Part 但没有 Season,默认视为 S1 + // 这防止 "Part 2" 误匹配 "Season 2" (确保它们产生 S1 vs S2 冲突) + if (hasPart && !hasSeason) { + markers.add('S1'); + } + + // 如果没有明确的 S 标记,也没有歧义标记和续篇标记,且不是剧场版/OVA,则默认为 S1 + const isTypeSpecial = markers.has('MOVIE') || markers.has('OVA') || markers.has('SP'); + + if (!hasSeason && !hasPart && !hasAmbiguous && !hasSequel && !isTypeSpecial) { + markers.add('S1'); + } + return markers; } /** * 获取严格的媒体类型标识 - * 仅匹配“电影”和“电视剧”,逻辑独立 - * @param {string} title - * @param {string} typeDesc - * @returns {string|null} 'MOVIE' | 'TV' | null + * 仅用于区分 'MOVIE' 和 'TV' + * @param {string} title 标题 + * @param {string} typeDesc 类型描述 + * @returns {string|null} 类型 ('MOVIE' | 'TV' | null) */ function getStrictMediaType(title, typeDesc) { - // 关键:保留原始文本中的【】等符号 const fullText = (title + ' ' + (typeDesc || '')).toLowerCase(); - // 严格匹配,不包含 "剧场版" 或 "连载" 等宽泛词,只针对 "电影" 和 "电视剧" const hasMovie = fullText.includes('电影'); const hasTV = fullText.includes('电视剧'); @@ -271,72 +635,163 @@ function getStrictMediaType(title, typeDesc) { return null; } +/** + * 获取内容分类 (真人/动漫) + * 用于解决真人剧与动漫的合并冲突 + * @param {string} title 标题 + * @param {string} typeDesc 类型描述 + * @param {string} source 来源 + * @returns {string} 分类 ('REAL' | 'ANIME' | 'UNKNOWN') + */ +function getContentCategory(title, typeDesc, source) { + if (source && RE_ANIMEKO_SOURCE.test(source)) { + return 'ANIME'; + } + + const fullText = (title + ' ' + (typeDesc || '')).toLowerCase(); + + if (RE_ANIME_KW.test(fullText)) return 'ANIME'; + if (RE_REAL_KW.test(fullText)) return 'REAL'; + + return 'UNKNOWN'; +} + +/** + * 检查是否满足“剧场版”结构豁免条件 + * 增加对纯季度/Part标记的阻断 + * @param {string} titleA 标题A + * @param {string} titleB 标题B + * @param {string} typeDescA 类型A + * @param {string} typeDescB 类型B + * @returns {boolean} 是否豁免 + */ +function checkTheatricalExemption(titleA, titleB, typeDescA, typeDescB) { + const isTheatrical = (typeDescA || '').includes('剧场版') || (typeDescB || '').includes('剧场版'); + if (!isTheatrical) return false; + + const lightClean = (str) => { + if (!str) return ''; + let s = simplized(str); + s = s.replace(RE_YEAR_TAG, ''); + s = s.replace(RE_SOURCE_TAG, ''); + s = s.replace(RE_FROM_SUFFIX, ''); + return s.trim(); + }; + + const t1 = lightClean(titleA); + const t2 = lightClean(titleB); + + if (RE_SPACE_STRUCTURE.test(t1) && RE_SPACE_STRUCTURE.test(t2)) { + // 检查副标题是否仅仅是 Part/Season,如果是则不予豁免 + const extractSub = (s) => { + const parts = s.split(RE_SPLIT_SPACES); + return parts.length > 1 ? parts.slice(1).join(' ') : ''; + }; + + const sub1 = extractSub(t1); + const sub2 = extractSub(t2); + + if (REGEX_PURE_SEASON_PART.test(sub1) || REGEX_PURE_SEASON_PART.test(sub2)) { + return false; + } + + return true; + } + + return false; +} + /** * 校验媒体类型是否冲突 - * 逻辑策略: - * 1. 如果类型明确互斥(一个电影,一个电视剧),且 - * 2. 如果双方都有具体的集数数据,按集数差异判断。 - * 3. 如果任意一方集数数据缺失(count=0),为了安全起见,直接判定为冲突(信任标题标签)。 * @param {string} titleA * @param {string} titleB * @param {string} typeDescA * @param {string} typeDescB * @param {number} countA 集数A * @param {number} countB 集数B - * @returns {boolean} true 表示冲突(禁止合并),false 表示无冲突 + * @param {string} sourceA 来源A + * @param {string} sourceB 来源B + * @returns {boolean} 是否冲突 (true: 冲突, false: 无冲突) */ -function checkMediaTypeMismatch(titleA, titleB, typeDescA, typeDescB, countA, countB) { +function checkMediaTypeMismatch(titleA, titleB, typeDescA, typeDescB, countA, countB, sourceA = '', sourceB = '') { + // 优先检测 真人剧 vs 动漫 + const catA = getContentCategory(titleA, typeDescA, sourceA); + const catB = getContentCategory(titleB, typeDescB, sourceB); + + if ((catA === 'REAL' && catB === 'ANIME') || (catA === 'ANIME' && catB === 'REAL')) { + return true; + } + const mediaA = getStrictMediaType(titleA, typeDescA); const mediaB = getStrictMediaType(titleB, typeDescB); - // 1. 如果没有检测到明确的互斥类型,放行 if (!mediaA || !mediaB || mediaA === mediaB) return false; - // 2. 检查集数数据的有效性 + if (checkTheatricalExemption(titleA, titleB, typeDescA, typeDescB)) { + return false; + } + const hasValidCounts = countA > 0 && countB > 0; if (hasValidCounts) { - // 如果双方都有集数,计算差异 - // 电影通常 1-2 集,电视剧通常 > 5 集,差异阈值设为 5 是合理的 const diff = Math.abs(countA - countB); if (diff > 5) { - return true; // 冲突 + return true; } return false; } - // 3. 数据缺失防御 - // 如果类型互斥(Movie vs TV),且不知道具体集数(count=0), - // 绝对不能因为 (0-0=0) 就放行。必须信任标题中的显式标签,判定为冲突。 return true; } /** * 校验季度/续作标记是否冲突 + * 包含针对 AMBIGUOUS 歧义标记的宽容处理逻辑 * @param {string} titleA 标题A * @param {string} titleB 标题B * @param {string} typeA 类型A * @param {string} typeB 类型B - * @returns {boolean} true 表示冲突(禁止合并),false 表示无冲突 + * @returns {boolean} 是否冲突 */ function checkSeasonMismatch(titleA, titleB, typeA, typeB) { const markersA = extractSeasonMarkers(titleA, typeA); const markersB = extractSeasonMarkers(titleB, typeB); - // 两者都无标记 -> 无冲突 if (markersA.size === 0 && markersB.size === 0) return false; - // 1. 如果两者都有标记,必须有交集,不能互斥 + const hasS2OrMore = (set) => Array.from(set).some(m => m.startsWith('S') && parseInt(m.substring(1)) >= 2); + const hasSequel = (set) => set.has('SEQUEL'); + const hasAmbiguous = (set) => set.has('AMBIGUOUS'); + if (markersA.size > 0 && markersB.size > 0) { + // 歧义后缀豁免:如果任意一方包含 AMBIGUOUS,且另一方包含任意季数标记,则不再判定为硬性冲突 + if ((hasAmbiguous(markersA) && (hasS2OrMore(markersB) || markersB.has('S1') || hasSequel(markersB))) || + (hasAmbiguous(markersB) && (hasS2OrMore(markersA) || markersA.has('S1') || hasSequel(markersA)))) { + return false; + } + + // 兼容 "S2+" 与 "SEQUEL" + if ((hasS2OrMore(markersA) && hasSequel(markersB)) || (hasS2OrMore(markersB) && hasSequel(markersA))) { + return false; + } + + // 标准冲突检测 for (const m of markersA) { - // 如果 A 的标记在 B 中不存在,且 B 也有同类标记(如都是S开头的季数),则视为冲突 - if (m.startsWith('S') && !markersB.has(m) && Array.from(markersB).some(b => b.startsWith('S'))) return true; + if (m.startsWith('S')) { + const hasSameS = markersB.has(m); + const bHasAnyS = Array.from(markersB).some(b => b.startsWith('S')); + if (!hasSameS && bHasAnyS) { + return true; + } + } } return false; } - // 2. 一方有标记,一方无标记 -> 冲突 if (markersA.size !== markersB.size) { + if (checkTheatricalExemption(titleA, titleB, typeA, typeB)) { + return false; + } return true; } @@ -345,12 +800,12 @@ function checkSeasonMismatch(titleA, titleB, typeA, typeB) { /** * 检查两个标题是否包含相同的季度/季数标记 - * 用于在年份不匹配时进行“豁免”判断 - * @param {string} titleA 标题A - * @param {string} titleB 标题B - * @param {string} typeA 类型A - * @param {string} typeB 类型B - * @returns {boolean} 是否包含相同的明确季度标记(如都包含 S1) + * 用于豁免逻辑 + * @param {string} titleA + * @param {string} titleB + * @param {string} typeA + * @param {string} typeB + * @returns {boolean} */ function hasSameSeasonMarker(titleA, titleB, typeA, typeB) { const markersA = extractSeasonMarkers(titleA, typeA); @@ -367,150 +822,503 @@ function hasSameSeasonMarker(titleA, titleB, typeA, typeB) { /** * 校验日期匹配度 - * @param {Object} dateA 日期对象A - * @param {Object} dateB 日期对象B - * @returns {number} 匹配得分 (-1 表示硬性不匹配) + * 包含针对配音版(Dub)的年份豁免逻辑 + * @param {Object} dateA 日期A + * @param {Object} dateB 日期B + * @param {boolean} [isDub=false] 是否为配音版本(配音版允许较大的年份差异) + * @returns {number} 匹配分数 (-1 表示硬性不匹配, 0 表示中性, >0 表示加分) */ -function checkDateMatch(dateA, dateB) { - if (!dateA.year || !dateB.year) return 0; - const yearDiff = Math.abs(dateA.year - dateB.year); +function checkDateMatch(dateA, dateB, isDub = false) { + if (!dateA.year || !dateB.year) return 0.05; + const yearDiff = dateA.year - dateB.year; // 有符号差值 - // 年份相差 > 1,硬性抛弃 - if (yearDiff > 1) return -1; - - // 年份相同 + // 如果年份完全相同 if (yearDiff === 0) { if (dateA.month && dateB.month) { const monthDiff = Math.abs(dateA.month - dateB.month); - // 月份差异大也不扣分 (可能是占位符 01-01) if (monthDiff > 2) return 0; return monthDiff === 0 ? 0.2 : 0.1; } return 0.1; } + + const absDiff = Math.abs(yearDiff); + + // 配音版豁免逻辑 + // 如果是配音版,允许最大 10 年的误差,不扣分 + if (isDub) { + if (absDiff <= 10) { + return 0; + } + } + + // 常规逻辑:超过1年即视为严重不匹配 + if (absDiff > 1) return -1; + return 0; } /** - * 在副源列表中寻找最佳匹配的动画对象 - * 采用“双重对比策略”:同时计算“完整标题相似度”和“去括号主标题相似度”,取最大值。 - * 并结合类型信息进行更精准的冲突检测(如剧场版vsTV版)。 + * 验证合并覆盖率是否合规 + * 防止剧场版误匹配TV版等低覆盖率情况 + * @param {number} mergedCount 成功匹配集数 + * @param {number} totalA 主源总集数 + * @param {number} totalB 副源总集数 + * @param {string} sourceA 主源名称 + * @param {string} sourceB 副源名称 + * @returns {boolean} 是否合规 + */ +function isMergeRatioValid(mergedCount, totalA, totalB, sourceA, sourceB) { + if (sourceA === 'animeko' || sourceB === 'animeko') { + return true; // 豁免 animeko (可能包含未放送集) + } + + const maxTotal = Math.max(totalA, totalB); + if (maxTotal === 0) return false; + + const ratio = mergedCount / maxTotal; + + if (maxTotal > 5 && ratio < 0.18) { + return false; + } + + return true; +} + +/** + * 在当前副源列表中,检测是否存在基于上下文的续作关系 + * 如果列表中同时存在 "Title" 和 "Title S",则 "Title S" 被认定为 Ambiguous Sequel + * @param {Array} secondaryList 副源动画列表 + * @returns {Map} 返回 { animeId -> baseTitle } 的 Map,命中ID即为续作 + */ +function detectPeerContextSequels(secondaryList) { + const contextMap = new Map(); + if (!secondaryList || secondaryList.length < 2) return contextMap; + + // 预处理所有标题 + const items = secondaryList.map(item => { + const raw = item.animeTitle || ''; + const clean = cleanText(raw).replace(RE_SOURCE_TAG, '').replace(RE_FROM_SUFFIX, '').trim(); + return { id: item.animeId, raw, clean }; + }); + + // 建立基准标题集合 + const baseTitles = new Set(items.map(i => i.clean)); + + for (const item of items) { + let baseCandidate = null; + + // 1. 优先检测特定后缀 (如 A's, StrikerS) + for (const mapItem of RE_SUFFIX_SPECIFIC_MAP) { + const m = item.clean.match(mapItem.regex); + if (m) { + baseCandidate = item.clean.replace(mapItem.regex, '').trim(); + break; + } + } + + // 2. 如果没命中特定后缀,检测歧义后缀 (S, T, R, II...) + if (!baseCandidate) { + const m = item.clean.match(RE_SUFFIX_AMBIGUOUS); + if (m) { + const suffix = m[1]; + if (item.clean.endsWith(suffix)) { + baseCandidate = item.clean.substring(0, item.clean.length - suffix.length).trim(); + } + } + } + + if (baseCandidate) { + // 只有当剥离后缀后的 Base Title 也存在于同列表中时,才判定为续作 + if (baseCandidate.length > 1 && baseTitles.has(baseCandidate)) { + contextMap.set(String(item.id), baseCandidate); + log("info", `[Merge-Check] 上下文感知: 判定 [${item.raw}] 为续作 (Base: "${baseCandidate}" 同时也存在于列表)`); + } + } + } + + return contextMap; +} + +/** + * 探测集内容匹配情况 (深度检测) + * 通过抽样对比集标题,判断是否强匹配或强不匹配 + * @param {Object} primaryAnime 主源动画 + * @param {Object} candidateAnime 候选动画 + * @returns {Object} { isStrongMatch: boolean, isStrongMismatch: boolean } + */ +function probeContentMatch(primaryAnime, candidateAnime) { + const result = { isStrongMatch: false, isStrongMismatch: false }; + + if (!primaryAnime.links || !candidateAnime.links) return result; + if (primaryAnime.links.length === 0 || candidateAnime.links.length === 0) return result; + + // 1. 集数量探测 (过滤掉番外) + const countEpisodes = (links) => { + return links.filter(l => { + const t = (l.title || l.name || '').toLowerCase(); + return !RE_PV_CHECK.test(t) && !RE_SPECIAL_CHECK.test(t); + }).length; + }; + + const countP = countEpisodes(primaryAnime.links); + const countS = countEpisodes(candidateAnime.links); + + // 如果两边都有较多集数,且数量差异巨大(如 12 vs 24),视为负面参考 + if (countP > 5 && countS > 5) { + const ratio = Math.min(countP, countS) / Math.max(countP, countS); + if (ratio < 0.4) { + // 不直接判定 Mismatch,需结合集标题进一步确认 + } + } + + // 2. 集标题探测 (必须语言一致) + const getEpTitles = (links) => links.map(l => { + const t = cleanEpisodeText(l.title || l.name || ''); + return t.replace(/\d+/g, '').trim(); // 移除数字,只比对文本 + }).filter(t => t.length > 1); + + const titlesP = getEpTitles(primaryAnime.links); + const titlesS = getEpTitles(candidateAnime.links); + + if (titlesP.length < 3 || titlesS.length < 3) return result; + + const langP = getLanguageType(titlesP.join(' ')); + const langS = getLanguageType(titlesS.join(' ')); + + if (langP !== langS || langP === 'Unspecified') return result; + + // 抽样对比 (最多5个) + const sampleSize = Math.min(titlesP.length, titlesS.length, 5); + let matchHits = 0; + let mismatchHits = 0; + let logSamples = []; + + for (let i = 0; i < sampleSize; i++) { + // 简单按索引对齐采样 + const idxP = Math.floor(i * titlesP.length / sampleSize); + const idxS = Math.floor(i * titlesS.length / sampleSize); + + const tp = titlesP[idxP]; + const ts = titlesS[idxS]; + + const sim = calculateSimilarity(tp, ts); + + if (i < 3) { + logSamples.push(`"${tp}" vs "${ts}" (${sim.toFixed(2)})`); + } + + if (sim > 0.6) matchHits++; + else if (sim < 0.3) mismatchHits++; + } + + if (matchHits >= Math.ceil(sampleSize * 0.6)) { + result.isStrongMatch = true; + log("info", `[Merge-Check] [Probe] 采样对比 (Match): ${logSamples.join(', ')}`); + } else if (mismatchHits >= Math.ceil(sampleSize * 0.8)) { + result.isStrongMismatch = true; + log("info", `[Merge-Check] [Probe] 采样对比 (Mismatch): ${logSamples.join(', ')}`); + } + + return result; +} + +/** + * 在副源列表中寻找最佳匹配的动画对象列表 + * 包含上下文感知、集内容探测、中配优先等复杂逻辑 * @param {Object} primaryAnime 主源动画对象 * @param {Array} secondaryList 副源动画列表 - * @returns {Object|null} 匹配的动画对象或 null + * @returns {Array} 匹配的动画对象列表(已按分数排序) */ -export function findSecondaryMatch(primaryAnime, secondaryList) { - if (!secondaryList || secondaryList.length === 0) return null; +export function findSecondaryMatches(primaryAnime, secondaryList) { + if (!secondaryList || secondaryList.length === 0) return []; - // 原始标题 (rawPrimaryTitle): 包含【电视剧】等所有信息,专供冲突检测使用 const rawPrimaryTitle = primaryAnime.animeTitle || ''; - // 计算标题 (primaryTitleForSim): 剔除年份和类型标签,专供相似度计算使用 - // 保证 calculateSimilarity 接收到的是纯净的名称 - let primaryTitleForSim = rawPrimaryTitle.replace(/\(\d{4}\).*$/, ''); + // 预处理主标题 + let primaryTitleForSim = rawPrimaryTitle.replace(RE_YEAR_TAG, ''); primaryTitleForSim = primaryTitleForSim.replace(/【(电影|电视剧)】/g, '').trim(); - const primaryDate = parseDate(primaryAnime.startDate); - // 优先使用 episodeCount 属性(即使 links 尚未加载) - const primaryCount = primaryAnime.episodeCount || (primaryAnime.links ? primaryAnime.links.length : 0); + // 检测中配 (RE_CN_DUB_VER 是 global,使用 match 避免 lastIndex 问题) + const isPrimaryDub = !!(primaryTitleForSim.match(RE_CN_DUB_VER)) || RE_LANG_CN.test(primaryTitleForSim); - let bestMatch = null; + const primaryDate = rawPrimaryTitle.includes('N/A') ? { year: null, month: null } : parseDate(primaryAnime.startDate); + const primaryCount = primaryAnime.episodeCount || (primaryAnime.links ? primaryAnime.links.length : 0); + const primaryLang = getLanguageType(rawPrimaryTitle); + + let validCandidates = []; let maxScore = 0; + const logReason = (secTitle, reason) => { + log("info", `[Merge-Check] 拒绝: [${primaryAnime.source}] ${rawPrimaryTitle} vs [${secTitle}] -> ${reason}`); + }; + + const primaryCleanForZhi = cleanText(primaryTitleForSim); + const cleanPrimarySim = cleanTitleForSimilarity(primaryTitleForSim); + const baseA = removeParentheses(primaryTitleForSim); + + // 上下文感知:预先标记副源列表中的Ambiguous Sequels + const ambiguousSequelsMap = detectPeerContextSequels(secondaryList); + for (const secAnime of secondaryList) { const rawSecTitle = secAnime.animeTitle || ''; - const secDate = parseDate(secAnime.startDate); + const secDate = rawSecTitle.includes('N/A') ? { year: null, month: null } : parseDate(secAnime.startDate); - // 同样对副源进行逻辑分离 - let secTitleForSim = rawSecTitle.replace(/\(\d{4}\).*$/, ''); + const secLang = getLanguageType(rawSecTitle); + let secTitleForSim = rawSecTitle.replace(RE_YEAR_TAG, ''); secTitleForSim = secTitleForSim.replace(/【(电影|电视剧)】/g, '').trim(); const secCount = secAnime.episodeCount || (secAnime.links ? secAnime.links.length : 0); - // 1. 严格冲突检测 (使用 rawTitle) - // 只要标题一个是电影一个是电视剧,且没有集数证明它们一样,就直接跳过 - if (checkMediaTypeMismatch(rawPrimaryTitle, rawSecTitle, primaryAnime.typeDescription, secAnime.typeDescription, primaryCount, secCount)) { + // 之字结构强阻断:主标题是副标题的前缀父集 + if (secTitleForSim.includes('之')) { + const parts = secTitleForSim.split('之'); + const prefix = cleanText(parts[0]); + if (primaryCleanForZhi === prefix) { + logReason(rawSecTitle, `结构冲突: 主标题是副标题的前缀父集 (Prefix: "${prefix}")`); + continue; + } + } + + // 严格媒体类型冲突 + if (checkMediaTypeMismatch(rawPrimaryTitle, rawSecTitle, primaryAnime.typeDescription, secAnime.typeDescription, primaryCount, secCount, primaryAnime.source, secAnime.source)) { + const pType = getContentCategory(rawPrimaryTitle, primaryAnime.typeDescription, primaryAnime.source); + const sType = getContentCategory(rawSecTitle, secAnime.typeDescription, secAnime.source); + logReason(rawSecTitle, `媒体类型不匹配 (P:${pType}/${getStrictMediaType(rawPrimaryTitle, primaryAnime.typeDescription)} vs S:${sType}/${getStrictMediaType(rawSecTitle, secAnime.typeDescription)})`); + continue; + } + + const isDateValid = (primaryDate.year !== null && secDate.year !== null); + const hasStructureConflict = checkTitleSubtitleConflict(rawPrimaryTitle, rawSecTitle, isDateValid); + + // 上下文 Sequel 阻断逻辑 + const isAmbiguousSequel = ambiguousSequelsMap.has(String(secAnime.animeId)); + if (isAmbiguousSequel) { + // 核心逻辑:副源是续作,而主源清洗后等于副源的 Base Title,则主源为前作,阻断 + const baseTitleOfSec = ambiguousSequelsMap.get(String(secAnime.animeId)); + + if (cleanPrimarySim === cleanTitleForSimilarity(baseTitleOfSec)) { + // 再次确认:除非主源自己也有类似的后缀 + const primaryHasSuffix = RE_SUFFIX_AMBIGUOUS.test(primaryCleanForZhi) || RE_SUFFIX_SPECIFIC_MAP.some(x => x.regex.test(primaryCleanForZhi)); + + if (!primaryHasSuffix) { + logReason(rawSecTitle, `上下文阻断: 主源(S1) vs 副源(S2/S续作) (Base: "${baseTitleOfSec}")`); + continue; + } + } + } + + if (!isDateValid && hasStructureConflict) { + logReason(rawSecTitle, `标题结构冲突且日期无效 (HasConflict=true, DateValid=false)`); continue; } - // 2. 豁免检测 (使用 clean 后的 simTitle) const isSeasonExactMatch = hasSameSeasonMarker(primaryTitleForSim, secTitleForSim, primaryAnime.typeDescription, secAnime.typeDescription); - // 3. 日期校验 - const dateScore = checkDateMatch(primaryDate, secDate); - if (!isSeasonExactMatch && dateScore === -1) { - continue; + // 提前计算集内容匹配度 (Probe Content Match) + const contentProbe = probeContentMatch(primaryAnime, secAnime); + + // 日期检查 (传入 isDub 参数) + const dateScore = checkDateMatch(primaryDate, secDate, isPrimaryDub); + + // 日期严重不匹配处理 + if (dateScore === -1) { + let allowExemption = isSeasonExactMatch; + // 如果内容探测强匹配,强制豁免日期错误 + if (contentProbe.isStrongMatch) allowExemption = true; + + if (hasStructureConflict) { + allowExemption = false; // 结构冲突下取消日期豁免 + } + + if (allowExemption && primaryDate.year && secDate.year) { + const yearDiff = Math.abs(primaryDate.year - secDate.year); + // 仅当Probe强匹配时,允许 >2 年的差异 + if (yearDiff > 2 && !contentProbe.isStrongMatch) { + allowExemption = false; + } + } + + if (!allowExemption) { + logReason(rawSecTitle, `日期严重不匹配且无豁免 (P:${primaryDate.year} vs S:${secDate.year}, IsDub:${isPrimaryDub}, StrongProbe:${contentProbe.isStrongMatch})`); + continue; + } } - // 4. 季度冲突检测 (使用 clean 后的 simTitle) + // 季度冲突检查 (支持探测越狱) if (checkSeasonMismatch(primaryTitleForSim, secTitleForSim, primaryAnime.typeDescription, secAnime.typeDescription)) { - continue; + if (contentProbe.isStrongMatch) { + log("info", `[Merge-Check] 季度冲突豁免: [${rawPrimaryTitle}] vs [${rawSecTitle}] (检测到集内容强匹配,无视标题季度差异)`); + } else { + logReason(rawSecTitle, `季度标记冲突`); + continue; + } } - // 5. 核心相似度计算 (使用 clean 后的 simTitle) + // 核心相似度计算 let scoreFull = calculateSimilarity(primaryTitleForSim, secTitleForSim); - // 去除括号再次对比 - const baseA = removeParentheses(primaryTitleForSim); const baseB = removeParentheses(secTitleForSim); let scoreBase = calculateSimilarity(baseA, baseB); - // 取两者的最大值作为最终得分 let score = Math.max(scoreFull, scoreBase); + const originalScore = score; + if (hasStructureConflict) { + score -= 0.15; + } + if (dateScore !== -1) { score += dateScore; } - if (score > maxScore) { - maxScore = score; - bestMatch = secAnime; + // 语言优先逻辑 + const isPrimaryCn = (primaryLang === 'CN'); + const isSecCn = (secLang === 'CN'); + + if (isPrimaryCn && isSecCn) { + score += 0.15; + } else if (isPrimaryCn !== isSecCn) { + score -= 0.20; + } + + // 应用 Probe 结果调整分数 + if (contentProbe.isStrongMatch) { + log("info", `[Merge-Check] 集内容探测: 强匹配! 提升分数 (原分: ${score.toFixed(2)}) -> 0.98`); + score = Math.max(score, 0.98); + } else if (contentProbe.isStrongMismatch) { + logReason(rawSecTitle, `集内容探测: 强不匹配 (集标题/内容差异巨大)`); + score = 0; // Force Kill + } + + if (score < 0.6) { + const cleanA = cleanPrimarySim; + const cleanB = cleanTitleForSimilarity(secTitleForSim); + logReason(rawSecTitle, `相似度不足: ${score.toFixed(2)} (Raw:${originalScore.toFixed(2)}, CleanA:"${cleanA}", CleanB:"${cleanB}")`); + } else { + if (score > maxScore) maxScore = score; + + validCandidates.push({ + anime: secAnime, + score: score, + lang: secLang, + debugTitle: rawSecTitle + }); + + log("info", `[Merge-Check] 候选选中: ${rawSecTitle} Score=${score.toFixed(2)} (BestSoFar=${maxScore.toFixed(2)})`); } } - return maxScore >= 0.6 ? bestMatch : null; + if (validCandidates.length === 0 || maxScore < 0.6) return []; + + // ========================================== + // 高分梯队筛选 (Score Tier Filtering) + // 解决 Winner-Takes-All,允许中配版或Part分部并存 + // ========================================== + + const TIER_TOLERANCE_DEFAULT = 0.001; + const TIER_TOLERANCE_CN = 0.40; + const TIER_TOLERANCE_PART = 0.50; + + const markersP = extractSeasonMarkers(rawPrimaryTitle, primaryAnime.typeDescription); + const seasonsP = Array.from(markersP).filter(m => m.startsWith('S')); + + const finalResults = validCandidates.filter(candidate => { + // 1. 最高分入选 + const isTopScore = candidate.score >= (maxScore - TIER_TOLERANCE_DEFAULT); + if (isTopScore) return true; + + // 2. 高分中配入选 + const isHighTierCn = (candidate.lang === 'CN') && (candidate.score >= (maxScore - TIER_TOLERANCE_CN)); + if (isHighTierCn) return true; + + // 3. Part 分部入选 + const markersC = extractSeasonMarkers(candidate.debugTitle, candidate.anime.typeDescription); + const hasPart = Array.from(markersC).some(m => m.startsWith('P')); + + if (hasPart && (candidate.score >= (maxScore - TIER_TOLERANCE_PART))) { + const seasonsC = Array.from(markersC).filter(m => m.startsWith('S')); + if (seasonsP.length > 0 && seasonsC.length > 0) { + const hasIntersection = seasonsP.some(sp => seasonsC.includes(sp)); + if (hasIntersection) return true; + } else { + return true; + } + } + + return false; + }); + + finalResults.sort((a, b) => b.score - a.score); + + return finalResults.map(item => item.anime); } /** * 提取集数信息 - * 增强正则以支持紧凑格式,并预先进行去噪清洗 - * 同时判断该集是否属于特殊集 (Special/OVA/Season标识等) - * @param {string} title 分集标题 - * @returns {Object} { isMovie: boolean, num: number|null, isSpecial: boolean } + * 包含严格番外检测和来源标签识别 + * @param {string} title 集标题 + * @param {string} sourceName 来源名称 + * @returns {Object} 集信息对象 { isMovie, num, isSpecial, isPV, season, isStrictSpecial } */ -function extractEpisodeInfo(title) { - // 使用 cleanText 移除干扰前缀和地区文字 - const t = cleanText(title || ""); +function extractEpisodeInfo(title, sourceName = '') { + let isStrictSpecial = false; - // 1. 判断是否是剧场版 - 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); - - // 3. 提取数字 - - // 策略 A: 强前缀 (EP, O, S, Part, 第) - const strongPrefixMatch = t.match(/(?:ep|o|s|part|第)\s*(\d+(\.\d+)?)/i); - if (strongPrefixMatch) { - num = parseFloat(strongPrefixMatch[1]); - } else { - // 策略 B: 弱前缀 (行首或空格) - // 必须有后缀分隔符 (话/集/空格/行尾) 或者数字是独立的 - const weakPrefixMatch = t.match(/(?:^|\s)(\d+(\.\d+)?)(?:话|集|\s|$)/); - if (weakPrefixMatch) { - num = parseFloat(weakPrefixMatch[1]); - } + // 优先从标题标签中识别真实来源 + let effectiveSource = sourceName; + if (title) { + const tagMatch = title.match(RE_DANDAN_TAG); + if (tagMatch) { + effectiveSource = tagMatch[1].toLowerCase(); + } } - return { isMovie, num, isSpecial }; + const isDandanOrAnimeko = /^(dandan|animeko)$/i.test(effectiveSource); + + if (isDandanOrAnimeko && title) { + // 严格检测:必须移除标签后才进行 S1 检测 + let rawTemp = title.replace(RE_SOURCE_TAG, '').replace(RE_FROM_SUFFIX, '').trim(); + + if (RE_SPECIAL_START.test(rawTemp)) { + isStrictSpecial = true; + } + } + + const t = cleanText(title || ""); + + const isMovie = RE_MOVIE_CHECK.test(t); + const isPV = RE_PV_CHECK.test(t); + + let num = null; + let season = null; + + const isSpecial = isPV || isStrictSpecial || RE_SPECIAL_CHECK.test(t); + + const seasonMatch = t.match(RE_EP_SEASON_MATCH); + if (seasonMatch) { + season = parseInt(seasonMatch[1]); + } + + const seasonEpMatch = t.match(RE_EP_NUM_STRATEGY_A); + if (seasonEpMatch) { + num = parseFloat(seasonEpMatch[2]); + } else { + const strongPrefixMatch = t.match(RE_EP_NUM_STRATEGY_B); + if (strongPrefixMatch) { + num = parseFloat(strongPrefixMatch[1]); + } else { + const weakPrefixMatch = t.match(RE_EP_NUM_STRATEGY_C); + if (weakPrefixMatch) { + num = parseFloat(weakPrefixMatch[1]); + } + } + } + + return { isMovie, num, isSpecial, isPV, season, isStrictSpecial }; } /** - * 判断集标题是否属于特定的特殊类型(Opening/Ending/Interview/Bloopers) - * 用于实现特殊集的独立匹配逻辑 + * 判断集标题是否属于特定的特殊类型 * @param {string} title 集标题 * @returns {string|null} 特殊类型标识 ('opening' | 'ending' | 'interview' | 'Bloopers' | null) */ @@ -521,22 +1329,19 @@ function getSpecialEpisodeType(title) { if (t.includes('opening')) return 'opening'; if (t.includes('ending')) return 'ending'; if (t.includes('interview')) return 'interview'; - if (t.includes('Bloopers')) return 'Bloopers'; + if (t.includes('bloopers')) return 'Bloopers'; return null; } /** - * 过滤无效剧集 (预告/花絮等) - * 完全依赖传入的正则,不再内置硬编码规则 - * @param {Array} links 剧集链接列表 + * 过滤无效剧集 + * @param {Array} links 链接列表 * @param {RegExp} filterRegex 过滤正则 - * @returns {Array} 包含原始索引的有效剧集列表 + * @returns {Array} 过滤后的列表 (保留 originalIndex) */ function filterEpisodes(links, filterRegex) { if (!links) return []; - - // 如果没有传入过滤正则(例如全局开关关闭),则不执行过滤,仅保留索引映射 if (!filterRegex) { return links.map((link, index) => ({ link, originalIndex: index })); } @@ -550,46 +1355,186 @@ function filterEpisodes(links, filterRegex) { } /** - * 寻找最佳对齐偏移量 - * 解决策略:滑动窗口 + 匹配集数权重 + 类型惩罚 + 数字一致性 - * 引入覆盖率权重,防止少量的巧合匹配战胜大量的正确匹配 - * @param {Array} primaryLinks 主源链接列表 - * @param {Array} secondaryLinks 副源链接列表 - * @returns {number} 最佳偏移量 + * 辅助函数:计算两个字符串的最长公共子串 + * @param {string} str1 字符串1 + * @param {string} str2 字符串2 + * @returns {string} 最长公共子串 */ -function findBestAlignmentOffset(primaryLinks, secondaryLinks) { +function getLongestCommonSubstring(str1, str2) { + if (!str1 || !str2) return ''; + let maxSub = ''; + const len1 = str1.length; + // 双重循环,无需过度优化,对于标题长度尚可 + for (let i = 0; i < len1; i++) { + for (let j = i + 1; j <= len1; j++) { + const sub = str1.substring(i, j); + if (str2.includes(sub)) { + if (sub.length > maxSub.length) maxSub = sub; + } + } + } + return maxSub; +} + +/** + * 检测并识别冗余的系列标题字段 + * 包含主标题截断和位置锚定逻辑,防止误删 + * @param {Array} links 集链接列表 + * @param {string} seriesTitle 系列标题 + * @param {string} sourceName 来源名称 + * @returns {string} 冗余字段字符串 + */ +function identifyRedundantTitle(links, seriesTitle, sourceName) { + if (!links || links.length < 2 || !seriesTitle) return ''; + + const cleanSource = (text) => { + if (!text || !sourceName) return text || ''; + try { + const escapedSource = sourceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(\\[|【|\\s)?${escapedSource}(\\]|】|\\s)?`, 'gi'); + return text.replace(regex, '').trim(); + } catch (e) { + return text; + } + }; + + let cleanSeriesTitle = cleanSource(seriesTitle); + + const separatorMatch = cleanSeriesTitle.match(RE_REDUNDANT_SEPARATOR); + if (separatorMatch) { + cleanSeriesTitle = cleanSeriesTitle.substring(0, separatorMatch.index); + } + + const titles = links.map(item => { + const realLink = item.link || item; + if (!realLink) return ''; + return cleanSource(realLink.title || realLink.name || ''); + }); + + if (titles.some(t => !t)) return ''; + + let common = getLongestCommonSubstring(titles[0], titles[1]); + if (common.length < 2) return ''; + + for (let i = 2; i < titles.length; i++) { + common = getLongestCommonSubstring(common, titles[i]); + if (common.length < 2) return ''; + } + + const validatedRedundant = getLongestCommonSubstring(common, cleanSeriesTitle); + + // 锚定检测 + if (!cleanSeriesTitle.startsWith(validatedRedundant)) { + return ''; + } + + if (RE_REDUNDANT_UNSAFE_END.test(validatedRedundant)) { + const trimmed = validatedRedundant.slice(0, -1).trim(); + if (trimmed.length >= 2 && cleanSeriesTitle.startsWith(trimmed)) { + return trimmed; + } + return ''; + } + + if (validatedRedundant.length >= 2) { + if (RE_REDUNDANT_VALID_CHARS.test(validatedRedundant) || validatedRedundant.length > 3) { + log("info", `[Merge-Check] 检测到集内冗余标题字段: "${validatedRedundant}" (已忽略来源: ${sourceName}, 锚定验证通过)`); + return validatedRedundant; + } + } + + return ''; +} + +/** + * 寻找最佳对齐偏移量 + * 包含超级锚点逻辑、双模式CN匹配等 + * @param {Array} primaryLinks 主源链接 + * @param {Array} secondaryLinks 副源链接 + * @param {string} seriesLangA 主源语言 + * @param {string} seriesLangB 副源语言 + * @param {string} sourceA 主源名称 + * @param {string} sourceB 副源名称 + * @param {string} primarySeriesTitle 主源标题 + * @param {string} secondarySeriesTitle 副源标题 + * @returns {number} 最佳偏移量 (Offset) + */ +function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Unspecified', seriesLangB = 'Unspecified', sourceA = '', sourceB = '', primarySeriesTitle = '', secondarySeriesTitle = '') { if (primaryLinks.length === 0 || secondaryLinks.length === 0) return 0; + // 1. 预计算冗余标题 + const redundantA = identifyRedundantTitle(primaryLinks, primarySeriesTitle, sourceA); + const redundantB = identifyRedundantTitle(secondaryLinks, secondarySeriesTitle, sourceB); + + const getTempTitle = (rawTitle, redundantStr) => { + if (!rawTitle) return ""; + if (redundantStr && rawTitle.includes(redundantStr)) { + return rawTitle.replace(redundantStr, ''); + } + return rawTitle; + }; + + const pInfos = primaryLinks.map(item => { + const rawTitle = item.link.title || ""; + const cleanTitle = getTempTitle(rawTitle, redundantA); + const info = extractEpisodeInfo(cleanTitle, sourceA); + const epLang = getLanguageType(cleanTitle); + const effLang = epLang !== 'Unspecified' ? epLang : seriesLangA; + const specialType = getSpecialEpisodeType(cleanTitle); + // Pre-calc Clean Text for similarity + const cleanEpText = cleanEpisodeText(cleanTitle); + const strictCnCore = (effLang === 'CN') ? cleanTitle.replace(RE_CN_STRICT_CORE_REMOVE, "") : null; + + return { info, effLang, specialType, cleanEpText, strictCnCore }; + }); + + const sInfos = secondaryLinks.map(item => { + const rawTitle = item.link.title || ""; + const cleanTitle = getTempTitle(rawTitle, redundantB); + const info = extractEpisodeInfo(cleanTitle, sourceB); + const epLang = getLanguageType(cleanTitle); + let effLang = epLang !== 'Unspecified' ? epLang : seriesLangB; + // 针对 dandan/animeko 的默认语言推断 + if (effLang === 'Unspecified' && /^(dandan|animeko)$/i.test(sourceB)) { + effLang = 'JP'; + } + const specialType = getSpecialEpisodeType(cleanTitle); + const cleanEpText = cleanEpisodeText(cleanTitle); + const strictCnCore = (effLang === 'CN') ? cleanTitle.replace(RE_CN_STRICT_CORE_REMOVE, "") : null; + + return { info, effLang, specialType, cleanEpText, strictCnCore }; + }); + let bestOffset = 0; - let maxScore = -999; + let maxScore = -9999; - // 计算主源和副源的正片起始集数(忽略特殊集) - // 用于计算相对集数偏移量,解决不同命名规范的对齐问题 let minNormalA = null; let minNormalB = null; - for (const item of primaryLinks) { - const info = extractEpisodeInfo(item.link.title); + // 使用预计算的数据寻找最小集数 + for (const { info } of pInfos) { 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); + for (const { info } of sInfos) { 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); + const baseRange = 15; + const targetShift = (seasonShift !== null) ? -seasonShift : 0; + const minSearch = Math.min(-baseRange, targetShift - baseRange); + const maxSearch = Math.max(baseRange, targetShift + baseRange); + const safeMin = Math.max(minSearch, -Math.max(primaryLinks.length, secondaryLinks.length)); + const safeMax = Math.min(maxSearch, Math.max(primaryLinks.length, secondaryLinks.length)); - for (let offset = -maxShift; offset <= maxShift; offset++) { + for (let offset = safeMin; offset <= safeMax; offset++) { let totalTextScore = 0; - let rawTextScoreSum = 0; // 记录原始文本相似度总和,用于一致性验证 + let rawTextScoreSum = 0; let matchCount = 0; let numericDiffs = new Map(); @@ -597,59 +1542,89 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) { const pIndex = i + offset; if (pIndex >= 0 && pIndex < primaryLinks.length) { - const titleA = primaryLinks[pIndex].link.title || ""; - const titleB = secondaryLinks[i].link.title || ""; - const infoA = extractEpisodeInfo(titleA); - const infoB = extractEpisodeInfo(titleB); + // 直接访问预计算数据 + const dataA = pInfos[pIndex]; + const dataB = sInfos[i]; + + const infoA = dataA.info; + const infoB = dataB.info; - // 1. 类型惩罚 (关键:阻止剧场版与正片匹配) + // 1. 类型惩罚 let pairScore = 0; if (infoA.isMovie !== infoB.isMovie) { - pairScore -= 5.0; // 强惩罚 + pairScore -= 5.0; } - // 1.1 特殊集类型惩罚/奖励 (Opening/Ending/Interview/Bloopers) - const specialTypeA = getSpecialEpisodeType(titleA); - const specialTypeB = getSpecialEpisodeType(titleB); - if (specialTypeA || specialTypeB) { - if (specialTypeA !== specialTypeB) { + if ((infoA.isStrictSpecial && !infoB.isSpecial) || (infoB.isStrictSpecial && !infoA.isSpecial)) { + pairScore -= 8.0; + } + + // 语言匹配 + const effLangA = dataA.effLang; + const effLangB = dataB.effLang; + + if (effLangA !== 'Unspecified' && effLangB !== 'Unspecified') { + if (effLangA === effLangB) { + pairScore += 3.0; + } else { + pairScore -= 5.0; + } + } + + if (infoA.season !== null && infoB.season !== null && infoA.season !== infoB.season) { + pairScore -= 10.0; + } + + // 特殊集类型 + if (dataA.specialType || dataB.specialType) { + if (dataA.specialType !== dataB.specialType) { 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; // 极强奖励 + pairScore += 5.0; } } - // 2. 文本相似度 - const sim = calculateSimilarity(titleA, titleB); + // 2. 文本相似度 (利用预计算) + let sim = 0; + if (effLangA === 'CN' && effLangB === 'CN') { + const coreA = dataA.strictCnCore; + const coreB = dataB.strictCnCore; + + if (coreA && coreB && (coreA.includes(coreB) || coreB.includes(coreA))) { + if (infoA.num !== null && infoB.num !== null && infoA.num === infoB.num) { + sim = 25.0; + } else { + sim = -5.0; + } + } else { + sim = calculateSimilarity(dataA.cleanEpText, dataB.cleanEpText); + } + } else { + sim = calculateSimilarity(dataA.cleanEpText, dataB.cleanEpText); + } + pairScore += sim; rawTextScoreSum += sim; - // 3. 数字完全匹配加分 - // 如果提取出的数字完全相等 (Diff=0),给予高额加分 if (infoA.num !== null && infoB.num !== null && infoA.num === infoB.num) { pairScore += 2.0; } totalTextScore += pairScore; - // 4. 数字差值记录 if (infoA.num !== null && infoB.num !== null) { const diff = infoB.num - infoA.num; - const diffKey = diff.toFixed(4); // 避免浮点误差 + const diffKey = diff.toFixed(4); const count = numericDiffs.get(diffKey) || 0; numericDiffs.set(diffKey, count + 1); } @@ -659,10 +1634,8 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) { } if (matchCount > 0) { - // 基础平均分 let finalScore = totalTextScore / matchCount; - // 5. 计算数字一致性加分 let maxFrequency = 0; for (const count of numericDiffs.values()) { if (count > maxFrequency) maxFrequency = count; @@ -671,23 +1644,21 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) { const consistencyRatio = maxFrequency / matchCount; const avgRawTextScore = rawTextScoreSum / matchCount; - // 仅当文本相似度达标时才给予一致性奖励,防止数字凑巧对齐但内容不符 if (consistencyRatio > 0.6 && avgRawTextScore > 0.33) { finalScore += 2.0; } - // 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 (zeroDiffCount > 3) { + finalScore += zeroDiffCount * 5.0; + finalScore += 100.0; + } else if (zeroDiffCount > 0) { + finalScore += zeroDiffCount * 2.0; } - // 选择逻辑 if (finalScore > maxScore) { maxScore = finalScore; bestOffset = offset; @@ -695,262 +1666,529 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks) { } } - // 只有当得分 > 0.3 时才采用偏移,否则默认对齐 return maxScore > 0.3 ? bestOffset : 0; } /** * 生成符合 int32 范围的安全 ID - * 通过哈希组合 ID 并映射到 10亿~21亿 区间,避免溢出并减少冲突 - * @param {string|number} id1 原始ID 1 - * @param {string|number} id2 原始ID 2 - * @param {string} salt 盐值(通常为配置组签名,用于区分不同合并组) - * @returns {number} 安全的 Int32 ID + * 通过哈希映射到 10亿~21亿 区间,避免溢出 + * @param {string|number} id1 ID 1 + * @param {string|number} id2 ID 2 + * @param {string} salt 盐值 + * @returns {number} 安全 ID */ 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 + hash |= 0; } - // 取绝对值并映射到 1,000,000,000 (10亿) ~ 2,147,483,647 (int32 max) 之间 return (Math.abs(hash) % 1000000000) + 1000000000; } /** - * 执行源合并逻辑 - * 遍历配置的源配置组,支持一主多从的链式合并 - * @param {Array} curAnimes 当前所有的动画条目列表 + * 创建新的 Link 对象 + * 格式化 URL 并设置显示标题 + * @param {Object} item 包含 link 和 originalIndex 的对象 + * @param {string} sourceName 来源名称 + * @returns {Object} 格式化后的 Link 对象 + */ +function createNewLink(item, sourceName) { + const rawLink = item.link; + const rawTitle = rawLink.title || rawLink.name || `Episode ${item.originalIndex + 1}`; + + let newUrl = rawLink.url || ''; + if (newUrl) { + newUrl = sanitizeUrl(newUrl); + if (!/^https?:\/\//i.test(newUrl)) { + newUrl = `${sourceName}:${newUrl}`; + } + } + + let displayTitle = rawTitle; + if (!displayTitle.includes(`【${sourceName}】`)) { + displayTitle = `【${sourceName}】 ${displayTitle}`; + } + + return { + title: displayTitle, + url: newUrl, + name: rawTitle + }; +} + +/** + * 智能拼接未匹配的集数 + * 处理头部插入、尾部追加和特殊集补充 + * @param {Object} derivedAnime 目标动漫对象 + * @param {Array} orphans 未匹配的集数列表 + * @param {string} sourceName 来源名称 + */ +function stitchUnmatchedEpisodes(derivedAnime, orphans, sourceName) { + if (!orphans || orphans.length === 0) return; + + const headList = []; + const tailList = []; + const specialList = []; + const currentLen = derivedAnime.links.length; + + for (const item of orphans) { + const relativeIdx = item.relativeIndex; + const isStrictSpecial = item.info && item.info.isStrictSpecial; + + if (relativeIdx < 0 && !isStrictSpecial) { + headList.push(item); + } + else if (relativeIdx >= currentLen && !isStrictSpecial) { + tailList.push(item); + } + else { + specialList.push(item); + } + } + + const addedLogs = []; + + if (headList.length > 0) { + headList.sort((a, b) => a.originalIndex - b.originalIndex); + const newLinks = headList.map(it => createNewLink(it, sourceName)); + derivedAnime.links.unshift(...newLinks); + addedLogs.push(` [补全-头部] 插入 ${headList.length} 集 (${headList.map(i => i.link.title).join(', ')})`); + } + + if (tailList.length > 0) { + tailList.sort((a, b) => a.originalIndex - b.originalIndex); + const newLinks = tailList.map(it => createNewLink(it, sourceName)); + derivedAnime.links.push(...newLinks); + addedLogs.push(` [补全-尾部] 追加 ${tailList.length} 集 (${tailList.map(i => i.link.title).join(', ')})`); + } + + if (specialList.length > 0) { + specialList.sort((a, b) => a.originalIndex - b.originalIndex); + const newLinks = specialList.map(it => createNewLink(it, sourceName)); + derivedAnime.links.push(...newLinks); + addedLogs.push(` [补全-特殊] 补充 ${specialList.length} 集 (中间缺失或番外) (${specialList.map(i => i.link.title).join(', ')})`); + } + + if (addedLogs.length > 0) { + log("info", `[Merge] [${sourceName}] 智能补全:\n${addedLogs.join('\n')}`); + } +} + +// ========================================================================= +// 单个主源合并任务处理函数 +// 复用逻辑核心,用于 Phase 1 (CN隔离) 和 Phase 2 (标准合并) +// ========================================================================= +async function processMergeTask(params) { + const { + pAnime, + availableSecondaries, + curAnimes, + groupConsumedIds, + globalConsumedIds, + generatedSignatures, + epFilter, + groupFingerprint, + currentPrimarySource, + logPrefix, + limitSecondaryLang + } = params; + + const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId)); + if (!cachedPAnime?.links) { + log("warn", `${logPrefix} 主源数据不完整,跳过: ${pAnime.animeTitle}`); + return null; + } + + const logTitleA = pAnime.animeTitle.replace(RE_FROM_SUFFIX, ''); + // Deep Clone 仅在必要时执行 + let derivedAnime = JSON.parse(JSON.stringify(cachedPAnime)); + + const actualMergedSources = []; + const contentSignatureParts = [pAnime.animeId]; + let hasMergedAny = false; + + const seriesLangA = getLanguageType(pAnime.animeTitle); + + const redundantP = identifyRedundantTitle(derivedAnime.links, pAnime.animeTitle, currentPrimarySource); + + const getTempTitle = (rawTitle, redundantStr) => { + if (!rawTitle) return ""; + if (redundantStr && rawTitle.includes(redundantStr)) { + return rawTitle.replace(redundantStr, ''); + } + return rawTitle; + }; + + for (const secSource of availableSecondaries) { + let secondaryItems = curAnimes.filter(a => a.source === secSource && !groupConsumedIds.has(a.animeId)); + + if (limitSecondaryLang) { + secondaryItems = secondaryItems.filter(a => getLanguageType(a.animeTitle) === limitSecondaryLang); + } + + if (secondaryItems.length === 0) continue; + + const matches = findSecondaryMatches(pAnime, secondaryItems); + + for (const match of matches) { + if (groupConsumedIds.has(match.animeId)) continue; + + const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId)); + if (!cachedMatch?.links) continue; + + const mappingEntries = []; + const matchedPIndices = new Set(); + const pendingMutations = []; + const orphanedEpisodes = []; + + const logTitleB = cachedMatch.animeTitle.replace(RE_FROM_SUFFIX, ''); + const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter); + const filteredMLinksWithIndex = filterEpisodes(cachedMatch.links, epFilter); + + const seriesLangB = getLanguageType(cachedMatch.animeTitle); + + const offset = findBestAlignmentOffset( + filteredPLinksWithIndex, + filteredMLinksWithIndex, + seriesLangA, + seriesLangB, + currentPrimarySource, + secSource, + pAnime.animeTitle, + cachedMatch.animeTitle + ); + + if (offset !== 0) { + log("info", `${logPrefix} 集数自动对齐 (${secSource}): Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`); + } + + derivedAnime.animeId = generateSafeMergedId(derivedAnime.animeId, match.animeId, groupFingerprint); + derivedAnime.bangumiId = String(derivedAnime.animeId); + + let mergedCount = 0; + + const redundantS = identifyRedundantTitle(cachedMatch.links, cachedMatch.animeTitle, secSource); + + for (let k = 0; k < filteredMLinksWithIndex.length; k++) { + const pIndex = k + offset; + const sourceLinkItem = filteredMLinksWithIndex[k]; + const sourceLink = sourceLinkItem.link; + const sTitleShort = sourceLink.name || sourceLink.title || `Index ${k}`; + + const orphanItem = { + link: sourceLink, + originalIndex: sourceLinkItem.originalIndex, + relativeIndex: pIndex, + info: null + }; + + const cleanTitleS = getTempTitle(sourceLink.title, redundantS); + orphanItem.info = extractEpisodeInfo(cleanTitleS, secSource); + + if (epFilter && epFilter.test(sTitleShort)) { + mappingEntries.push({ idx: pIndex, text: ` [略过] ${sTitleShort} (命中PV/预告过滤器)` }); + continue; + } + + if (pIndex >= 0 && pIndex < filteredPLinksWithIndex.length) { + const originalPIndex = filteredPLinksWithIndex[pIndex].originalIndex; + const targetLink = derivedAnime.links[originalPIndex]; + const pTitleShort = targetLink.name || targetLink.title || `Index ${originalPIndex}`; + + const cleanTitleP = getTempTitle(targetLink.title, redundantP); + + const specialP = getSpecialEpisodeType(cleanTitleP); + const specialS = getSpecialEpisodeType(cleanTitleS); + + const infoP = extractEpisodeInfo(cleanTitleP, currentPrimarySource); + const infoS = orphanItem.info; + + if (infoS.isPV && !specialP) { + mappingEntries.push({ idx: pIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (PV不匹配正片)` }); + orphanedEpisodes.push(orphanItem); + continue; + } + + if (specialP !== specialS) { + mappingEntries.push({ idx: pIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (特殊集类型不匹配)` }); + orphanedEpisodes.push(orphanItem); + continue; + } + + if ((infoP.isStrictSpecial && !infoS.isSpecial) || (infoS.isStrictSpecial && !infoP.isSpecial)) { + mappingEntries.push({ idx: pIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (正片与番外阻断)` }); + orphanedEpisodes.push(orphanItem); + continue; + } + + const idB = sanitizeUrl(sourceLink.url); + let currentUrl = targetLink.url; + const secPart = `${secSource}:${idB}`; + + if (!currentUrl.includes(MERGE_DELIMITER)) { + if (!currentUrl.startsWith(currentPrimarySource + ':')) { + currentUrl = `${currentPrimarySource}:${currentUrl}`; + } + } + const newMergedUrl = `${currentUrl}${MERGE_DELIMITER}${secPart}`; + + let newMergedTitle = targetLink.title; + if (newMergedTitle) { + let sLabel = secSource; + if (sourceLink.title) { + const sMatch = sourceLink.title.match(/^【([^】\d]+)(?:\d*)】/); + if (sMatch) sLabel = sMatch[1].trim(); + } + newMergedTitle = newMergedTitle.replace( + /^【([^】]+)】/, + (match, content) => `【${content}${DISPLAY_CONNECTOR}${sLabel}】` + ); + } + + mappingEntries.push({ idx: pIndex, text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}` }); + matchedPIndices.add(pIndex); + mergedCount++; + + pendingMutations.push({ linkIndex: originalPIndex, newUrl: newMergedUrl, newTitle: newMergedTitle }); + + } else { + mappingEntries.push({ idx: pIndex, text: ` [落单] (主源越界) <-> ${sTitleShort}` }); + orphanedEpisodes.push(orphanItem); + } + } + + for (let j = 0; j < filteredPLinksWithIndex.length; j++) { + if (!matchedPIndices.has(j)) { + const originalPIndex = filteredPLinksWithIndex[j].originalIndex; + const targetLink = derivedAnime.links[originalPIndex]; + const pTitleShort = targetLink.name || targetLink.title || `Index ${originalPIndex}`; + mappingEntries.push({ idx: j, text: ` [落单] ${pTitleShort} <-> (副源缺失或被略过)` }); + } + } + + if (mergedCount > 0) { + if (isMergeRatioValid(mergedCount, filteredPLinksWithIndex.length, filteredMLinksWithIndex.length, currentPrimarySource, secSource)) { + + for (const mutation of pendingMutations) { + const link = derivedAnime.links[mutation.linkIndex]; + link.url = mutation.newUrl; + link.title = mutation.newTitle; + } + + log("info", `${logPrefix} 关联成功: [${currentPrimarySource}] ${logTitleA} <-> [${secSource}] ${logTitleB} (本次合并 ${mergedCount} 集)`); + if (mappingEntries.length > 0) { + mappingEntries.sort((a, b) => a.idx - b.idx); + log("info", `${logPrefix} [${secSource}] 映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`); + } + + stitchUnmatchedEpisodes(derivedAnime, orphanedEpisodes, secSource); + + groupConsumedIds.add(match.animeId); + globalConsumedIds.add(match.animeId); + + hasMergedAny = true; + actualMergedSources.push(secSource); + contentSignatureParts.push(match.animeId); + } else { + log("info", `${logPrefix} 关联取消: [${currentPrimarySource}] ${logTitleA} <-> [${secSource}] ${logTitleB} (匹配率过低: ${mergedCount}/${Math.max(filteredPLinksWithIndex.length, filteredMLinksWithIndex.length)})`); + } + } + } + } + + if (hasMergedAny) { + // 强制将 dandan 和 animeko 的番外集沉底 + const normals = []; + const sinkers = []; + + derivedAnime.links.forEach(link => { + if (RE_SPECIAL_SINK_TITLE.test(cleanText(link.title).replace(RE_SOURCE_TAG, '')) && RE_DANDAN_TAG.test(link.title)) { + sinkers.push(link); + } else { + normals.push(link); + } + }); + + if (sinkers.length > 0) { + derivedAnime.links = [...normals, ...sinkers]; + log("info", `${logPrefix} 执行番外沉底排序: 移动了 ${sinkers.length} 个 dandan/animeko 番外集到末尾`); + } + + const signature = contentSignatureParts.join('|'); + if (generatedSignatures.has(signature)) { + log("info", `${logPrefix} 检测到重复的合并结果 (Signature: ${signature}),已自动隐去冗余条目。`); + return derivedAnime; + } + generatedSignatures.add(signature); + + const joinedSources = actualMergedSources.join(DISPLAY_CONNECTOR); + derivedAnime.animeTitle = derivedAnime.animeTitle.replace(`from ${currentPrimarySource}`, `from ${currentPrimarySource}${DISPLAY_CONNECTOR}${joinedSources}`); + derivedAnime.source = currentPrimarySource; + + return derivedAnime; + } + + return null; +} + +/** + * 执行源合并逻辑 (入口函数) + * 引入 CN 隔离逻辑与回退机制 (Phase 1 & Phase 2) + * Phase 2 增加排序逻辑:非CN主源优先执行,防止CN主源抢占通用资源 + * @param {Array} curAnimes 当前所有的动画条目列表 (将被就地修改) */ export async function applyMergeLogic(curAnimes) { - const groups = globals.mergeSourcePairs; // 此时已是 {primary, secondaries[]} 结构 + const groups = globals.mergeSourcePairs; if (!groups || groups.length === 0) return; log("info", `[Merge] 启动源合并策略,配置: ${JSON.stringify(groups)}`); - // 获取过滤正则 (直接从 globals 获取,支持 RegExp 或字符串) let epFilter = globals.episodeTitleFilter; if (epFilter && typeof epFilter === 'string') { try { epFilter = new RegExp(epFilter, 'i'); } catch (e) { epFilter = null; } } const newMergedAnimes = []; - 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('&')}`; - - const primaryItems = curAnimes.filter(a => a.source === primary && !a._isMerged); - - // 如果该主源没有数据,直接跳过整个组 - if (primaryItems.length === 0) continue; - - // 对每个主源动漫条目进行处理 - for (const pAnime of primaryItems) { - - 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); - mergedSecondaryAnimes.forEach(item => { - item._isMerged = true; - }); + const generatedSignatures = new Set(); + const globalConsumedIds = new Set(); + for (const group of groups) { + const groupConsumedIds = new Set(); + + const fullPriorityList = [group.primary, ...group.secondaries]; + const groupFingerprint = fullPriorityList.join('&'); + + for (let i = 0; i < fullPriorityList.length - 1; i++) { + const currentPrimarySource = fullPriorityList[i]; + const availableSecondaries = fullPriorityList.slice(i + 1); + + const allSourceItems = curAnimes.filter(a => a.source === currentPrimarySource); + + const activeRemainingSourcesCount = availableSecondaries.filter(secSrc => { + return curAnimes.some(a => a.source === secSrc && !groupConsumedIds.has(a.animeId)); + }).length; + + if (allSourceItems.length === 0) { + if (activeRemainingSourcesCount >= 1 && (activeRemainingSourcesCount + (allSourceItems.length > 0 ? 1 : 0)) >= 2) { + if (activeRemainingSourcesCount >= 2) { + log("info", `[Merge] 轮替: 源 [${currentPrimarySource}] 无可用结果,尝试下一顺位.`); + } + } + continue; + } + + const validPrimaryItems = allSourceItems.filter(a => !groupConsumedIds.has(a.animeId)); + if (validPrimaryItems.length === 0) continue; + + const hasCnInPrimary = validPrimaryItems.some(a => getLanguageType(a.animeTitle) === 'CN'); + let enableCnIsolation = false; + + if (hasCnInPrimary) { + for (const secSrc of availableSecondaries) { + const secItems = curAnimes.filter(a => a.source === secSrc && !groupConsumedIds.has(a.animeId)); + if (secItems.some(a => getLanguageType(a.animeTitle) === 'CN')) { + enableCnIsolation = true; + break; + } + } + } + + // [Phase 1: CN Isolation Pass] 优先匹配 CN -> CN + // 这一步本身就是隔离保护,确保 CN 主源优先去消化 CN 副源,不干扰 JP 资源 + if (enableCnIsolation) { + const cnPrimaries = validPrimaryItems.filter(a => getLanguageType(a.animeTitle) === 'CN'); + + if (cnPrimaries.length > 0) { + log("info", `[Merge] 启动 CN 隔离逻辑: 检测到 [${currentPrimarySource}] 包含 ${cnPrimaries.length} 个 CN 资源,将优先匹配 CN 副源。`); + + for (const pAnime of cnPrimaries) { + const resultAnime = await processMergeTask({ + pAnime, + availableSecondaries, + curAnimes, + groupConsumedIds, + globalConsumedIds, + generatedSignatures, + epFilter, + groupFingerprint, + currentPrimarySource, + logPrefix: `[Merge][Phase 1: CN-Isolation]`, + limitSecondaryLang: 'CN' + }); + + if (resultAnime) { + newMergedAnimes.push(resultAnime); + groupConsumedIds.add(pAnime.animeId); + globalConsumedIds.add(pAnime.animeId); + } + } + } + } + + // [Phase 2: Standard/Fallback Pass] 处理剩余条目 (非CN 或 Phase 1 失败的条目) + let remainingPrimaryItems = allSourceItems.filter(a => !groupConsumedIds.has(a.animeId)); + + // 主源排序优化:让 非CN 结果优先执行 + // 目的:避免 CN 结果(通常匹配分较低或容易误配)抢占了属于 JP 原版的高质量副源。 + // 排序规则:Non-CN 排在前面,CN 排在后面。 + if (remainingPrimaryItems.length > 1) { + remainingPrimaryItems.sort((a, b) => { + const isCnA = getLanguageType(a.animeTitle) === 'CN'; + const isCnB = getLanguageType(b.animeTitle) === 'CN'; + + if (isCnA === isCnB) return 0; + return isCnA ? 1 : -1; + }); + } + + for (const pAnime of remainingPrimaryItems) { + const resultAnime = await processMergeTask({ + pAnime, + availableSecondaries, + curAnimes, + groupConsumedIds, + globalConsumedIds, + generatedSignatures, + epFilter, + groupFingerprint, + currentPrimarySource, + logPrefix: `[Merge][Phase 2: Standard]` + }); + + if (resultAnime) { + newMergedAnimes.push(resultAnime); + groupConsumedIds.add(pAnime.animeId); + globalConsumedIds.add(pAnime.animeId); + } + } + } + } + + if (newMergedAnimes.length > 0) { + for (const anime of newMergedAnimes) { + addAnime(anime); + } + curAnimes.unshift(...newMergedAnimes); + } + for (let i = curAnimes.length - 1; i >= 0; i--) { const item = curAnimes[i]; - if (item._isMerged || usedBaseAnimeIds.has(item.animeId)) { + if (item._isMerged || globalConsumedIds.has(item.animeId)) { curAnimes.splice(i, 1); } } + + log("info", `[Merge] 合并完成,最终列表数量: ${curAnimes.length}`); } /** * 合并两个弹幕列表并按时间排序 * @param {Array} listA 弹幕列表A * @param {Array} listB 弹幕列表B - * @returns {Array} 合并后的弹幕列表 + * @returns {Array} 合并后按时间升序排列的列表 */ export function mergeDanmakuList(listA, listB) { const final = [...(listA || []), ...(listB || [])];