diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 73968cd..142e3aa 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -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`); diff --git a/danmu_api/utils/cache-util.js b/danmu_api/utils/cache-util.js index 0d86caf..aae3488 100644 --- a/danmu_api/utils/cache-util.js +++ b/danmu_api/utils/cache-util.js @@ -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; } } -} \ No newline at end of file +} diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index f080eb0..cd0574d 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -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',