From e8f22b621f6b68d73764ef21760c65bbd1aeb509 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:56:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B7=A8=E5=AD=A3=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E6=8C=89=E5=B9=B3=E5=8F=B0=E6=BB=A1=E8=B6=B3=E5=BA=A6=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=BA=90=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=81=B6=E6=80=A7?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AF=B7=E6=B1=82=E9=97=AE=E9=A2=98=20(#408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 跨季扩展按平台满足度过滤源并修复恶性重复请求问题 checkEpisodeSatisfied不再在首个不满足平台处break,改为遍历所有平台 通过新参数unsatisfiedOut记录不满足的平台集合。跨季扩展据此仅处理不满足 平台对应源,已满足者直接跳过不再被牵连重复调用handleAnimes。 跨季执行按源分组并发:源间Promise.all并行,同源内各季顺序执行确保 模块级缓存传播。源处理顺序遵循sourceOrderArr。 titleMatches新增threshold参数(默认0.8),maxSeason传入0.6放宽相似度 阈值使与查询翻译差异较大的结果仍能通过匹配参与季号提取。 * fix: 修复跨季场景下身份平台名与优选平台名不一致时的匹配失效 跨季分支 (PR #408) 的 checkEpisodeSatisfied 与 spillover 评分在判断某源是否为目标平台提供数据时,仅依据番剧身份(标题 from 段或 source),未并入命中集所挂的平台标签。当源身份名与配置平台名不一致、但集上挂有目标平台标签时(如 tencent 源、集标题挂 qq),该源被误判为「目标平台无数据」而跳过,导致配置 qq 却无法命中 tencent 源。 现于两处评分前,将候选平台由「身份标签」扩展为「身份标签与所挂集标签去重合并」(与 matchAniAndEp 的 1421 修复口径一致),使身份名(如 tencent)与优选平台名(如 qq)不一致、但集上挂有该标签的源也能被正确识别。该加性改动不影响既有的合并源优选评分(已以 21 例用例验证,含 tencent/qq 受影响案例与正常单源、身份命中、无平台、去重等不受影响案例)。 * fix: 修复未满足平台集合命名空间错配导致跨季扩展被跳过 问题:checkEpisodeSatisfied 在目标平台未满足时向 unsatisfiedOut 写入规范平台名(tPlat),而跨季扩展源过滤分支以源名(source)查询该集合,命名空间不一致使 PLATFORM_ORDER 模式下跨季扩展被静默整体跳过。 修复:在写入端就近收集提供该平台数据的 anime.source(providedSources),未满足时写入源名而非规范名,复用既有候选平台判定(actualPlatform),无新增映射表;仅当某 tPlat 子遍历确有数据且未满足时才写入对应源名,无关源不会被污染。 验证:新增 cross-season-unsatisfied-test.mjs 覆盖修复前后对比(tencent/qq、iqiyi/qiyi 受影响案例)、常规流程(首季命中/_any_/源名即规范名)与边界(多平台仅未满足源扩展、无关源不污染集合、主返回值契约不变)共 36 项全通过;既有跨季平台匹配测试 21/21 不受影响。 --- danmu_api/apis/dandan-api.js | 61 ++++++++++++++++++++++++---------- danmu_api/utils/common-util.js | 4 +-- 2 files changed, 46 insertions(+), 19 deletions(-) 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; }); // 年份噪音兜底:前序策略均未命中时,尝试去年份后再次包含匹配