diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 810c5df..4d261b8 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -239,7 +239,7 @@ export function matchSeason(anime, queryTitle, season) { * @param {string|null} targetPlatform 期望优先验证的目标平台 * @returns {boolean} 是否满足需求 */ -function checkEpisodeSatisfied(animesList, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform) { +function checkEpisodeSatisfied(animesList, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform, unsatisfiedOut = null) { if (queryEpisode === null || querySeason === null) return true; let targetPlatforms = []; @@ -258,17 +258,29 @@ function checkEpisodeSatisfied(animesList, querySeason, queryEpisode, requestAni let isEpisodeSatisfied = false; const seasonCapacities = new Map(); let platformHasData = false; + const providedSources = new Set(); for (const anime of animesList) { - const actualPlatform = extractPlatformFromTitle(anime.animeTitle) || anime.source; + // 候选平台由番剧身份标签(标题 from 段或 source)与所挂集标签共同决定,使身份名(如tencent)与优选平台名(如qq)不一致但集上挂有目标标签的源也能被正确识别为该平台有数据 + const identityPlatform = extractPlatformFromTitle(anime.animeTitle) || anime.source; + const bData = getBangumiDataForMatch(anime, requestAnimeDetailsMap); + const epPlatforms = new Set(); + if (bData?.success && bData.bangumi?.episodes) { + for (const ep of bData.bangumi.episodes) { + const epPlat = extractEpisodeTitle(ep.episodeTitle); + if (epPlat) epPlatforms.add(epPlat); + } + } + const actualPlatform = [...new Set([identityPlatform, ...epPlatforms].filter(Boolean) + .flatMap(p => p.split(/[&&]/).map(s => s.trim().toLowerCase())).filter(s => s))].join('&'); if (tPlat !== '_any_' && getPlatformMatchScore(actualPlatform, tPlat) === 0) { continue; } platformHasData = true; + providedSources.add(anime.source); - const bData = getBangumiDataForMatch(anime, requestAnimeDetailsMap); if (bData?.success && bData.bangumi?.episodes) { const validEps = bData.bangumi.episodes.filter(ep => !globals.episodeTitleFilter.test(ep.episodeTitle)); const filtered = filterSameEpisodeTitle(validEps); @@ -311,7 +323,9 @@ function checkEpisodeSatisfied(animesList, querySeason, queryEpisode, requestAni } if (totalValidEpisodes < queryEpisode) { allSatisfied = false; - break; + if (unsatisfiedOut) { + for (const s of providedSources) unsatisfiedOut.add(s); + } } } } @@ -796,9 +810,10 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null } // 判断当前获取的季度是否已包含用户指定的集数 - const isEpisodeSatisfied = checkEpisodeSatisfied(curAnimes, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform); + const unsatisfiedPlatforms = new Set(); + const isEpisodeSatisfied = checkEpisodeSatisfied(curAnimes, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform, unsatisfiedPlatforms); - // 若未包含且用户指定了季度,推导最大季并并行映射 S2、S3 等结果,合并同一次搜索下的后续季以辅助跨季匹配 + // 若未包含且用户指定了季度,推导最大季并扩展至后续季以辅助跨季匹配 if (!isEpisodeSatisfied && querySeason !== null) { let maxSeason = querySeason; for (const source of globals.sourceOrderArr) { @@ -808,9 +823,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null const list = (item && Array.isArray(item.list)) ? item.list : [item]; for (const a of list) { if (!a) continue; - // 仅从与查询相关的条目中提取季号,避免无关源的搜索结果污染 maxSeason + // 用titleMatches过滤与查询无关的条目,仅从相关结果中提取季号 const testTitle = a.animeTitle || a.title || a.name || a.name_cn || ""; - if (testTitle && !titleMatches(testTitle, queryTitle, null, true)) continue; + if (testTitle && !titleMatches(testTitle, queryTitle, null, true, 0.6)) continue; const s = extractSeasonNumberFromAnimeTitle(testTitle).season; if (s !== null && s > maxSeason) maxSeason = s; } @@ -821,18 +836,25 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null if (maxSeason > querySeason) { log("info", `[system] [LogVar-API] Episode ${queryEpisode} not satisfied in Season ${querySeason}. Parallel mapping to S${querySeason + 1}~S${maxSeason}...`); const expandPromises = []; - for (let s = querySeason + 1; s <= maxSeason; s++) { + for (const source of globals.sourceOrderArr) { + if (!resultData[source]) continue; + // 在PLATFORM_ORDER模式下,跳过已满足平台的对应源 + if (targetPlatform && !unsatisfiedPlatforms.has(source)) continue; + // 源间并发、源内顺序,防止同源并发导致模块级缓存竞态 expandPromises.push((async () => { - const seasonAnimes = []; - await executeSourceHandlers(resultData, queryTitle, seasonAnimes, requestAnimeDetailsMap, s, preferAnimeId, preferSource); - // 缓存额外季度的结果 - if (seasonAnimes.length > 0) { - setSearchCache(`${queryTitle}_S${s}`, seasonAnimes.map(({ links, ...pureAnime }) => pureAnime), requestAnimeDetailsMap); + const sourceResults = []; + for (let s = querySeason + 1; s <= maxSeason; s++) { + const seasonAnimes = []; + await executeSourceHandlers({ [source]: resultData[source] }, queryTitle, seasonAnimes, requestAnimeDetailsMap, s, preferAnimeId, preferSource); + if (seasonAnimes.length > 0) { + setSearchCache(`${queryTitle}_S${s}`, seasonAnimes.map(({ links, ...pureAnime }) => pureAnime), requestAnimeDetailsMap); + } + sourceResults.push(seasonAnimes); } - return seasonAnimes; + return sourceResults; })()); } - const expandedResults = await Promise.all(expandPromises); + const expandedResults = (await Promise.all(expandPromises)).flat(); for (const res of expandedResults) { curAnimes.push(...res); } @@ -1456,10 +1478,15 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat if (!bestRes.episode && season && episode) { const spilloverRes = findCrossSeasonEpisodeMap(searchData, title, year, season, episode, platform, detailStore); if (spilloverRes.resEpisode) { + // 候选平台由番剧身份标签与命中集所挂平台标签共同决定,与 matchAniAndEp 评分口径保持一致 + const spillIdentity = extractPlatformFromTitle(spilloverRes.resAnime.animeTitle) || spilloverRes.resAnime.source; + const spillEpPlatform = spilloverRes.resEpisode ? extractEpisodeTitle(spilloverRes.resEpisode.episodeTitle) : null; + const spillCandidate = [...new Set([spillIdentity, spillEpPlatform].filter(Boolean) + .flatMap(p => p.split(/[&&]/).map(s => s.trim().toLowerCase())).filter(s => s))].join('&'); bestRes = { episode: spilloverRes.resEpisode, anime: spilloverRes.resAnime, - score: platform ? getPlatformMatchScore(extractPlatformFromTitle(spilloverRes.resAnime.animeTitle) || spilloverRes.resAnime.source, platform) : 1 + score: platform ? getPlatformMatchScore(spillCandidate, platform) : 1 }; } } diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index 98b3166..e1ecb7d 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -269,7 +269,7 @@ export function getExplicitSeasonNumber(text) { * @param {number|null} parsedSeason - 解析出的目标季度 * @returns {boolean} 是否匹配 */ -export function titleMatches(title, query, parsedSeason = null, forceNonStrict = false) { +export function titleMatches(title, query, parsedSeason = null, forceNonStrict = false, threshold = 0.8) { if (title == null || query == null) return false; const titleText = String(title); @@ -351,7 +351,7 @@ export function titleMatches(title, query, parsedSeason = null, forceNonStrict = } } - return (matchCount / kw.length) > 0.8; + return (matchCount / kw.length) > threshold; }); // 年份噪音兜底:前序策略均未命中时,尝试去年份后再次包含匹配