fix: 修复匹配与过滤误读全局缓存 (#257)

- 复现:全局 animes 被旧详情污染时,/api/v2/match 可能把 E42 错配到第45集;开启剧集过滤时,同 animeId 跨源候选也可能被误过滤
- 修复:匹配、bangumi 解析、剧集过滤统一优先使用当前请求详情快照,并补充回归测试
This commit is contained in:
Wo254992 authored and GitHub committed 2026-03-27 23:46:54 +08:00
1 parent a5fb406596
commit ba88bc03a5
3 files changed
+197 -23

No files matched your search

+43 -20
View File
@@ -6,7 +6,7 @@ import { setRedisKey, updateRedisCaches } from "../utils/redis-util.js";
import { setLocalRedisKey, updateLocalRedisCaches } from "../utils/local-redis-util.js";
import {
setCommentCache, addAnime, findAnimeIdByCommentId, findTitleById, findUrlById, getCommentCache, getPreferAnimeId,
getSearchCache, removeEarliestAnime, resolveAnimeById, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile,
getSearchCache, removeEarliestAnime, resolveAnimeById, resolveAnimeByIdFromDetailStore, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile,
updateLocalCaches, setLastSearch, getLastSearch, findAnimeTitleById, findIndexById
} from "../utils/cache-util.js";
import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js";
@@ -455,7 +455,11 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
continue; // 跳过该动漫
}
const animeData = globals.animes.find(a => a.animeId === anime.animeId);
const animeData =
resolveAnimeByIdFromDetailStore(anime?.bangumiId, requestAnimeDetailsMap, anime?.source) ||
resolveAnimeByIdFromDetailStore(anime?.animeId, requestAnimeDetailsMap, anime?.source) ||
resolveAnimeById(anime?.bangumiId, requestAnimeDetailsMap, anime?.source) ||
resolveAnimeById(anime?.animeId, requestAnimeDetailsMap, anime?.source);
if (animeData && animeData.links) {
let episodesList = animeData.links.map((link, index) => ({
episodeId: link.id,
@@ -607,7 +611,7 @@ function findEpisodeByNumber(filteredEpisodes, targetEpisode, platform = null) {
return null;
}
async function matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId) {
async function matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId, detailStore = null) {
const aiBaseUrl = globals.aiBaseUrl;
const aiModel = globals.aiModel;
const aiApiKey = globals.aiApiKey;
@@ -677,9 +681,10 @@ async function matchAniAndEpByAi(season, episode, year, searchData, title, req,
return { resEpisode: null, resAnime: null };
}
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${selectedAnime.animeId}`));
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
const bangumiData = await bangumiRes.json();
const bangumiData = getBangumiDataForMatch(selectedAnime, detailStore);
if (!bangumiData?.success || !bangumiData?.bangumi?.episodes) {
return { resEpisode: null, resAnime: null };
}
let filteredEpisode = null;
@@ -708,6 +713,19 @@ async function matchAniAndEpByAi(season, episode, year, searchData, title, req,
}
}
function getBangumiDataForMatch(anime, detailStore = null) {
const detailAnime =
resolveAnimeByIdFromDetailStore(anime?.bangumiId, detailStore, anime?.source) ||
resolveAnimeByIdFromDetailStore(anime?.animeId, detailStore, anime?.source);
if (!detailAnime) {
log("warn", `[matchAnime] Missing request detail snapshot for anime ${anime?.animeId ?? anime?.bangumiId}`);
return null;
}
return buildBangumiData(detailAnime, anime?.bangumiId || anime?.animeId || "");
}
function computeTargetEpisode(offsets, season, episode, filteredEpisodes, targetEpisode) {
const seasonKey = String(season);
const match = offsets[seasonKey].match(/^([^:]+):(.+)$/);
@@ -725,7 +743,7 @@ function computeTargetEpisode(offsets, season, episode, filteredEpisodes, target
return targetEpisode;
}
async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets) {
async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets, detailStore = null) {
// 定义最佳匹配结果容器
let bestRes = {
anime: null,
@@ -784,9 +802,10 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
if (!isMatch) continue;
// 2. 获取剧集详情 (无条件获取,确保数据完整性)
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`));
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
const bangumiData = await bangumiRes.json();
const bangumiData = getBangumiDataForMatch(anime, detailStore);
if (!bangumiData?.success || !bangumiData?.bangumi?.episodes) {
continue;
}
// 输出匹配分数及原始数据日志
log("info", "判断剧集", `Anime: ${anime.animeTitle}`);
@@ -866,7 +885,7 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
return { resEpisode: bestRes.episode, resAnime: bestRes.anime };
}
async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets) {
async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets, detailStore = null) {
for (const anime of searchData.animes) {
// 年份匹配优先(如果提供了年份)
if (year && !matchYear(anime, year)) {
@@ -874,9 +893,10 @@ async function fallbackMatchAniAndEp(searchData, req, season, episode, year, res
continue;
}
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`));
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
const bangumiData = await bangumiRes.json();
const bangumiData = getBangumiDataForMatch(anime, detailStore);
if (!bangumiData?.success || !bangumiData?.bangumi?.episodes) {
continue;
}
log("info", bangumiData);
if (season && episode) {
// 过滤集标题正则条件的 episode
@@ -1041,8 +1061,9 @@ export async function matchAnime(url, req, clientIp) {
const [preferAnimeId, preferSource, offsets] = getPreferAnimeId(title, season);
log("info", `prefer animeId: ${preferAnimeId} from ${preferSource}`);
const requestAnimeDetailsMap = new Map();
let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}`));
const searchRes = await searchAnime(originSearchUrl, preferAnimeId, preferSource);
const searchRes = await searchAnime(originSearchUrl, preferAnimeId, preferSource, requestAnimeDetailsMap);
const searchData = await searchRes.json();
log("info", `searchData: ${searchData.animes}`);
@@ -1056,7 +1077,7 @@ export async function matchAnime(url, req, clientIp) {
log("info", `Preferred platform: ${preferredPlatform || 'none'}`);
// 尝试使用AI进行匹配
const aiMatchResult = await matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId);
const aiMatchResult = await matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId, requestAnimeDetailsMap);
if (aiMatchResult.resAnime) {
resAnime = aiMatchResult.resAnime;
resEpisode = aiMatchResult.resEpisode;
@@ -1064,7 +1085,7 @@ export async function matchAnime(url, req, clientIp) {
} else {
// AI匹配失败或未配置,使用传统匹配方式
for (const platform of dynamicPlatformOrder) {
const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets);
const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets, requestAnimeDetailsMap);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
@@ -1076,7 +1097,7 @@ export async function matchAnime(url, req, clientIp) {
// 如果都没有找到则返回第一个满足剧集数的剧集
if (!resAnime) {
const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets);
const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets, requestAnimeDetailsMap);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
}
@@ -1231,9 +1252,11 @@ export async function searchEpisodes(url) {
}
// Extracted function for GET /api/v2/bangumi/:animeId
export async function getBangumi(path) {
export async function getBangumi(path, detailStore = null, source = null) {
const idParam = path.split("/").pop();
const anime = resolveAnimeById(idParam);
const anime =
resolveAnimeByIdFromDetailStore(idParam, detailStore, source) ||
resolveAnimeById(idParam);
if (!anime) {
log("error", `Anime with ID ${idParam} not found`);
+6 -1
View File
@@ -165,6 +165,11 @@ export function resolveAnimeById(id, detailStore = null, source = null) {
return findAnimeByIdInIterator(iterateSearchCacheDetails(), idStr, source);
}
export function resolveAnimeByIdFromDetailStore(id, detailStore = null, source = null) {
const idStr = String(id);
return findAnimeByIdInIterator(iterateDetailStore(detailStore), idStr, source);
}
export function resolveEpisodeContextById(id, detailStore = null) {
const commentId = Number(id);
if (!Number.isFinite(commentId)) {
@@ -751,4 +756,4 @@ export async function judgeLocalCacheValid(urlPath, deployPlatform) {
globals.localCacheValid = false;
}
}
}
}
+148 -2
View File
@@ -5,7 +5,7 @@ dotenv.config();
import test from 'node:test';
import assert from 'node:assert';
import { handleRequest } from './worker.js';
import { extractTitleSeasonEpisode, getBangumi, getComment, searchAnime } from "./apis/dandan-api.js";
import { extractTitleSeasonEpisode, getBangumi, getComment, matchAnime, searchAnime } from "./apis/dandan-api.js";
import { getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry } from "./utils/redis-util.js";
import { getLocalRedisKey, setLocalRedisKey, setLocalRedisKeyWithExpiry } from "./utils/local-redis-util.js";
import { getImdbepisodes } from "./utils/imdb-util.js";
@@ -33,7 +33,7 @@ import { NetlifyHandler } from "./configs/handlers/netlify-handler.js";
import { CloudflareHandler } from "./configs/handlers/cloudflare-handler.js";
import { EdgeoneHandler } from "./configs/handlers/edgeone-handler.js";
import { Globals } from "./configs/globals.js";
import { addEpisode } from "./utils/cache-util.js";
import { addAnime, addEpisode } from "./utils/cache-util.js";
import { Segment, SegmentListResponse } from "./models/dandan-model.js"
// Mock Request class for testing
@@ -617,6 +617,152 @@ test('worker.js API endpoints', async (t) => {
delete Globals.requestAnimeDetailsMap;
}
});
await t.test('POST /api/v2/match should ignore polluted global anime details and use current search snapshot', async () => {
resetSearchState();
const correctLinks = Array.from({ length: 50 }, (_, index) => ({
id: 35001 + index,
url: `https://www.iqiyi.com/v_match_correct_${index + 1}.html`,
title: `【qiyi】 太平年第${index + 1}集`
}));
const cachedAnime = {
animeId: 700002,
bangumiId: "700002",
animeTitle: "太平年(2024)【TV】from iqiyi",
type: "tvseries",
typeDescription: "TV",
imageUrl: "https://example.com/tp.jpg",
startDate: "2024-01-01T00:00:00.000Z",
episodeCount: 50,
rating: 0,
isFavorited: true,
source: "iqiyi",
links: correctLinks
};
const pollutedAnime = {
...cachedAnime,
links: correctLinks.map(link => ({ ...link }))
};
pollutedAnime.links[41] = {
id: 35999,
url: "https://www.iqiyi.com/v_match_polluted_45.html",
title: "【qiyi】 太平年第45集 金陵落日"
};
Globals.searchCache.set("太平年", {
results: [createSearchResult(cachedAnime)],
details: [cachedAnime],
timestamp: Date.now()
});
Globals.animes = [pollutedAnime];
const req = {
url: urlPrefix + "/api/v2/match",
async json() {
return {
fileName: "太平年 S01E42"
};
}
};
const res = await matchAnime(new URL(req.url), req, "127.0.0.1");
const body = await parseResponse(res);
assert.equal(res.status, 200);
assert.equal(body.success, true);
assert.equal(body.isMatched, true);
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].episodeId, cachedAnime.links[41].id);
assert.equal(body.matches[0].episodeTitle, cachedAnime.links[41].title);
});
await t.test('GET /api/v2/search/anime should filter by request snapshot instead of collided runtime animeId state', async () => {
resetSearchState();
const originalTencentSearch = TencentSource.prototype.search;
const originalTencentHandleAnimes = TencentSource.prototype.handleAnimes;
const originalIqiyiSearch = IqiyiSource.prototype.search;
const originalIqiyiHandleAnimes = IqiyiSource.prototype.handleAnimes;
const originalSourceOrderArr = Array.isArray(Globals.envs.sourceOrderArr) ? [...Globals.envs.sourceOrderArr] : Globals.envs.sourceOrderArr;
const originalEnableAnimeEpisodeFilter = Globals.envs.enableAnimeEpisodeFilter;
const originalEpisodeTitleFilter = Globals.envs.episodeTitleFilter;
const originalAnimeTitleFilter = Globals.envs.animeTitleFilter;
const sharedAnimeId = 880001;
const tencentAnime = {
animeId: sharedAnimeId,
bangumiId: "tx-880001",
animeTitle: "同ID跨源番剧",
type: "tvseries",
typeDescription: "TV",
imageUrl: "",
startDate: "2024-01-01T00:00:00.000Z",
episodeCount: 1,
rating: 0,
isFavorited: false,
source: "tencent",
links: [
{ url: "https://v.qq.com/x/cover/collision/ep1.html", title: "【qq】 正片第1集" }
]
};
const iqiyiAnime = {
animeId: sharedAnimeId,
bangumiId: "iqiyi-880001",
animeTitle: "同ID跨源番剧",
type: "tvseries",
typeDescription: "TV",
imageUrl: "",
startDate: "2024-01-01T00:00:00.000Z",
episodeCount: 1,
rating: 0,
isFavorited: false,
source: "iqiyi",
links: [
{ url: "https://www.iqiyi.com/v_collision_extra.html", title: "【qiyi】 花絮" }
]
};
Globals.envs.sourceOrderArr = ["tencent", "iqiyi"];
Globals.envs.enableAnimeEpisodeFilter = true;
Globals.envs.episodeTitleFilter = /花絮/;
Globals.envs.animeTitleFilter = null;
TencentSource.prototype.search = async () => [createSearchResult(tencentAnime)];
TencentSource.prototype.handleAnimes = async (_results, _queryTitle, curAnimes, detailStore) => {
curAnimes.push(createSearchResult(tencentAnime));
addAnime(tencentAnime, detailStore);
};
IqiyiSource.prototype.search = async () => [createSearchResult(iqiyiAnime)];
IqiyiSource.prototype.handleAnimes = async (_results, _queryTitle, curAnimes, detailStore) => {
curAnimes.push(createSearchResult(iqiyiAnime));
addAnime(iqiyiAnime, detailStore);
};
try {
const req = new MockRequest(urlPrefix + "/api/v2/search/anime?keyword=" + encodeURIComponent("同ID跨源番剧"), { method: "GET" });
const res = await searchAnime(new URL(req.url), null, null, new Map());
const body = await parseResponse(res);
assert.equal(res.status, 200);
assert.equal(body.success, true);
assert.equal(body.animes.length, 1);
assert.equal(body.animes[0].animeId, tencentAnime.animeId);
assert.equal(body.animes[0].source, tencentAnime.source);
assert.equal(body.animes[0].animeTitle, tencentAnime.animeTitle);
} finally {
TencentSource.prototype.search = originalTencentSearch;
TencentSource.prototype.handleAnimes = originalTencentHandleAnimes;
IqiyiSource.prototype.search = originalIqiyiSearch;
IqiyiSource.prototype.handleAnimes = originalIqiyiHandleAnimes;
Globals.envs.sourceOrderArr = Array.isArray(originalSourceOrderArr) ? [...originalSourceOrderArr] : originalSourceOrderArr;
Globals.envs.enableAnimeEpisodeFilter = originalEnableAnimeEpisodeFilter;
Globals.envs.episodeTitleFilter = originalEpisodeTitleFilter;
Globals.envs.animeTitleFilter = originalAnimeTitleFilter;
}
});
// await t.test('Test ai cilent', async () => {
// const ai = new AIClient({
// apiKey: 'xxxxxxxxxxxxxxxxxxxxx',