From 2dac103629d32fc5a7a4548fcc4e0ccb66481b64 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:58:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=88=E5=B9=B6=E6=BA=90?= =?UTF-8?q?=E6=97=B6=E5=90=8C=E4=B8=80=E4=B8=AA=E6=BA=90=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E4=B8=8D=E5=90=8C=E7=BB=84=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=20(#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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:去除多余空格 --- danmu_api/sources/bahamut.js | 16 +++- danmu_api/sources/bilibili.js | 21 +++- danmu_api/utils/common-util.js | 12 +-- danmu_api/utils/merge-util.js | 169 ++++++++++++++++++++------------- 4 files changed, 141 insertions(+), 77 deletions(-) diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index e69e0fd..0f4bc93 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -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 })); } } diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 197576c..5c7b817 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -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 = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" }; 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); diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index aa0b07d..c134926 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -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; } diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index e339cb2..9c0121e 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -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);