fix: 跨季扩展按平台满足度过滤源并修复恶性重复请求问题 (#408)

* 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 不受影响。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-08-01 12:56:58 +08:00
1 parent f19a5d9c4c
commit e8f22b621f
2 files changed
+46 -19

No files matched your search

+44 -17
View File
@@ -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
};
}
}
+2 -2
View File
@@ -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;
});
// 年份噪音兜底:前序策略均未命中时,尝试去年份后再次包含匹配