修复合并源时同一个源不能参与不同组合并的问题 (#230)

* merge-util.js:巩固过滤集相关函数,修复同一个源不能参与多组合并配置

* bahamut.js:将tmdb智能标题前的标题传给合并工具

解决Fate/strange Fake智能替换成中文别名后无法关联的问题

* bilibili.js:将接口中的org_title条目原名也传给合并工具对比

解决 命运-奇异赝品 黎明低语 无法与英文别名关联

* bahamut.js:修复时间轴取整导致的去重偏差

* merge-util.js:修复执行顺序bug

* merge-util.js:修复合集复用副源bug,调整结果排序为用户配置优先

* common-util.js:清理标题空格的同时清理修饰性符号

修复【我推的孩子】括号带与不带时被过滤掉了

* bahamut.js:去除多余空格
This commit is contained in:
Elegy233 authored and GitHub committed 2026-03-15 12:58:01 +08:00
1 parent c5b7221997
commit 2dac103629
4 files changed
+141 -77

No files matched your search

+14 -2
View File
@@ -281,6 +281,11 @@ export default class BahamutSource extends BaseSource {
return bahamutTitleMatches(itemTitle, queryTitle, usedSearchTitle);
});
// 记录替换前的原始标题,作为别名传递给合并工具进行比对
filtered.forEach(item => {
item._originalTitleAlias = item.title ? simplized(item.title) : "";
});
// 应用tmdb智能标题替换
const cnAlias = filtered.length > 0 ? filtered[0]._tmdbCnAlias : null;
smartTitleReplace(filtered, cnAlias);
@@ -317,6 +322,12 @@ export default class BahamutSource extends BaseSource {
// 优先使用tmdb智能标题替换的标题,否则简转繁处理原标题
const displayTitle = anime._displayTitle || simplized(anime.title);
// 提取原始标题作为别名
const aliases = [];
if (anime._originalTitleAlias && anime._originalTitleAlias !== displayTitle) {
aliases.push(anime._originalTitleAlias);
}
// 解析剧集类型
let itemType = "动漫"; // 默认类型
// 从 epData 中获取完整标题 (优先使用 anime.title)
@@ -332,6 +343,7 @@ export default class BahamutSource extends BaseSource {
animeId: anime.video_sn,
bangumiId: String(anime.video_sn),
animeTitle: `${displayTitle}(${(anime.info.match(/(\d{4})/) || [null])[0]})【${itemType}】from bahamut`,
aliases: aliases,
type: "动漫",
typeDescription: "动漫",
imageUrl: anime.cover,
@@ -412,10 +424,10 @@ export default class BahamutSource extends BaseSource {
const positionToMode = { 0: 1, 1: 5, 2: 4 };
return comments.map(c => ({
cid: Number(c.sn),
p: `${Math.round(c.time / 10).toFixed(2)},${positionToMode[c.position] || c.tp},${parseInt(c.color.slice(1), 16)},[bahamut]`,
p: `${(c.time / 10).toFixed(2)},${positionToMode[c.position] || c.tp},${parseInt(c.color.slice(1), 16)},[bahamut]`,
// 根据 globals.danmuSimplifiedTraditional 控制是否繁转简
m: globals.danmuSimplifiedTraditional === 'simplified' ? simplized(c.text) : c.text,
t: Math.round(c.time / 10)
t: c.time / 10
}));
}
}
+18 -3
View File
@@ -434,13 +434,24 @@ export default class BilibiliSource extends BaseSource {
return [];
}
// 提取并备份原始标题与接口提取的 org_title 作为别名
sourceAnimes.forEach(anime => {
anime.aliases = anime.aliases || [];
if (anime.title && !anime.aliases.includes(anime.title)) {
anime.aliases.push(anime.title);
}
if (anime.org_title && !anime.aliases.includes(anime.org_title)) {
anime.aliases.push(anime.org_title);
}
});
// 应用tmdb智能标题替换
const cnAlias = sourceAnimes.length > 0 ? sourceAnimes[0]._tmdbCnAlias : null;
smartTitleReplace(sourceAnimes, cnAlias);
const processPromises = sourceAnimes
// 港澳台资源不做严格标题匹配,因为可能搜到的是日语/繁体标题
.filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle)||(anime.org_title && titleMatches(anime.org_title, queryTitle)))
// 港澳台资源不做严格标题匹配,其他资源根据当前标题或别名池(已包含原标题和 org_title)验证查询匹配度
.filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle) || (anime.aliases && anime.aliases.some(alias => titleMatches(alias, queryTitle))))
.map(async (anime) => {
try {
let links = [];
@@ -523,6 +534,7 @@ export default class BilibiliSource extends BaseSource {
rating: 0,
isFavorited: true,
source: "bilibili",
aliases: anime.aliases
};
tmpAnimes.push(transformedAnime);
@@ -893,13 +905,14 @@ export default class BilibiliSource extends BaseSource {
const data = await this._fetchAppSearchWithStream(url, { "User-Agent": "Mozilla/5.0 Android", "X-From-Biliroaming": "1.0.0" }, label, signal);
if (data && data.code === 0) {
// 兼容 items (影视/综艺) 和 result (番剧) 两种字段结构
// 兼容 items (影视/综艺) 和 result (番剧) 两种字段结构,提取返回的 org_title 字段
return (data.data?.items || data.data?.result || data.data || [])
.filter(i => i.goto !== 'recommend_tips' && i.area !== '漫游' && i.badge !== '公告')
.map(i => ({
provider: "bilibili",
mediaId: i.season_id ? `ss${i.season_id}` : (i.uri.match(/season\/(\d+)/)?.[1] ? `ss${i.uri.match(/season\/(\d+)/)[1]}` : ""),
title: (i.title||"").replace(/<[^>]+>/g,'').trim(),
org_title: (i.org_title||"").replace(/<[^>]+>/g,'').trim(),
type: this._extractMediaType(i.season_type_name),
year: i.ptime ? new Date(i.ptime*1000).getFullYear() : null,
imageUrl: i.cover||i.pic||"",
@@ -938,8 +951,10 @@ export default class BilibiliSource extends BaseSource {
return [];
}
if(data.data?.result) {
// 在 Web Fallback 提取并清洗 org_title 字段
return data.data.result.filter(i => i.url?.includes("bilibili.com") && (!i.areas?.includes("漫游"))).map(i => ({
provider: "bilibili", mediaId: i.season_id?`ss${i.season_id}`:"", title: (i.title||"").replace(/<[^>]+>/g,'').trim(),
org_title: (i.org_title || "").replace(/<[^>]+>/g,'').replace(/&[^;]+;/g, match => { const entities = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&#39;': "'" }; return entities[match] || match; }).trim(),
type: this._extractMediaType(i.season_type_name), year: i.pubtime?new Date(i.pubtime*1000).getFullYear():null, imageUrl: i.cover||null,
episodeCount: i.ep_size||0, _eps: i.eps, isOversea: true
})).filter(i => i.mediaId);
+6 -6
View File
@@ -192,14 +192,14 @@ export function createDynamicPlatformOrder(preferredPlatform) {
}
/**
* 规范化标题中的空格(移除所有空格以便进行空格无关的匹配)
* 规范化标题(移除空格并清理修饰性符号)
* @param {string} str - 输入字符串
* @returns {string} 规范化后的字符串(移除所有空格)
* @returns {string} 规范化后的字符串
*/
export function normalizeSpaces(str) {
if (!str) return '';
// 移除所有空格(包括多个连续空格、制表符等)
return String(str).trim().replace(/\s+/g, '');
// 移除所有空格与修饰性符号(包括多个连续空格、制表符等)
return String(str).trim().replace(/[\s【】\[\]《》<>「」!?!?.,,。~~]/g, '');
}
/**
@@ -265,7 +265,7 @@ export function titleMatches(title, query) {
const querySeason = getExplicitSeasonNumber(query);
if (querySeason !== null) {
const titleSeason = getExplicitSeasonNumber(title);
if (querySeason > 1) {
// 搜索指定续作(>1)时,标题必须明确包含该季度标识
if ((titleSeason || 1) !== querySeason) return false;
@@ -280,7 +280,7 @@ export function titleMatches(title, query) {
const qSet = new Set(q);
const tSet = new Set(t);
const matchCount = [...qSet].reduce((acc, char) => acc + (tSet.has(char) ? 1 : 0), 0);
return (matchCount / qSet.size) > 0.8;
}
+103 -66
View File
@@ -205,14 +205,14 @@ const SUFFIX_SPECIFIC_MAP = [
];
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: /(剧场版|the\s*movie|theatrical|movie|film|电影)/, val: 'MOVIE' },
{ regex: /(?:第)?(\d+)(?:季|期|部(?!分))/, prefix: 'S' },
{ regex: /\bseason\s*(\d+)/i, prefix: 'S' },
{ regex: /\bs\s*(\d+)\b/i, prefix: 'S' },
{ regex: /\bpart\s*(\d+)/i, prefix: 'P' },
{ regex: /\b(ova|oad)\d*\b/i, val: 'OVA' },
{ regex: /(剧场版|the\s*movie|theatrical|movie|film|电影)/i, val: 'MOVIE' },
{ regex: /(续篇|续集)/, val: 'SEQUEL' },
{ regex: /sp/, val: 'SP' },
{ regex: /\b(sp|special)\d*\b/i, val: 'SP' },
{ regex: /[^0-9](\d)$/, prefix: 'S', useCleaned: true }
];
@@ -560,8 +560,8 @@ function extractSeasonMarkers(title, typeDesc = '') {
}
if (RegexStore.Season.SUFFIX_SEQUEL.test(t) || type.includes('续篇')) markers.add('SEQUEL');
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');
if (/\b(ova|oad)\b/i.test(type)) markers.add('OVA');
if (/\b(sp|special)\b/i.test(type)) markers.add('SP');
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}`);
@@ -1133,19 +1133,43 @@ function extractEpisodeInfo(title, sourceName = '') {
/**
* 过滤无效剧集 (基于标题正则)
* @param {Array} links
* @param {RegExp} filterRegex
* @returns {Array}
* 特定高置信度源跳过正则过滤,防止常规集数因命中全局正则而丢失
* @param {Array} links 集数对象列表
* @param {RegExp} filterRegex 过滤正则
* @param {string} [sourceName=''] 来源平台名称
* @returns {Array} 携带原始索引的过滤后列表
*/
function filterEpisodes(links, filterRegex) {
function filterEpisodes(links, filterRegex, sourceName = '') {
if (!links) return [];
if (!filterRegex) return links.map((link, index) => ({ link, originalIndex: index }));
return links
.map((link, index) => ({ link, originalIndex: index }))
.filter(item => {
const title = item.link.title || item.link.name || "";
return !filterRegex.test(title);
});
// 特定源白名单:这些源的集标题通常比较规范,免除正则过滤拦截
const skipFilterSources = ['animeko', 'bilibili', 'bilibili1', 'bahamut', 'dandan'];
const shouldSkipFilter = skipFilterSources.includes(sourceName);
if (!filterRegex || shouldSkipFilter) {
return links.map((link, index) => ({ link, originalIndex: index }));
}
const validLinks = [];
const droppedTitles = [];
// 遍历并拦截命中规则的条目
links.forEach((link, index) => {
const title = link.title || link.name || "";
if (filterRegex.test(title)) {
droppedTitles.push(title);
} else {
validLinks.push({ link, originalIndex: index });
}
});
// 集中输出被过滤的条目日志
if (droppedTitles.length > 0) {
const sourcePrefix = sourceName ? `[${sourceName}] ` : '';
log("info", `[Merge-Check] ${sourcePrefix}命中EPISODE_TITLE_FILTER过滤,已前置剔除 ${droppedTitles.length} 集: ${droppedTitles.join(', ')}`);
}
return validLinks;
}
/**
@@ -1533,7 +1557,7 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
const seasonNum = getSeasonNumber(realAnime.animeTitle, realAnime.typeDescription);
if (seasonNum !== null && realAnime.links) {
const validLinks = filterEpisodes(realAnime.links, epFilter).filter(item => {
const validLinks = filterEpisodes(realAnime.links, epFilter, realAnime.source).filter(item => {
const title = item.link.title || item.link.name || "";
const cleanT = cleanText(title);
const rawTemp = cleanT.replace(RegexStore.Clean.SOURCE_TAG, '').replace(RegexStore.Clean.FROM_SUFFIX, '').trim();
@@ -1641,8 +1665,9 @@ async function processMergeTask(params) {
limitSecondaryLang, collectionAnimeIds, allowReuseIds, collectionProgress
} = params;
if (collectionAnimeIds.has(pAnime.animeId) && globalConsumedIds.has(pAnime.animeId)) {
log("info", `${logPrefix} 跳过: [${currentPrimarySource}] 是合集且已作为副源参与过合并。`);
// 在一组中是合集且已作为副源参与过合并就跳过,另一组相互独立互不干扰。
if (collectionAnimeIds.has(pAnime.animeId) && groupConsumedIds.has(pAnime.animeId)) {
log("info", `${logPrefix} 跳过: [${currentPrimarySource}] 是合集且已作为组内副源参与过合并。`);
return null;
}
@@ -1683,6 +1708,10 @@ async function processMergeTask(params) {
for (const secSource of availableSecondaries) {
let secondaryItems = curAnimes.filter(a => {
if (a.source !== secSource) return false;
// 合集主源特权:当主源为合集时,允许无视当前组的消耗状态,复用已被消耗的副源以拼凑完整季度
if (isPrimaryCollection) return true;
const isConsumed = groupConsumedIds.has(a.animeId);
const isAllowedReuse = allowReuseIds && allowReuseIds.has(a.animeId);
if (isConsumed && !isAllowedReuse) return false;
@@ -1704,32 +1733,35 @@ async function processMergeTask(params) {
const matches = findSecondaryMatches(pAnime, secondaryItems, collectionAnimeIds);
for (const match of matches) {
const isReuse = allowReuseIds && allowReuseIds.has(match.animeId);
if (!isReuse && groupConsumedIds.has(match.animeId)) continue;
// 合集主源特权:如果是合集主源,直接跳过此处的二次消耗校验
if (!isPrimaryCollection) {
const isReuse = allowReuseIds && allowReuseIds.has(match.animeId);
if (!isReuse && groupConsumedIds.has(match.animeId)) continue;
}
const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
if (!cachedMatch?.links) continue;
const globalCachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
if (!globalCachedMatch?.links) continue;
const derivedMatch = fastCloneAnime(globalCachedMatch);
const mappingEntries = [], matchedPIndices = new Set(), pendingMutations = [], orphanedEpisodes = [];
const logTitleB = cachedMatch.animeTitle.replace(RegexStore.Clean.FROM_SUFFIX, '');
const logTitleB = derivedMatch.animeTitle.replace(RegexStore.Clean.FROM_SUFFIX, '');
const decimalsP = getDecimalEpisodes(derivedAnime.links, currentPrimarySource);
const decimalsS = getDecimalEpisodes(cachedMatch.links, secSource);
const decimalsS = getDecimalEpisodes(derivedMatch.links, secSource);
const toSinkS = new Set([...decimalsS].filter(x => !decimalsP.has(x)));
const toSinkP = new Set([...decimalsP].filter(x => !decimalsS.has(x)));
if (toSinkP.size > 0) sinkDecimalEpisodes(derivedAnime.links, toSinkP, currentPrimarySource, `主源:${currentPrimarySource}`);
let currentSecondaryLinks = cachedMatch.links;
if (toSinkS.size > 0) {
currentSecondaryLinks = [...cachedMatch.links];
sinkDecimalEpisodes(currentSecondaryLinks, toSinkS, secSource, `副源:${secSource}`);
sinkDecimalEpisodes(derivedMatch.links, toSinkS, secSource, `副源:${secSource}`);
}
let currentSecondaryLinks = derivedMatch.links;
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter);
const filteredMLinksWithIndex = filterEpisodes(currentSecondaryLinks, epFilter);
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter, currentPrimarySource);
const filteredMLinksWithIndex = filterEpisodes(currentSecondaryLinks, epFilter, secSource);
const seriesLangB = getLanguageType(cachedMatch.animeTitle);
const seriesLangB = getLanguageType(derivedMatch.animeTitle);
let activePLinks = filteredPLinksWithIndex, activeMLinks = filteredMLinksWithIndex;
let sliceStartP = 0, sliceStartS = 0;
const isSecondaryCollection = collectionAnimeIds.has(match.animeId);
@@ -1790,7 +1822,7 @@ async function processMergeTask(params) {
};
if (isPrimaryCollection && !isSecondaryCollection) {
const secSeason = getSeasonNumber(cachedMatch.animeTitle, cachedMatch.typeDescription);
const secSeason = getSeasonNumber(derivedMatch.animeTitle, derivedMatch.typeDescription);
const res = performSlicing(true, filteredPLinksWithIndex, secSeason);
activePLinks = res.slicedList; sliceStartP = res.sliceStart;
} else if (!isPrimaryCollection && isSecondaryCollection) {
@@ -1799,7 +1831,7 @@ async function processMergeTask(params) {
activeMLinks = res.slicedList; sliceStartS = res.sliceStart;
}
const bestOffsetLocal = findBestAlignmentOffset(activePLinks, activeMLinks, seriesLangA, seriesLangB, currentPrimarySource, secSource, pAnime.animeTitle, cachedMatch.animeTitle);
const bestOffsetLocal = findBestAlignmentOffset(activePLinks, activeMLinks, seriesLangA, seriesLangB, currentPrimarySource, secSource, pAnime.animeTitle, derivedMatch.animeTitle);
const offset = bestOffsetLocal + sliceStartP - sliceStartS;
if (offset !== 0) log("info", `${logPrefix} 集数自动对齐 (${secSource}): Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`);
@@ -1807,7 +1839,7 @@ async function processMergeTask(params) {
derivedAnime.bangumiId = String(derivedAnime.animeId);
let mergedCount = 0;
const redundantS = identifyRedundantTitle(cachedMatch.links, cachedMatch.animeTitle, secSource);
const redundantS = identifyRedundantTitle(derivedMatch.links, derivedMatch.animeTitle, secSource);
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
const pIndex = k + offset;
@@ -1818,11 +1850,6 @@ async function processMergeTask(params) {
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;
@@ -1938,9 +1965,6 @@ async function processMergeTask(params) {
derivedAnime.links = [...normals, ...sinkers];
log("info", `${logPrefix} [排序优化] 立即执行番外沉底: 移动了 ${sinkers.length} 个番外集到末尾`);
}
if (!collectionAnimeIds.has(match.animeId)) groupConsumedIds.add(match.animeId);
else log("info", `${logPrefix} 合集保留: [${secSource}] ${logTitleB} 是合集,保留以供同组复用。`);
globalConsumedIds.add(match.animeId);
hasMergedAny = true;
actualMergedSources.push(secSource);
contentSignatureParts.push(match.animeId);
@@ -1953,14 +1977,25 @@ async function processMergeTask(params) {
const signature = contentSignatureParts.join('|');
if (generatedSignatures.has(signature)) {
log("info", `${logPrefix} 检测到重复的合并结果 (Signature: ${signature}),已自动隐去冗余条目。`);
return derivedAnime;
return null;
}
generatedSignatures.add(signature);
for (let i = 1; i < contentSignatureParts.length; i++) {
const secId = contentSignatureParts[i];
if (!collectionAnimeIds.has(secId)) {
groupConsumedIds.add(secId);
} else {
log("info", `${logPrefix} 合集保留: ID [${secId}] 是合集,保留以供同组复用。`);
}
globalConsumedIds.add(secId);
}
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;
}
@@ -2176,14 +2211,20 @@ export async function applyMergeLogic(curAnimes) {
const groupFingerprint = fullPriorityList.join('&');
const groupConsumedIds = new Set();
// 通用排序函数:媒体类型 (TV>Movie) > 季度编号 ASC (S1->S2) > 源优先级 ASC
// 通用排序函数:源优先级 ASC > 媒体类型 (TV>Movie) > 季度编号 ASC (S1->S2)
const sortCandidates = (list, phaseName) => {
if (!list || list.length < 2) return list;
log("info", `[Merge-Check] [Sort] ${phaseName} 排序前首个元素: ${list[0].animeTitle}`);
list.sort((a, b) => {
// 优先级 1: 媒体类型 (确保 TV 季度先于 电影/OVA/SP 处理)
// 优先级 1: 源优先级 ASC (依据配置文件定义的源顺序,主源总是先于副源执行)
// 加上 ?? 99 防止某些未在 map 中的源报错
const pA = sourcePriorityMap.get(a.source) ?? 99;
const pB = sourcePriorityMap.get(b.source) ?? 99;
if (pA !== pB) return pA - pB;
// 优先级 2: 媒体类型 (确保同源内 TV 季度先于 电影/OVA/SP 处理)
// 1 = High Priority (TV/Seasonal), 2 = Low Priority (Movie/Non-seasonal)
const getMediaTypePriority = (anime) => {
const markers = extractSeasonMarkers(anime.animeTitle, anime.typeDescription);
@@ -2201,22 +2242,19 @@ export async function applyMergeLogic(curAnimes) {
const typeB = getMediaTypePriority(b);
if (typeA !== typeB) return typeA - typeB;
// 优先级 2: 季度编号 ASC (确保合集进度记录按顺序产生,S1 先于 S2)
// 优先级 3: 季度编号 ASC (确保同源、同类型内,按 S1, S2, S3 顺序执行)
const sA = getSeasonNumber(a.animeTitle, a.typeDescription) || 1;
const sB = getSeasonNumber(b.animeTitle, b.typeDescription) || 1;
if (sA !== sB) return sA - sB;
// 优先级 3: 源优先级 ASC (依据配置文件定义的源顺序)
const pA = sourcePriorityMap.get(a.source);
const pB = sourcePriorityMap.get(b.source);
return pA - pB;
return sA - sB;
});
// 优化了调试日志:加入优先级展示,例如 [P0] [S2] [dandan] ...
const debugOrder = list.map(a => {
const sNum = getSeasonNumber(a.animeTitle, a.typeDescription) || 1;
// 调试辅助:如果是电影类型,标记为 Movie 以便区分
const typeLabel = (extractSeasonMarkers(a.animeTitle, a.typeDescription).has('MOVIE') || getStrictMediaType(a.animeTitle, a.typeDescription) === 'MOVIE') ? 'Movie' : `S${sNum}`;
return `[${typeLabel}] [${a.source}] ${a.animeTitle}`;
const pLevel = sourcePriorityMap.get(a.source) ?? '?';
return `[P${pLevel}] [${typeLabel}] [${a.source}] ${a.animeTitle}`;
});
log("info", `[Merge-Check] [Sort] ${phaseName} 执行顺序:\n ${debugOrder.join('\n ')}`);
return list;
@@ -2227,14 +2265,14 @@ export async function applyMergeLogic(curAnimes) {
// 注意:此处策略稍微放宽,允许列表中所有 CN 源作为发起方尝试匹配,只要它们还没被消费
const cnCandidates = [];
fullPriorityList.forEach(source => {
const items = curAnimes.filter(a => a.source === source && !globalConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
const items = curAnimes.filter(a => a.source === source && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
items.forEach(item => cnCandidates.push(item));
});
// 检查副源池中是否有 CN 资源,如果没有则跳过 Phase 1
let hasCnInSecondaries = false;
for (const secSrc of fullPriorityList) {
if (curAnimes.some(a => a.source === secSrc && !globalConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN')) {
if (curAnimes.some(a => a.source === secSrc && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN')) {
hasCnInSecondaries = true;
break;
}
@@ -2246,7 +2284,6 @@ export async function applyMergeLogic(curAnimes) {
for (const pAnime of cnCandidates) {
if (groupConsumedIds.has(pAnime.animeId)) continue;
if (globalConsumedIds.has(pAnime.animeId)) continue;
const currentPriorityIdx = sourcePriorityMap.get(pAnime.source);
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
@@ -2272,7 +2309,7 @@ export async function applyMergeLogic(curAnimes) {
// 从第二个源开始扫描,因为主源已经在 Phase 1 尝试过了
for (let i = 1; i < fullPriorityList.length; i++) {
const source = fullPriorityList[i];
const items = curAnimes.filter(a => a.source === source && !globalConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
const items = curAnimes.filter(a => a.source === source && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
items.forEach(item => secondaryCnCandidates.push(item));
}
@@ -2282,7 +2319,6 @@ export async function applyMergeLogic(curAnimes) {
for (const tAnime of secondaryCnCandidates) {
if (groupConsumedIds.has(tAnime.animeId)) continue;
if (globalConsumedIds.has(tAnime.animeId)) continue;
const currentPriorityIdx = sourcePriorityMap.get(tAnime.source);
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
@@ -2305,7 +2341,7 @@ export async function applyMergeLogic(curAnimes) {
// 处理剩余的所有资源(包含非 CN 资源以及 Phase 1/1.5 未匹配的 CN 资源)
const remainingCandidates = [];
fullPriorityList.forEach(source => {
const items = curAnimes.filter(a => a.source === source && !globalConsumedIds.has(a.animeId));
const items = curAnimes.filter(a => a.source === source && !groupConsumedIds.has(a.animeId));
items.forEach(item => remainingCandidates.push(item));
});
@@ -2315,7 +2351,6 @@ export async function applyMergeLogic(curAnimes) {
for (const pAnime of remainingCandidates) {
if (groupConsumedIds.has(pAnime.animeId)) continue;
if (globalConsumedIds.has(pAnime.animeId)) continue;
const currentPriorityIdx = sourcePriorityMap.get(pAnime.source);
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
@@ -2354,6 +2389,7 @@ export async function applyMergeLogic(curAnimes) {
}
}
// 将所有合法衍生出来的合并对象推入主列表
if (newMergedAnimes.length > 0) {
for (const anime of newMergedAnimes) addAnime(anime);
curAnimes.unshift(...newMergedAnimes);
@@ -2366,6 +2402,7 @@ export async function applyMergeLogic(curAnimes) {
}
}
// 最终清理:移除已被任意一组所消费的单源原始资源
for (let i = curAnimes.length - 1; i >= 0; i--) {
const item = curAnimes[i];
if (item._isMerged || globalConsumedIds.has(item.animeId)) curAnimes.splice(i, 1);