fix: 修复匹配与过滤误读全局缓存 (#257)
- 复现:全局 animes 被旧详情污染时,/api/v2/match 可能把 E42 错配到第45集;开启剧集过滤时,同 animeId 跨源候选也可能被误过滤 - 修复:匹配、bangumi 解析、剧集过滤统一优先使用当前请求详情快照,并补充回归测试
This commit is contained in:
1 parent
a5fb406596
commit
ba88bc03a5
3 files changed
+197
-23
No files matched your search
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user