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 || [])];