部分源细节优化 (#184)

* sohu.js:过滤仅预告片结果,提升类型提取准确性

* renren.js:使用准确的类型值

* animeko.js:规范集命名

* merge-util.js:健壮对比函数,修复bug
This commit is contained in:
Elegy233 authored and GitHub committed 2026-02-08 23:16:51 +08:00
1 parent 2ad2d5fa1a
commit cb0bf051e6
4 files changed
+483 -117

No files matched your search

+1 -1
View File
@@ -481,7 +481,7 @@ export default class AnimekoSource extends BaseSource {
const epNum = ep.sort || ep.ep;
const epName = ep.name_cn || ep.name || "";
const fullTitle = `EP${epNum} ${epName}`.trim();
const fullTitle = `第${epNum}话 ${epName}`.trim();
links.push({
"name": `${epNum}`,
+2 -2
View File
@@ -206,7 +206,7 @@ export default class RenrenSource extends BaseSource {
provider: "renren",
mediaId: String(item.id),
title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"),
type: "tv_series",
type: item.classify || "Renren",
season: null,
year: item.year,
imageUrl: item.cover,
@@ -373,7 +373,7 @@ export default class RenrenSource extends BaseSource {
provider: "renren",
mediaId: String(item.id),
title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"),
type: "tv_series",
type: item.classify || "Renren",
season: null,
year: item.year,
imageUrl: item.cover,
+27 -5
View File
@@ -34,17 +34,39 @@ export default class SohuSource extends BaseSource {
return null;
}
// 过滤仅预告片结果 通过 is_trailer 字段判断 (1 为预告片)
if (item.is_trailer === 1) {
return null;
}
// 过滤仅预告片结果 通过角标文字判断 (corner_mark.text 为 "预告")
if (item.corner_mark && item.corner_mark.text === '预告') {
return null;
}
// 清理标题中的高亮标记
let title = item.album_name.replace('<<<', '').replace('>>>', '');
// 从meta中提取类型信息
// meta格式: ["20集全", "电视剧 | 内地 | 2018年", "主演:..."]
let categoryName = null;
if (item.meta && item.meta.length >= 2) {
const metaText = item.meta[0].txt; // "电视剧 | 内地 | 2018年"
const parts = metaText.split('|');
if (parts.length > 0) {
categoryName = parts[0].trim(); // "电视剧"
if (item.meta && Array.isArray(item.meta)) {
// 遍历 meta 数组,寻找包含 "|" 的条目 (例如: "电视剧 | 美国 | 2018年")
for (const metaData of item.meta) {
if (metaData.txt && metaData.txt.includes('|')) {
const parts = metaData.txt.split('|');
if (parts.length > 0) {
const firstPart = parts[0].trim();
// 额外处理:如果第一部分是 "别名:XXX",则取第二部分
// (例如 "别名:铁面无私包公 | 电影 | ...")
if (firstPart.includes('别名') && parts.length > 1) {
categoryName = parts[1].trim();
} else {
categoryName = firstPart;
}
break; // 找到后立即停止
}
}
}
}
+453 -109
View File
@@ -41,8 +41,8 @@ function log(level, ...args) {
const REGEX_PURE_SEASON_PART = /^(?:(?:第|S(?:eason)?)\s*\d+(?:季|期|部)?|(?:Part|P|第)\s*\d+(?:部分)?)$/i;
// 语言识别正则
const RE_LANG_CN = /(普通话|国语|中文配音|中配|中文版|粤配|粤语)/;
const RE_LANG_JP = /(日语|日配|原版|原声)/;
const RE_LANG_CN = /(普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听)(?:版)?/;
const RE_LANG_JP = /(日语|日配|原版|原声)(?:版)?/;
// cleanText 相关正则 (通用清洗)
const RE_NA_TAG = /(\(|(|\[)N\/A(\)|)|\])/gi;
@@ -52,7 +52,7 @@ 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_CN_DUB_VER = /(\(|(|\[)?(普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听)版?(\)|)|\])?/g;
const RE_JP_DUB_VER = /(\(|(|\[)?(日语|日配|原版|原声)版?(\)|)|\])?/g;
const RE_SOURCE_TAG = /【.*?】/g;
const RE_REGION_LIMIT = /(\(|(|\[)仅限.*?地区(\)|)|\])/g;
@@ -65,16 +65,17 @@ 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 = /剧场版|the\s*movie|theatrical|movie|film|电影/gi;
const RE_LANG_KEYWORDS_STRONG = /(?:中配|普通话|国语|日语|原声|粤配|粤语|日配)(?:版)?/g;
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_SEASON_PREFIX = /(?:^|\s)(?:第[0-9一二三四五六七八九十]+季|S(?:eason)?\s*\d+)(?:\s+|_)/gi;
const RE_CLEAN_EP_SMART = /(?:^|\s)(?:EP|E|Vol|Episode|No|Part|第)\s*\d+(?:\.\d+)?(?:\s*[话話集])?(?!\s*[季期部])/gi;
const RE_LANG_CN_STD = /普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听/g;
const RE_LANG_JP_STD = /日语|日配|原版|原声/g;
const RE_EP_PUNCTUATION = /[!!??,,.。、~~::\-–—]/g;
// checkTitleSubtitleConflict 相关正则 (结构检测)
@@ -91,7 +92,7 @@ const SEASON_PATTERNS = [
{ regex: /s(\d+)/, prefix: 'S' },
{ regex: /part\s*(\d+)/, prefix: 'P' },
{ regex: /(ova|oad)/, val: 'OVA' },
{ regex: /(剧场版|movie|film|电影)/, val: 'MOVIE' },
{ regex: /(剧场版|the\s*movie|theatrical|movie|film|电影)/, val: 'MOVIE' },
{ regex: /(续篇|续集)/, val: 'SEQUEL' },
{ regex: /sp/, val: 'SP' },
// 末尾数字检测:改为使用无 Part 的文本进行检测
@@ -99,6 +100,9 @@ const SEASON_PATTERNS = [
];
const RE_PART_ANY = /(?:part|p)\s*\d+/gi; // 用于预处理移除Part
// 起承转结结构正则 (Global Scope) 匹配模式:空格/开头/x数字 后接 承/转/结,且后面是结束或分隔符
const RE_CN_STRUCTURE = /(?:^|\s|×\d+\s?)(承|转|结)(?=$|[\s\(\(\[【])/i;
// 复杂特定后缀 (高优先级检测)
// 包含: A's(S2), StrikerS(S3), ViVid(S4), SuperS(S4)
const RE_SUFFIX_SPECIFIC_MAP = [
@@ -123,19 +127,19 @@ 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|$)/;
const RE_EP_NUM_STRATEGY_A = /(?:第|s)(\d+)[季s]\s*(?:第|ep|e)(\d+)/i;
const RE_EP_NUM_STRATEGY_B = /(?:ep|e|vol|episode|chapter|no|part|第)\s*(\d+(\.\d+)?)(?:\s*[话話集])?(?!\s*[季期部])/i;
const RE_EP_NUM_STRATEGY_C = /(?:^|\s)(?:第)?(\d+(\.\d+)?)(?:话|集|\s|$)/;
// dandan/animeko 特殊集排除相关正则 (S1, C1...)
const RE_DANDAN_IGNORE_EP = /^[SC]\d+/i;
// 非正片集数关键词相关正则 (用于构建季度集数地图时严格过滤)
const RE_MAP_EXCLUDE_KEYWORDS = /(?:^|\s)(?:PV|OP|ED|SP|Special|Drama|OAD|OVA|Opening|Ending|特番|花絮|采访|Behind\s+the\s+Scenes|Making|Interview)(?:\s|$|[::])/i;
const RE_MAP_EXCLUDE_KEYWORDS = /(?:^|\s)(?:PV|OP|ED|SP|Special|Drama|OAD|OVA|Opening|Ending|特番|特典|Behind\s+the\s+Scenes|Making|Interview)(?:\s|$|[::])/i;
// getContentCategory 相关 (内容分类)
const RE_ANIME_KW = /(动画|动漫|日漫|国漫)/;
const RE_REAL_KW = /(电视剧|真人剧|综艺)/;
const RE_ANIME_KW = /(动画|TV动画|动漫|日漫|国漫)/;
const RE_REAL_KW = /(电视剧|真人剧|综艺|纪录片)/;
const RE_ANIMEKO_SOURCE = /animeko/i;
// identifyRedundantTitle 相关 (冗余检测)
@@ -147,7 +151,7 @@ const RE_REDUNDANT_VALID_CHARS = /[\u4e00-\u9fa5a-zA-Z]{2,}/;
const RE_CN_STRICT_CORE_REMOVE = /[0-9a-zA-Z\s第季集话partEPep._\-–—::【】()()]/gi;
// 列表沉底检测正则 匹配规则:以 S, C, SP, OP, ED, PV 开头,后跟数字,或者特定的关键词
const RE_SPECIAL_SINK_TITLE_STRICT = /^(?:S\d+|C\d+|SP\d*|OP\d*|ED\d*|PV\d*|Trailers?|Interview|Making|特番|花絮|特典)(?:\s|$|[::.\-]|\u3000)/i;
const RE_SPECIAL_SINK_TITLE_STRICT = /^(?:S\d+|C\d+|SP\d*|OP\d*|ED\d*|PV\d*|Trailers?|Interview|Making|特番|特典)(?:\s|$|[::.\-]|\u3000)/i;
/**
* 获取文本的语言/配音类型
@@ -192,7 +196,15 @@ function cleanText(text) {
clean = clean.replace(RE_JP_DUB_VER, '');
clean = clean.replace(RE_SOURCE_TAG, '');
clean = clean.replace(RE_REGION_LIMIT, '');
// 保护数字中间的小数点 (如 12.5 -> 12{{DOT}}5)
// 避免被 RE_PUNCTUATION 误伤
clean = clean.replace(/(\d+)\.(\d+)/g, '$1{{DOT}}$2');
clean = clean.replace(RE_PUNCTUATION, ' ');
// 恢复小数点
clean = clean.replace(/{{DOT}}/g, '.');
return clean.replace(RE_WHITESPACE, ' ').toLowerCase().trim();
}
@@ -236,7 +248,7 @@ function cleanTitleForSimilarity(text) {
/**
* 专用于集标题的清洗函数
* 专门净化 "第X集_01" 等格式
* 专门净化 "第X集_01"、"EP01"、"第1话" 等格式,只保留核心标题
* @param {string} text 集标题
* @returns {string} 清洗后的集标题
*/
@@ -244,13 +256,21 @@ function cleanEpisodeText(text) {
if (!text) return '';
let clean = simplized(text);
// 1. 清除文件杂质
clean = clean.replace(RE_EP_SUFFIX_DIGIT, '');
clean = clean.replace(RE_FILE_NOISE, '');
// 2. 清除季数前缀 (S2 ...)
clean = clean.replace(RE_EP_SEASON_PREFIX, ' ');
// 3. 清除集数编号 (第25话 -> "")
clean = clean.replace(RE_CLEAN_EP_SMART, ' ');
clean = clean.replace(RE_SOURCE_TAG, '');
clean = clean.replace(RE_LANG_CN_STD, '中文');
clean = clean.replace(RE_LANG_JP_STD, '日文');
// 保护数字中间的小数点
clean = clean.replace(/(\d+)\.(\d+)/g, '$1{{DOT}}$2');
clean = clean.replace(RE_EP_PUNCTUATION, ' ');
// 恢复小数点
clean = clean.replace(/{{DOT}}/g, '.');
return clean.replace(RE_WHITESPACE, ' ').toLowerCase().trim();
}
@@ -558,6 +578,16 @@ function extractSeasonMarkers(title, typeDesc = '') {
// 临时移除 Part 信息,防止 Part 数字干扰末尾季数检测
const tWithoutParts = t.replace(RE_PART_ANY, '');
// 起承转结 (Qi-Cheng-Zhuan-Jie) 结构检测 匹配模式:空格/开头/x数字 后接 承/转/结,且后面是结束或分隔符
// 例子: "无头骑士异闻录×2 承" -> S2, "×2 转" -> S3, "×2 结" -> S4
const structMatch = tWithoutParts.match(RE_CN_STRUCTURE);
if (structMatch) {
const char = structMatch[1];
if (char === '承') markers.add('S2');
else if (char === '转') markers.add('S3');
else if (char === '结') markers.add('S4');
}
// 1. 标准数字季数检测 (Season 2, 第2季)
SEASON_PATTERNS.forEach(p => {
const targetText = p.useCleaned ? tWithoutParts : t;
@@ -804,11 +834,20 @@ function checkSeasonMismatch(titleA, titleB, typeA, typeB) {
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');
const hasS1 = (set) => set.has('S1');
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)))) {
// 显式冲突检测:AMBIGUOUS (代表S2/S3/续作) 与 S1 绝对互斥
// 之前的逻辑因为 AMBIGUOUS 不以 'S' 开头,导致跳过了循环检查,从而让 "中配版(S1)" 漏网
if ((hasAmbiguous(markersA) && hasS1(markersB)) ||
(hasAmbiguous(markersB) && hasS1(markersA))) {
return true;
}
// 歧义后缀豁免:如果任意一方包含 AMBIGUOUS,且另一方包含 S2+ 或 Sequel,则不再判定为硬性冲突
// 这允许 "小林S" (Ambiguous) 匹配 "小林S2" 或 "小林续篇"
if ((hasAmbiguous(markersA) && (hasS2OrMore(markersB) || hasSequel(markersB))) ||
(hasAmbiguous(markersB) && (hasS2OrMore(markersA) || hasSequel(markersA)))) {
return false;
}
@@ -817,11 +856,12 @@ function checkSeasonMismatch(titleA, titleB, typeA, typeB) {
return false;
}
// 标准冲突检测
// 标准冲突检测 (针对 S1, S2, S3 等明确数字标记)
for (const m of markersA) {
if (m.startsWith('S')) {
const hasSameS = markersB.has(m);
const bHasAnyS = Array.from(markersB).some(b => b.startsWith('S'));
// 如果 A 有 S标记 (如 S2),但 B 没有这个标记且 B 有其他 S标记 (如 S1),则冲突
if (!hasSameS && bHasAnyS) {
return true;
}
@@ -830,6 +870,7 @@ function checkSeasonMismatch(titleA, titleB, typeA, typeB) {
return false;
}
// 如果集合大小不同,可能存在包含关系或漏标,进行剧场版豁免检查
if (markersA.size !== markersB.size) {
if (checkTheatricalExemption(titleA, titleB, typeA, typeB)) {
return false;
@@ -1094,7 +1135,7 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
let primaryTitleForSim = rawPrimaryTitle.replace(RE_YEAR_TAG, '');
primaryTitleForSim = primaryTitleForSim.replace(/【(电影|电视剧)】/g, '').trim();
// 检测中配 (RE_CN_DUB_VER 是 global,使用 match 避免 lastIndex 问题)
// 检测主源是否为中配 (RE_CN_DUB_VER 是 global,使用 match 避免 lastIndex 问题)
const isPrimaryDub = !!(primaryTitleForSim.match(RE_CN_DUB_VER)) || RE_LANG_CN.test(primaryTitleForSim);
// 针对 hanjutv 源进行年份忽略处理,视为无效日期
@@ -1119,7 +1160,13 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
const isPrimaryCollection = collectionAnimeIds.has(primaryAnime.animeId);
// 上下文感知:预先标记副源列表中的Ambiguous Sequels
const ambiguousSequelsMap = detectPeerContextSequels(secondaryList);
// 将主源也加入检测池,判断主源是否为列表内某条目的续作
// 这解决了 "小林家的龙女仆S" (主源) 匹配 "小林家的龙女仆" (副源/Base) 的问题
const combinedForContext = [{ animeId: primaryAnime.animeId, animeTitle: rawPrimaryTitle }, ...secondaryList];
const ambiguousSequelsMap = detectPeerContextSequels(combinedForContext);
const isPrimaryContextSequel = ambiguousSequelsMap.has(String(primaryAnime.animeId));
const primaryBaseTitleFromContext = ambiguousSequelsMap.get(String(primaryAnime.animeId));
for (const secAnime of secondaryList) {
const rawSecTitle = secAnime.animeTitle || '';
@@ -1137,6 +1184,12 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
let secTitleForSim = rawSecTitle.replace(RE_YEAR_TAG, '');
secTitleForSim = secTitleForSim.replace(/【(电影|电视剧)】/g, '').trim();
// 检测副源是否为中配
const isSecDub = !!(secTitleForSim.match(RE_CN_DUB_VER)) || RE_LANG_CN.test(secTitleForSim);
// 只要任意一方是配音版,即视为配音关系,启用宽容日期检查
const isDubRelation = isPrimaryDub || isSecDub;
const secCount = secAnime.episodeCount || (secAnime.links ? secAnime.links.length : 0);
// 之字结构强阻断:主标题是副标题的前缀父集
@@ -1162,8 +1215,9 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
// 上下文 Sequel 阻断逻辑
const isAmbiguousSequel = ambiguousSequelsMap.has(String(secAnime.animeId));
// 1. 副源是续作,而主源是前作 -> 阻断
if (isAmbiguousSequel) {
// 核心逻辑:副源是续作,而主源清洗后等于副源的 Base Title,则主源为前作,阻断
const baseTitleOfSec = ambiguousSequelsMap.get(String(secAnime.animeId));
if (cleanPrimarySim === cleanTitleForSimilarity(baseTitleOfSec)) {
@@ -1176,6 +1230,15 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
}
}
}
// 2. 主源是续作,而副源是前作 -> 阻断
// 即使 checkSeasonMismatch 豁免了 S1,这里也会基于上下文进行强制拦截
if (isPrimaryContextSequel) {
if (cleanTitleForSimilarity(secTitleForSim) === cleanTitleForSimilarity(primaryBaseTitleFromContext)) {
logReason(rawSecTitle, `上下文阻断: 主源(S2/Sequel) vs 副源(Base/S1) (Base: "${primaryBaseTitleFromContext}")`);
continue;
}
}
if (!isDateValid && hasStructureConflict) {
logReason(rawSecTitle, `标题结构冲突且日期无效 (HasConflict=true, DateValid=false)`);
@@ -1216,9 +1279,9 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
}
}
// 日期检查 (传入 isDub 参数)
// 日期检查 (传入 isDubRelation 参数)
// 如果是合集,豁免日期检查
const dateScore = isAnyCollection ? 0 : checkDateMatch(primaryDate, secDate, isPrimaryDub);
const dateScore = isAnyCollection ? 0 : checkDateMatch(primaryDate, secDate, isDubRelation);
// 日期严重不匹配处理
if (dateScore === -1) {
@@ -1242,7 +1305,7 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
}
if (!allowExemption) {
logReason(rawSecTitle, `日期严重不匹配且无豁免 (P:${primaryDate.year} vs S:${secDate.year}, IsDub:${isPrimaryDub}, StrongProbe:${contentProbe.isStrongMatch})`);
logReason(rawSecTitle, `日期严重不匹配且无豁免 (P:${primaryDate.year} vs S:${secDate.year}, IsDub:${isDubRelation}, StrongProbe:${contentProbe.isStrongMatch})`);
continue;
}
}
@@ -1670,13 +1733,14 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un
// 语言匹配
const effLangA = dataA.effLang;
const effLangB = dataB.effLang;
// 将 Unspecified 默认为 JP,防止无标识主源匹配到 CN 副源
const normLangA = effLangA === 'Unspecified' ? 'JP' : effLangA;
const normLangB = effLangB === 'Unspecified' ? 'JP' : effLangB;
if (effLangA !== 'Unspecified' && effLangB !== 'Unspecified') {
if (effLangA === effLangB) {
pairScore += 3.0;
} else {
pairScore -= 5.0;
}
if (normLangA === normLangB) {
pairScore += 3.0;
} else {
pairScore -= 5.0;
}
if (infoA.season !== null && infoB.season !== null && infoA.season !== infoB.season) {
@@ -1920,6 +1984,82 @@ function stitchUnmatchedEpisodes(derivedAnime, orphans, sourceName) {
}
}
/**
* 获取列表中的“中间插值”小数集数
* 智能逻辑:识别夹在整数序列中间的小数 (如 12->12.5->13 中的 12.5)
* 忽略位于序列末尾的小数 (如 12->13->12.5,此时 12.5 不影响对齐,不视为插值)
* @param {Array} links 集数列表
* @param {string} source 来源
* @returns {Set<number>} 需要处理的中间小数集合
*/
function getDecimalEpisodes(links, source) {
const decimals = new Set();
if (!links) return decimals;
// 1. 预解析所有集数信息,并找到最后一个“正片整数集”的索引
// 目的:确定“主序列”的边界
let lastIntegerIndex = -1;
const parsedLinks = [];
for (let i = 0; i < links.length; i++) {
const title = links[i].title || links[i].name || "";
const info = extractEpisodeInfo(title, source);
parsedLinks.push({ index: i, info });
// 更新最后一个整数集的索引
// 排除 PV、Special 等干扰,只认准正片整数
if (info.num !== null && !info.isSpecial && !info.isPV && info.num % 1 === 0) {
lastIntegerIndex = i;
}
}
// 2. 筛选出位于主序列内部的小数
// 判定标准:该小数出现在最后一个整数集之前
parsedLinks.forEach(item => {
const { index, info } = item;
if (info.num !== null && !info.isSpecial && !info.isPV && info.num % 1 !== 0) {
// [智能判断] 只有当它出现在最后一个整数之前,才视为“中间插值”
// 如果它已经在最后 (index > lastIntegerIndex),说明已经是沉底状态或后续无正片,无需处理
if (index < lastIntegerIndex) {
decimals.add(info.num);
}
}
});
return decimals;
}
/**
* 将指定的小数集数沉底
* 保持其他集数顺序不变,仅将目标集数移到末尾
*/
function sinkDecimalEpisodes(links, numsToSink, source, sideName) {
const normals = [];
const sinkers = [];
let movedCount = 0;
links.forEach(link => {
const title = link.title || link.name || "";
const info = extractEpisodeInfo(title, source);
// 如果是目标小数集数,则加入沉底队列
const isTarget = info.num !== null && numsToSink.has(info.num);
if (isTarget) {
sinkers.push(link);
movedCount++;
} else {
normals.push(link);
}
});
if (movedCount > 0) {
// 就地修改数组
links.length = 0;
links.push(...normals, ...sinkers);
log("info", `[Merge-Check] [${sideName}] 自动沉底: 移动了 ${movedCount} 个中间插值集数 (${Array.from(numsToSink).join(',')}) 到末尾 (因对方缺失或位置不一致,避免阻断对齐)`);
}
}
/**
* 构建季度集数地图 (辅助合集切片)
* 遍历同组内所有动漫,统计各季度的公认集数
@@ -2071,6 +2211,17 @@ async function processMergeTask(params) {
secondaryItems = secondaryItems.filter(a => getLanguageType(a.animeTitle) === limitSecondaryLang);
}
// 副源排序逻辑:让 Non-CN 结果优先执行
// 目的:与主源排序逻辑保持一致,确保 Non-CN 的副源(通常是原版或更高质量)排在前面传入匹配函数
if (secondaryItems.length > 1) {
secondaryItems.sort((a, b) => {
const isCnA = getLanguageType(a.animeTitle) === 'CN';
const isCnB = getLanguageType(b.animeTitle) === 'CN';
if (isCnA === isCnB) return 0;
return isCnA ? 1 : -1; // Non-CN 排在前面 (false < true)
});
}
if (secondaryItems.length === 0) continue;
// 将 collectionAnimeIds 传递给匹配函数
@@ -2091,8 +2242,34 @@ async function processMergeTask(params) {
const orphanedEpisodes = [];
const logTitleB = cachedMatch.animeTitle.replace(RE_FROM_SUFFIX, '');
// [智能沉底逻辑应用]
// getDecimalEpisodes 现在只返回“捣乱”的中间小数
const decimalsP = getDecimalEpisodes(derivedAnime.links, currentPrimarySource);
const decimalsS = getDecimalEpisodes(cachedMatch.links, secSource);
// 逻辑 A: 副源沉底
// 如果副源有某个中间小数,但主源没有(可能主源根本没有,或者主源已经沉底了导致 getDecimalEpisodes 不返回它)
// 那么副源也应该把这个小数沉底,以保持序列一致
const toSinkS = new Set([...decimalsS].filter(x => !decimalsP.has(x)));
// 逻辑 B: 主源沉底
// 如果主源有中间小数,但副源没有,主源沉底以适配副源
const toSinkP = new Set([...decimalsP].filter(x => !decimalsS.has(x)));
if (toSinkP.size > 0) {
sinkDecimalEpisodes(derivedAnime.links, toSinkP, currentPrimarySource, `主源:${currentPrimarySource}`);
}
// 副源不能直接修改 cachedMatch,需创建副本
let currentSecondaryLinks = cachedMatch.links;
if (toSinkS.size > 0) {
currentSecondaryLinks = [...cachedMatch.links]; // Shallow copy
sinkDecimalEpisodes(currentSecondaryLinks, toSinkS, secSource, `副源:${secSource}`);
}
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter);
const filteredMLinksWithIndex = filterEpisodes(cachedMatch.links, epFilter);
const filteredMLinksWithIndex = filterEpisodes(currentSecondaryLinks, epFilter);
const seriesLangB = getLanguageType(cachedMatch.animeTitle);
@@ -2390,6 +2567,7 @@ async function processMergeTask(params) {
* 引入标题保护逻辑,防止【Title】格式在清洗时丢失,导致分组失败
* 引入中文数字转换,解决“第三季”与“第3季”清洗不一致问题
* 引入 Part 聚合逻辑,确保 S1 + S1 Part 2 被正确计算为单季总量
* 引入 内容分类隔离、多季先决条件、倍率上限
* @param {Array} curAnimes
* @returns {Set<string>} 疑似合集的 Anime ID 集合
*/
@@ -2401,10 +2579,22 @@ function detectCollectionCandidates(curAnimes) {
// 按清洗后的 Base Title 分组
const groups = new Map();
// ==========================================================
// 阶段一:分组与初筛 (Grouping & Pre-filtering)
// ==========================================================
curAnimes.forEach(anime => {
// 获取完整数据对象,确保类型判断准确
const realAnime = globals.animes.find(a => String(a.animeId) === String(anime.animeId)) || anime;
const markers = extractSeasonMarkers(realAnime.animeTitle, realAnime.typeDescription);
// [严格类型阻断] 在分组前直接排除非目标类型 (MOVIE/OVA/SP/SEQUEL)
if (markers.has('MOVIE') || markers.has('OVA') || markers.has('SP') || markers.has('SEQUEL')) {
return;
}
const rawTitle = anime.animeTitle || '';
// 引入与相似度计算一致的“标题保护逻辑”
// 标题保护逻辑:如果标题被包裹在【】或[]中且位于开头,尝试提取内容
let protectedTitle = simplized(rawTitle);
const startBracketMatch = protectedTitle.match(/^(?:【|\[)(.+?)(?:】|\])/);
if (startBracketMatch) {
@@ -2414,41 +2604,65 @@ function detectCollectionCandidates(curAnimes) {
}
}
// 中文数字转换
// 中文数字转换,统一格式
protectedTitle = protectedTitle.replace(/第([一二三四五六七八九十])季/g, (m, num) => `第${cnNums[num]}季`);
// 移除所有括号、来源、年份,只保留核心标题
// 基础清洗 (移除来源、年份、Meta)
let clean = protectedTitle
.replace(RE_SOURCE_TAG, '')
.replace(RE_FROM_SUFFIX, '')
.replace(RE_YEAR_TAG, '')
.replace(RE_META_SUFFIX, '') // 移除 (TV版) 等后缀
.replace(RE_META_SUFFIX, '')
.trim();
// 移除季数标记 (S2, Part 2)
// 移除配音版本关键词 (中配/日配等),确保不同配音版本能归入同一分组进行统计
clean = clean.replace(RE_LANG_KEYWORDS_STRONG, '');
// 移除特定后缀 (A's, StrikerS...)
for (const mapItem of RE_SUFFIX_SPECIFIC_MAP) {
clean = clean.replace(mapItem.regex, '');
}
// 移除歧义后缀 (S, II, III...)
clean = clean.replace(RE_SUFFIX_AMBIGUOUS, '');
// 移除标准季数/Part标记
clean = clean
.replace(/(?:第|S)\d+(?:季|期|部)/gi, '')
.replace(/(?:Part|P)\s*\d+/gi, '')
.trim();
// 移除空格
// 移除空格并转小写
clean = clean.replace(/\s+/g, '').toLowerCase();
if (!clean) return;
if (!groups.has(clean)) {
groups.set(clean, []);
// 获取内容分类 (REAL vs ANIME)
// 将分类混入 GroupKey,确保动漫不会去和真人剧比对集数
const category = getContentCategory(rawTitle, realAnime.typeDescription, realAnime.source);
const groupKey = `${clean}|${category}`;
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(clean).push(anime);
groups.get(groupKey).push(anime);
});
for (const [baseTitle, list] of groups.entries()) {
// ==========================================================
// 阶段二:组内统计与判定 (Analysis & Detection)
// ==========================================================
for (const [groupKey, list] of groups.entries()) {
if (list.length < 2) continue;
log("info", `[Merge-Check] [合集探测] 正在检查分组: "${baseTitle}" (包含 ${list.length} 个条目)`);
const [baseTitle, category] = groupKey.split('|');
// 输出详细的分组详情日志
const itemDetails = list.map(a => ` - [${a.source}] ${a.animeTitle}`).join('\n');
log("info", `[Merge-Check] [合集探测] 正在检查分组: "${baseTitle}" [${category}] (包含 ${list.length} 个条目):\n${itemDetails}`);
// 预聚合:按源统计各季度集数 (处理 Part 拆分)
const sourceStats = new Map(); // Source -> { seasonCounts: {1:0, 2:0...}, maxSeason: 0, s1Candidates: [] }
let groupGlobalMaxSeason = 0; // 全组最大季数记录
list.forEach(anime => {
const realAnime = globals.animes.find(a => String(a.animeId) === String(anime.animeId)) || anime;
@@ -2463,13 +2677,57 @@ function detectCollectionCandidates(curAnimes) {
}
}
// 计算有效集数 (强力排除番外)
// 检测标题中是否包含歧义后缀或续篇标识
// 如果 seasonNum 仍为 1 (未检测到明确的 S2, S3),但存在歧义标记 (如 "Title S")
// 则强制将其视为 S2 (或更高),避免将其集数误计入 S1 统计池
if (seasonNum === 1) {
const isSequel = markers.has('SEQUEL') || RE_SUFFIX_SEQUEL.test(realAnime.animeTitle);
const isAmbiguous = markers.has('AMBIGUOUS') || RE_SUFFIX_AMBIGUOUS.test(realAnime.animeTitle);
if (isSequel || isAmbiguous) {
seasonNum = 2;
// [Debug] 输出季度判定日志
log("info", `[Merge-Check] [Detail] [${realAnime.source}] "${realAnime.animeTitle}" -> 判定为 S2 (Reason: Sequel/Ambiguous Suffix)`);
}
}
if (seasonNum > groupGlobalMaxSeason) groupGlobalMaxSeason = seasonNum;
// 计算有效正片集数,针对 dandan/animeko 源执行强力过滤以排除番外
let validCount = 0;
if (realAnime.links) {
if (/^(dandan|animeko)$/i.test(realAnime.source)) {
validCount = realAnime.links.filter(l => {
const t = cleanText(l.title || l.name || '');
return !RE_SPECIAL_CHECK.test(t) && !RE_DANDAN_IGNORE_EP.test(t);
validCount = realAnime.links.filter((l, idx) => {
const rawTitle = l.title || l.name || '';
// 1. 优先基于【原始无清洗】的文本进行特征检测
// 目的:防止 cleanText 将 "S1 ..." 标准化为 "第1季 ..." 从而导致 RE_SPECIAL_CHECK 失效
const rawContent = rawTitle.replace(RE_SOURCE_TAG, '').replace(RE_FROM_SUFFIX, '').trim();
if (RE_SPECIAL_CHECK.test(rawContent) || RE_DANDAN_IGNORE_EP.test(rawContent)) {
// log("info", `[Merge-Check] [Detail-Ep] [${realAnime.source}] 排除: "${rawTitle}" (Reason: Raw Title Pattern [S/C/SP+Digit])`);
return false;
}
// 2. 基础清洗后的检测 (保留原有逻辑作为补充)
const t = cleanText(rawTitle);
const rawTemp = t.replace(RE_SOURCE_TAG, '').replace(RE_FROM_SUFFIX, '').trim();
if (RE_SPECIAL_CHECK.test(t) || RE_DANDAN_IGNORE_EP.test(t)) {
// log("info", `[Merge-Check] [Detail-Ep] [${realAnime.source}] 排除: "${rawTitle}" (Reason: Cleaned Title Pattern)`);
return false;
}
// 3. 使用关键词强力排除非正片集数 (OVA, OAD, SP, OP, ED, Special...)
if (RE_MAP_EXCLUDE_KEYWORDS.test(rawTemp) || RE_MAP_EXCLUDE_KEYWORDS.test(rawTitle)) {
// log("info", `[Merge-Check] [Detail-Ep] [${realAnime.source}] 排除: "${rawTitle}" (Reason: Keyword Filter)`);
return false;
}
// 保留的集数记录
log("info", `[Merge-Check] [Detail-Ep] [${realAnime.source}] 保留: "${rawTitle}"`);
return true;
}).length;
} else {
validCount = realAnime.links.length;
@@ -2486,24 +2744,29 @@ function detectCollectionCandidates(curAnimes) {
}
const stat = sourceStats.get(realAnime.source);
// 累加集数 (Dandan S1=22 + S1 Part2=18 -> S1 Total=40)
// 累加集数 (例如 S1 + S1 Part 2)
if (!stat.seasonCounts[seasonNum]) stat.seasonCounts[seasonNum] = 0;
stat.seasonCounts[seasonNum] += validCount;
if (seasonNum > stat.maxSeason) stat.maxSeason = seasonNum;
// 只有被认定为真正的 S1 时,才加入候选项
if (seasonNum === 1) {
stat.s1Candidates.push({ anime: realAnime, originalCount: validCount });
}
});
// 多季特征先决条件:理论上都是 S1 的组意味着这个作品本身就没有季,没有季哪来的合集
if (groupGlobalMaxSeason <= 1) {
// log("info", `[Merge-Check] [合集探测] 跳过: [${baseTitle}] 全组均为 S1,无多季特征`);
continue;
}
// 计算其他源的 S1 基准长度 (排除当前源)
// 规则:取其他拥有 S1 的源的最大 S1 集数
// 这样可以找到“公认的最长 S1”,如果某源的 S1 远超这个值,才可能是合集
const allSources = Array.from(sourceStats.keys());
for (const [source, stat] of sourceStats.entries()) {
// 豁免规则:如果该源拥有 S2, S3 等独立条目,说明该源已分季,其 S1 不视为合集
// 豁免:如果该源拥有 S2+ 独立条目,说明该源已分季,其 S1 不视为合集
if (stat.maxSeason > 1) {
continue;
}
@@ -2525,19 +2788,39 @@ function detectCollectionCandidates(curAnimes) {
if (!hasOtherSources) continue;
// 判定:如果当前源的 S1 总集数 > 其他源最大 S1 集数 + 阈值(6)
// 说明它可能包含了 S2
// 判定:如果当前源的 S1 总集数 > 其他源最大 S1 集数 + 阈值
// 说明它可能包含了 S2,将其标记为合集
const currentS1Total = stat.seasonCounts[1];
const threshold = maxOtherS1 + 6;
const ratio = maxOtherS1 > 0 ? (currentS1Total / maxOtherS1) : 0;
if (currentS1Total > threshold) {
// 标记该源下的所有 S1 候选项为合集
stat.s1Candidates.forEach(cand => {
collectionIds.add(cand.anime.animeId);
log("info", `[Merge-Check] [合集探测] 发现疑似合集: [${source}] ${cand.anime.animeTitle} (TotalS1:${currentS1Total} > OtherMax:${maxOtherS1}+6) -> 标记为合集`);
});
// 上限检查:倍率不能超过 4.0 (防止长篇连载匹配到短篇)
if (ratio > 4.0) {
log("info", `[Merge-Check] [合集探测] 拒绝: [${source}] ${baseTitle} (Ratio too high: ${ratio.toFixed(2)} > 4.0)`);
continue;
}
// 区分“单体巨头”和“分部聚合”
// 1. 检查是否存在单个文件就已经超过阈值的“巨头”
const giants = stat.s1Candidates.filter(c => c.originalCount > threshold);
if (giants.length > 0) {
// 场景A: 存在单体合集 (如 24集版) - 只标记巨头
giants.forEach(cand => {
collectionIds.add(cand.anime.animeId);
log("info", `[Merge-Check] [合集探测] 发现疑似合集(单体): [${source}] ${cand.anime.animeTitle} (Count:${cand.originalCount} > Thr:${threshold}, Ratio:${ratio.toFixed(2)}) -> 标记为合集`);
});
} else {
// 场景B: 单体都未超标,但总和超标 (Part1+Part2) - 标记全员
stat.s1Candidates.forEach(cand => {
collectionIds.add(cand.anime.animeId);
log("info", `[Merge-Check] [合集探测] 发现疑似合集(聚合): [${source}] ${cand.anime.animeTitle} (TotalS1:${currentS1Total} > Thr:${threshold}, Ratio:${ratio.toFixed(2)}) -> 标记为合集`);
});
}
} else {
log("info", `[Merge-Check] [合集探测] 未命中: [${source}] (TotalS1:${currentS1Total} <= Threshold:${threshold})`);
// 打印未命中日志
log("info", `[Merge-Check] [合集探测] 未命中: [${source}] ${baseTitle} (TotalS1:${currentS1Total} <= Threshold:${threshold})`);
}
}
}
@@ -2547,7 +2830,7 @@ function detectCollectionCandidates(curAnimes) {
/**
* 执行源合并逻辑 (入口函数)
* 引入 CN 隔离逻辑与回退机制 (Phase 1 & Phase 2)
* 引入 CN 隔离逻辑 (Phase 1) -> 副源 CN 自组织 (Phase 1.5) -> 回退机制 (Phase 2)
* Phase 2 增加排序逻辑:非CN主源优先执行,防止CN主源抢占通用资源
* @param {Array} curAnimes 当前所有的动画条目列表 (将被就地修改)
*/
@@ -2583,79 +2866,141 @@ export async function applyMergeLogic(curAnimes) {
if (group.secondaries.length === 0) continue;
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}] 无可用结果,尝试下一顺位.`);
}
if (activeRemainingSourcesCount >= 2) {
log("info", `[Merge] 轮替: 源 [${currentPrimarySource}] 无可用结果,尝试下一顺位.`);
}
continue;
}
// =================================================================================
// [Phase 1: Primary CN Isolation] 主源主导的 CN 隔离
// =================================================================================
// 只有当“当前主源”自己就是 CN 时才触发。
// 目的:保护 CN 主源不被 JP 副源干扰。
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;
const cnPrimaries = validPrimaryItems.filter(a => getLanguageType(a.animeTitle) === 'CN');
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;
// 检查副源是否有 CN 资源 (触发条件的开关)
let hasCnInSecondaries = false;
for (const secSrc of availableSecondaries) {
if (curAnimes.some(a => a.source === secSrc && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN')) {
hasCnInSecondaries = true;
break;
}
}
if (cnPrimaries.length > 0 && hasCnInSecondaries) {
log("info", `[Merge] [Phase 1] 启动主源 CN 隔离: [${currentPrimarySource}] 包含 ${cnPrimaries.length} 个 CN 资源。`);
for (const pAnime of cnPrimaries) {
const resultAnime = await processMergeTask({
pAnime,
availableSecondaries,
curAnimes,
groupConsumedIds,
globalConsumedIds,
generatedSignatures,
epFilter,
groupFingerprint,
currentPrimarySource,
logPrefix: `[Merge][Phase 1: CN-Primary]`,
limitSecondaryLang: 'CN', // 强制只匹配 CN 副源
collectionAnimeIds
});
if (resultAnime) {
newMergedAnimes.push(resultAnime);
groupConsumedIds.add(pAnime.animeId);
globalConsumedIds.add(pAnime.animeId);
}
}
}
// =================================================================================
// [Phase 1.5: Secondary CN Self-Organization] 副源自组织 CN 隔离 (NEW!)
// =================================================================================
// 场景:主源可能是原版(无标),但副源里有 Youku(助听) 和 iQiyi(助听)。
// 目标:在主源介入前,先让 Youku 和 iQiyi 内部自行配对,防止被主源误吞。
// 1. 统计剩余未消费的副源中有多少个 CN 资源
let secondaryCnCount = 0;
for (const secSrc of availableSecondaries) {
const count = curAnimes.filter(a =>
a.source === secSrc &&
!groupConsumedIds.has(a.animeId) &&
getLanguageType(a.animeTitle) === 'CN'
).length;
secondaryCnCount += count;
}
// 只有当至少能凑成一对 (>=2) 时才启动
if (secondaryCnCount >= 2) {
log("info", `[Merge] [Phase 1.5] 启动副源 CN 自组织: 检测到副源池中有 ${secondaryCnCount} 个未消费的 CN 资源,尝试内部互联...`);
// 遍历副源列表,将其中的 CN 资源“临时提拔”为主源
// 注意:我们只遍历到倒数第二个,因为最后一个没法再配对了
for (let j = 0; j < availableSecondaries.length - 1; j++) {
const tempPrimarySrc = availableSecondaries[j];
// 它的匹配对象只能是它“顺位后面”的副源 (防止逆向匹配导致死循环或重复)
const tempSecondaries = availableSecondaries.slice(j + 1);
// 找出该源下的 CN 候选人
const tempPrimaryItems = curAnimes.filter(a =>
a.source === tempPrimarySrc &&
!groupConsumedIds.has(a.animeId) &&
getLanguageType(a.animeTitle) === 'CN'
);
for (const tAnime of tempPrimaryItems) {
const resultAnime = await processMergeTask({
pAnime: tAnime,
availableSecondaries: tempSecondaries, // 只能匹配它后面的副源
curAnimes,
groupConsumedIds,
globalConsumedIds,
generatedSignatures,
epFilter,
groupFingerprint,
currentPrimarySource: tempPrimarySrc, // 临时身份:主源
logPrefix: `[Merge][Phase 1.5: CN-Secondary]`,
limitSecondaryLang: 'CN', // 严格限制:只跟后面的 CN 配对
collectionAnimeIds
});
if (resultAnime) {
newMergedAnimes.push(resultAnime);
groupConsumedIds.add(tAnime.animeId); // 标记已消费,真正的主源就不会再碰它了
globalConsumedIds.add(tAnime.animeId);
}
}
}
}
// [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',
collectionAnimeIds // 传递合集集合
});
if (resultAnime) {
newMergedAnimes.push(resultAnime);
groupConsumedIds.add(pAnime.animeId);
globalConsumedIds.add(pAnime.animeId);
}
}
}
}
// [Phase 2: Standard/Fallback Pass] 处理剩余条目 (非CN 或 Phase 1 失败的条目)
// =================================================================================
// [Phase 2: Standard Fallback] 标准合并 (兜底)
// =================================================================================
// 处理剩下的所有资源(包括 Phase 1 和 1.5 没匹配上的,以及非 CN 资源)
let remainingPrimaryItems = allSourceItems.filter(a => !groupConsumedIds.has(a.animeId));
// 主源排序优化:让 非CN 结果优先执行
@@ -2665,7 +3010,6 @@ export async function applyMergeLogic(curAnimes) {
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;
});