diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index dd81985..dddaab6 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -219,7 +219,9 @@ export function matchSeason(anime, queryTitle, season) { // Extracted function for GET /api/v2/search/anime export async function searchAnime(url, preferAnimeId = null, preferSource = null, detailStore = null) { let queryTitle = url.searchParams.get("keyword"); - log("info", `Search anime with keyword: ${queryTitle}`); + let querySeason = url.searchParams.get("season"); + querySeason = querySeason ? parseInt(querySeason, 10) : null; + log("info", `Search anime with keyword: ${queryTitle}, target season: ${querySeason}`); // 关键字为空直接返回,不用多余查询 if (queryTitle === "") { @@ -239,9 +241,10 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null } const requestAnimeDetailsMap = detailStore instanceof Map ? detailStore : new Map(); + const cacheKey = querySeason !== null ? `${queryTitle}_S${querySeason}` : queryTitle; // 检查搜索缓存 - const cachedResults = getSearchCache(queryTitle, requestAnimeDetailsMap); + const cachedResults = getSearchCache(cacheKey, requestAnimeDetailsMap); if (cachedResults !== null) { return jsonResponse({ errorCode: 0, @@ -317,11 +320,14 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null await updateLocalRedisCaches(); } + // 构造响应 DTO:剥离合并产生的 links,确保接口纯净 + const responseAnimes = curAnimes.map(({ links, ...pureAnime }) => pureAnime); + return jsonResponse({ errorCode: 0, success: true, errorMessage: "", - animes: curAnimes, + animes: responseAnimes, }); } @@ -376,73 +382,73 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null for (const key of globals.sourceOrderArr) { if (key === '360') { // 等待处理360来源 - await kan360Source.handleAnimes(animes360, queryTitle, curAnimes, requestAnimeDetailsMap); + await kan360Source.handleAnimes(animes360, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'vod') { // 等待处理Vod来源(遍历所有VOD服务器的结果) if (animesVodResults && Array.isArray(animesVodResults)) { for (const vodResult of animesVodResults) { if (vodResult && vodResult.list && vodResult.list.length > 0) { - await vodSource.handleAnimes(vodResult.list, queryTitle, curAnimes, vodResult.serverName, requestAnimeDetailsMap); + await vodSource.handleAnimes(vodResult.list, queryTitle, curAnimes, vodResult.serverName, requestAnimeDetailsMap, querySeason); } } } } else if (key === 'tmdb') { // 等待处理TMDB来源 - await tmdbSource.handleAnimes(animesTmdb, queryTitle, curAnimes, requestAnimeDetailsMap); + await tmdbSource.handleAnimes(animesTmdb, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'douban') { // 等待处理Douban来源 - await doubanSource.handleAnimes(animesDouban, queryTitle, curAnimes, requestAnimeDetailsMap); + await doubanSource.handleAnimes(animesDouban, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'renren') { // 等待处理Renren来源 - await renrenSource.handleAnimes(animesRenren, queryTitle, curAnimes, requestAnimeDetailsMap); + await renrenSource.handleAnimes(animesRenren, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'hanjutv') { // 等待处理Hanjutv来源 - await hanjutvSource.handleAnimes(animesHanjutv, queryTitle, curAnimes, requestAnimeDetailsMap); + await hanjutvSource.handleAnimes(animesHanjutv, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'bahamut') { // 等待处理Bahamut来源 - await bahamutSource.handleAnimes(animesBahamut, queryTitle, curAnimes, requestAnimeDetailsMap); + await bahamutSource.handleAnimes(animesBahamut, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'dandan') { // 等待处理弹弹play来源 - await dandanSource.handleAnimes(animesDandan, queryTitle, curAnimes, requestAnimeDetailsMap); + await dandanSource.handleAnimes(animesDandan, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'custom') { // 等待处理自定义弹幕源来源 await customSource.handleAnimes(animesCustom, queryTitle, curAnimes, requestAnimeDetailsMap); } else if (key === 'tencent') { // 等待处理Tencent来源 - await tencentSource.handleAnimes(animesTencent, queryTitle, curAnimes, requestAnimeDetailsMap); + await tencentSource.handleAnimes(animesTencent, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'youku') { // 等待处理Youku来源 - await youkuSource.handleAnimes(animesYouku, queryTitle, curAnimes, requestAnimeDetailsMap); + await youkuSource.handleAnimes(animesYouku, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'iqiyi') { // 等待处理iQiyi来源 - await iqiyiSource.handleAnimes(animesIqiyi, queryTitle, curAnimes, requestAnimeDetailsMap); + await iqiyiSource.handleAnimes(animesIqiyi, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'imgo') { // 等待处理Mango来源 - await mangoSource.handleAnimes(animesImgo, queryTitle, curAnimes, requestAnimeDetailsMap); + await mangoSource.handleAnimes(animesImgo, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'bilibili') { // 等待处理Bilibili来源 - await bilibiliSource.handleAnimes(animesBilibili, queryTitle, curAnimes, requestAnimeDetailsMap); + await bilibiliSource.handleAnimes(animesBilibili, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'migu') { // 等待处理Migu来源 - await miguSource.handleAnimes(animesMigu, queryTitle, curAnimes, requestAnimeDetailsMap); + await miguSource.handleAnimes(animesMigu, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'sohu') { // 等待处理Sohu来源 - await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes, requestAnimeDetailsMap); + await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'leshi') { // 等待处理Leshi来源 - await leshiSource.handleAnimes(animesLeshi, queryTitle, curAnimes, requestAnimeDetailsMap); + await leshiSource.handleAnimes(animesLeshi, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'xigua') { // 等待处理Xigua来源 - await xiguaSource.handleAnimes(animesXigua, queryTitle, curAnimes, requestAnimeDetailsMap); + await xiguaSource.handleAnimes(animesXigua, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'maiduidui') { // 等待处理Maiduidui来源 - await maiduiduiSource.handleAnimes(animesMaiduidui, queryTitle, curAnimes, requestAnimeDetailsMap); + await maiduiduiSource.handleAnimes(animesMaiduidui, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'aiyifan') { // 等待处理Aiyifan来源 - await aiyifanSource.handleAnimes(animesAiyifan, queryTitle, curAnimes, requestAnimeDetailsMap); + await aiyifanSource.handleAnimes(animesAiyifan, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } else if (key === 'animeko') { // 等待处理Animeko来源 - await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes, requestAnimeDetailsMap); + await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason); } } } catch (error) { @@ -509,16 +515,20 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null await updateLocalRedisCaches(); } + // 构造响应 DTO:剥离合并产生的 links,确保接口纯净 + const responseAnimes = curAnimes.map(({ links, ...pureAnime }) => pureAnime); + // 缓存搜索结果 - if (curAnimes.length > 0) { - setSearchCache(queryTitle, curAnimes, requestAnimeDetailsMap); + if (responseAnimes.length > 0) { + const cacheKey = querySeason !== null ? `${queryTitle}_S${querySeason}` : queryTitle; + setSearchCache(cacheKey, responseAnimes, requestAnimeDetailsMap); } return jsonResponse({ errorCode: 0, success: true, errorMessage: "", - animes: curAnimes, + animes: responseAnimes, }); } @@ -1090,7 +1100,7 @@ export async function matchAnime(url, req, clientIp) { log("info", `prefer animeId: ${preferAnimeId} from ${preferSource}`); const requestAnimeDetailsMap = new Map(); - let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}`)); + let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}&season=${season || ''}`)); const searchRes = await searchAnime(originSearchUrl, preferAnimeId, preferSource, requestAnimeDetailsMap); const searchData = await searchRes.json(); log("info", `searchData: ${searchData.animes}`); diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 71d9f35..be90bad 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.19.4', + VERSION: '1.19.5', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_RECORDS: 100, // 请求记录最大数量 diff --git a/danmu_api/sources/aiyifan.js b/danmu_api/sources/aiyifan.js index b76ded6..0a5922a 100644 --- a/danmu_api/sources/aiyifan.js +++ b/danmu_api/sources/aiyifan.js @@ -5,7 +5,7 @@ import { convertToAsciiSum } from "../utils/codec-util.js"; import { hexToInt } from "../utils/danmu-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { titleMatches } from "../utils/common-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { simplized } from "../utils/zh-util.js"; import { globals } from '../configs/globals.js'; import { AiyifanSigningProvider } from '../utils/aiyifan-util.js'; @@ -308,14 +308,14 @@ export default class AiyifanSource extends BaseSource { } /** - * 处理animes结果 - * @param {Array} sourceAnimes - 源动漫数据 - * @param {string} queryTitle - 查询标题 - * @param {Array} curAnimes - 当前动漫列表 - * @param {any} detailStore - 详情存储 - * @returns {Promise} 处理后的动漫列表 + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 */ - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; if (!sourceAnimes || !Array.isArray(sourceAnimes)) { @@ -323,9 +323,27 @@ export default class AiyifanSource extends BaseSource { return []; } - const processPromises = sourceAnimes - .filter(anime => titleMatches(anime.title, queryTitle)) - .map(async (anime) => { + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(anime => titleMatches(anime.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Aiyifan] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processPromises = filteredAnimes.map(async (anime) => { try { // 获取剧集列表 const eps = await this.getEpisodes(anime.mediaId); diff --git a/danmu_api/sources/animeko.js b/danmu_api/sources/animeko.js index 9932f74..610d15f 100644 --- a/danmu_api/sources/animeko.js +++ b/danmu_api/sources/animeko.js @@ -5,13 +5,19 @@ import { httpGet, httpPost } from "../utils/http-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized } from "../utils/zh-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; -import { titleMatches } from "../utils/common-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { searchBangumiData } from '../utils/bangumi-data-util.js'; // ===================== // 获取Animeko弹幕(https://github.com/open-ani/animeko) // ===================== +// 接口健康状态缓存 (全局共享,跨请求持久化,实现业务级智能路由) +// 链路层级规范: Danmu (GLOBAL -> CN) +const API_HEALTH = { + danmu: 'GLOBAL' +}; + /** * Animeko 源适配器 (基于 Bangumi API V0) * 提供深度元数据搜索、结果过滤及条目关系检测功能 @@ -333,7 +339,6 @@ export default class AnimekoSource extends BaseSource { }); // 1. 结构校验:确保 resp.data.data 存在且为数组 - // 对应您的 JSON: resp.data 存在,resp.data.data 是 [] (数组),校验通过 if (!resp || !resp.data || !Array.isArray(resp.data.data)) { if (offset === 0) { log("info", `[Animeko] Subject ${subjectId} 无剧集数据或响应异常`); @@ -344,7 +349,6 @@ export default class AnimekoSource extends BaseSource { const currentBatch = resp.data.data; // 2. 空数据校验:如果没有数据,停止 - // 对应您的 JSON: data 为 [],length 为 0,在此处 break 退出 if (currentBatch.length === 0) { break; } @@ -386,12 +390,14 @@ export default class AnimekoSource extends BaseSource { } /** - * 处理并存储番剧及剧集信息 - * @param {Array} sourceAnimes 搜索到的番剧列表 - * @param {string} queryTitle 原始查询标题 - * @param {Array} curAnimes 当前缓存的番剧列表 + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 */ - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; if (!sourceAnimes || !Array.isArray(sourceAnimes)) { @@ -399,7 +405,27 @@ export default class AnimekoSource extends BaseSource { return []; } - const processAnimekoAnimes = await Promise.all(sourceAnimes.map(async (anime) => { + let filteredAnimes = sourceAnimes; + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const titleToCheck = anime.name_cn || anime.name; + const s = extractSeasonNumberFromAnimeTitle(titleToCheck).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Animeko] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processAnimekoAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.id); let links = []; @@ -488,8 +514,11 @@ export default class AnimekoSource extends BaseSource { return []; } - const HOST_GLOBAL = "https://danmaku-global.myani.org"; - const HOST_CN = "https://danmaku-cn.myani.org"; + const endpoints = { + 'GLOBAL': "https://danmaku-global.myani.org", + 'CN': "https://danmaku-cn.myani.org" + }; + const tiers = ['GLOBAL', 'CN']; // 定义内部通用请求函数 const fetchDanmu = async (hostUrl) => { @@ -508,22 +537,35 @@ export default class AnimekoSource extends BaseSource { } }; - // 2. 优先尝试 Global 节点 - let danmuList = await fetchDanmu(HOST_GLOBAL); + let danmuList = null; + let currentTierIndex = tiers.indexOf(API_HEALTH.danmu); + if (currentTierIndex === -1) currentTierIndex = 0; - // 3. 如果失败,降级尝试 CN 节点 - if (!danmuList) { - log("info", `[Animeko] Global 节点获取失败/无数据,降级尝试 CN 节点... ID:${realId}`); - danmuList = await fetchDanmu(HOST_CN); + // 智能路由检测与降级回路 + for (let i = currentTierIndex; i < tiers.length; i++) { + const tier = tiers[i]; + const hostUrl = endpoints[tier]; + log("info", `[Animeko] 尝试使用 ${tier} 节点获取弹幕`); + + danmuList = await fetchDanmu(hostUrl); + + if (danmuList && Array.isArray(danmuList)) { + // 记录当前健康的节点层级 + if (API_HEALTH.danmu !== tier) { + log("info", `[Animeko] 弹幕域节点健康状态更新: ${API_HEALTH.danmu} -> ${tier}`); + API_HEALTH.danmu = tier; + } + log("info", `[Animeko] 成功获取弹幕,共 ${danmuList.length} 条 (${tier}节点)`); + return danmuList; + } else { + log("info", `[Animeko] ${tier} 节点获取失败/无数据,触发降级`); + } } - // 4. 返回结果或空数组 - if (danmuList) { - log("info", `[Animeko] 成功获取弹幕,共 ${danmuList.length} 条`); - return danmuList; - } + // 所有节点轮换完毕仍未获取到数据,重置健康状态 + log("info", `[Animeko] 弹幕域所有降级节点均失败,重置健康状态至 GLOBAL 节点`); + API_HEALTH.danmu = 'GLOBAL'; - log("error", "[Animeko] 所有节点尝试均失败,无法获取弹幕"); return []; } diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index 0f80bda..80f58ff 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -241,12 +241,19 @@ export default class BahamutSource extends BaseSource { } } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 使用正则判断原始搜索词是否包含日文平假名或片假名 const isJapaneseKeyword = /[\u3040-\u309F\u30A0-\u30FF]/.test(queryTitle); - queryTitle = traditionalized(queryTitle); // 巴哈姆特搜索辅助函数 @@ -337,23 +344,23 @@ export default class BahamutSource extends BaseSource { const cnAlias = filtered.length > 0 ? filtered[0]._tmdbCnAlias : null; smartTitleReplace(filtered, cnAlias); - // 提取搜索词中的明确季度信息 - const querySeason = getExplicitSeasonNumber(queryTitle); + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); // 初始列表预过滤机制:若用户指定了季度,优先检查初始结果中是否已包含匹配项 let matchedAnimes = filtered; - if (querySeason !== null) { + if (resolvedQuerySeason !== null) { const seasonFiltered = filtered.filter(anime => { const titleToCheck = anime._displayTitle || anime.title; const s = extractSeasonNumberFromAnimeTitle(titleToCheck).season; - return s === querySeason || (querySeason === 1 && s === null); + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); }); // 如果已命中目标,减少详情请求量 if (seasonFiltered.length > 0) { matchedAnimes = seasonFiltered; - log("info", `[Bahamut] 结果已命中目标季(第${querySeason}季),跳过非目标季相关请求`); + log("info", `[Bahamut] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); } } diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index f215336..0b4c29b 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -5,7 +5,7 @@ import { httpGet, httpGetWithStreamCheck } from "../utils/http-util.js"; import { parseDanmakuBase64, md5, convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { titleMatches } from "../utils/common-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; import { simplized } from "../utils/zh-util.js"; import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js"; @@ -553,7 +553,15 @@ export default class BilibiliSource extends BaseSource { return []; } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -577,10 +585,33 @@ export default class BilibiliSource extends BaseSource { const cnAlias = sourceAnimes.length > 0 ? sourceAnimes[0]._tmdbCnAlias : null; smartTitleReplace(sourceAnimes, cnAlias); - const processPromises = sourceAnimes - // 港澳台资源不做严格标题匹配,其他资源根据当前标题或别名池(已包含原标题和 org_title)验证查询匹配度 - .filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle) || (anime.aliases && anime.aliases.some(alias => titleMatches(alias, queryTitle)))) - .map(async (anime) => { + // 基础标题与季度匹配过滤 + // 港澳台资源不做严格标题匹配,其他资源根据当前标题或别名池(已包含原标题和 org_title)验证查询匹配度 + let filteredAnimes = sourceAnimes.filter(anime => + anime.isOversea || + titleMatches(anime.title, queryTitle, querySeason) || + (anime.aliases && anime.aliases.some(alias => titleMatches(alias, queryTitle, querySeason))) + ); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const titleToCheck = anime._displayTitle || anime.title; + const s = extractSeasonNumberFromAnimeTitle(titleToCheck).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Bilibili] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processPromises = filteredAnimes.map(async (anime) => { try { let links = []; diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index eb19f7d..7b651b4 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -290,8 +290,15 @@ export default class DandanSource extends BaseSource { return intersection / union; } - // 处理并转换番剧信息 - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -314,25 +321,25 @@ export default class DandanSource extends BaseSource { const existingIds = new Set(); const queue = []; - // 提取搜索词中的明确季度信息 - const querySeason = getExplicitSeasonNumber(queryTitle); + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); // 初始列表预过滤机制:若用户指定了季度,优先检查初始结果中是否已包含匹配项 let matchedAnimes = sourceAnimes; let isTargetFoundInInitial = false; - if (querySeason !== null) { + if (resolvedQuerySeason !== null) { const filtered = sourceAnimes.filter(anime => { const titleToCheck = anime._displayTitle || anime.animeTitle; const s = extractSeasonNumberFromAnimeTitle(titleToCheck).season; - return s === querySeason || (querySeason === 1 && s === null); + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); }); // 如果已命中目标,减少详情请求量 if (filtered.length > 0) { matchedAnimes = filtered; isTargetFoundInInitial = true; - log("info", `[Dandan] 结果已命中目标季(第${querySeason}季),跳过非目标季相关请求`); + log("info", `[Dandan] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); } } @@ -387,8 +394,7 @@ export default class DandanSource extends BaseSource { if (anime.isRelated || anime.isTmdbSource) { // 相关作品及TMDB原名搜索结果逻辑:仅执行单纯的季度过滤,跳过常规标题匹配,防止标题差异导致误判 - const querySeason = getExplicitSeasonNumber(queryTitle); - if (querySeason !== null) { + if (resolvedQuerySeason !== null) { let titleSeason = null; for (const t of allTitles) { if (!t) continue; @@ -398,9 +404,9 @@ export default class DandanSource extends BaseSource { break; } } - if (querySeason > 1) { - isMatch = (titleSeason || 1) === querySeason; - } else if (querySeason === 1) { + if (resolvedQuerySeason > 1) { + isMatch = (titleSeason || 1) === resolvedQuerySeason; + } else if (resolvedQuerySeason === 1) { isMatch = titleSeason === null || titleSeason === 1; } } else { @@ -408,7 +414,7 @@ export default class DandanSource extends BaseSource { } } else { // 初始数据源逻辑:执行严密的完整标题及季度双重校验 - isMatch = allTitles.some(t => t && titleMatches(t, queryTitle)); + isMatch = allTitles.some(t => t && titleMatches(t, queryTitle, resolvedQuerySeason)); } // 丢弃不符合拦截策略的条目,停止后续构建流程 diff --git a/danmu_api/sources/douban.js b/danmu_api/sources/douban.js index 8fd9d0f..dc2c9ac 100644 --- a/danmu_api/sources/douban.js +++ b/danmu_api/sources/douban.js @@ -1,6 +1,7 @@ import BaseSource from './base.js'; import { log } from "../utils/log-util.js"; import { getDoubanDetail, searchDoubanTitles, searchDoubanTitlesByPublic } from "../utils/douban-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; // ===================== // 获取豆瓣源播放链接 @@ -112,7 +113,15 @@ export default class DoubanSource extends BaseSource { async getEpisodes(id) {} - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const doubanAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -121,7 +130,31 @@ export default class DoubanSource extends BaseSource { return []; } - const processDoubanAnimes = await Promise.allSettled(sourceAnimes.map(async (anime) => { + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(anime => { + const titleToCheck = anime?.target?.title || ""; + return titleMatches(titleToCheck, queryTitle, querySeason); + }); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const titleToCheck = anime?.target?.title || ""; + const s = extractSeasonNumberFromAnimeTitle(titleToCheck).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Douban] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processDoubanAnimes = await Promise.allSettled(filteredAnimes.map(async (anime) => { try { if (anime?.layout !== "subject") return; const doubanId = anime.target_id; @@ -180,7 +213,7 @@ export default class DoubanSource extends BaseSource { if (cid) { tmpAnimes[0].provider = "tencent"; tmpAnimes[0].mediaId = cid; - await this.tencentSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore) + await this.tencentSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore, querySeason) } break; } @@ -189,7 +222,7 @@ export default class DoubanSource extends BaseSource { if (tvid) { tmpAnimes[0].provider = "iqiyi"; tmpAnimes[0].mediaId = anime?.type_name === '电影' ? `movie_${tvid}` : tvid; - await this.iqiyiSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore) + await this.iqiyiSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore, querySeason) } break; } @@ -198,7 +231,7 @@ export default class DoubanSource extends BaseSource { if (showId) { tmpAnimes[0].provider = "youku"; tmpAnimes[0].mediaId = showId; - await this.youkuSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore) + await this.youkuSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore, querySeason) } break; } @@ -207,7 +240,7 @@ export default class DoubanSource extends BaseSource { if (seasonId) { tmpAnimes[0].provider = "bilibili"; tmpAnimes[0].mediaId = `ss${seasonId}`; - await this.bilibiliSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore) + await this.bilibiliSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore, querySeason) } break; } @@ -221,7 +254,7 @@ export default class DoubanSource extends BaseSource { if (epId) { tmpAnimes[0].provider = "migu"; tmpAnimes[0].mediaId = `https://v3-sc.miguvideo.com/program/v4/cont/content-info/${epId}/1`; - await this.miguSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore) + await this.miguSource.handleAnimes(tmpAnimes, response.data?.title, doubanAnimes, detailStore, querySeason) } break; } @@ -241,4 +274,4 @@ export default class DoubanSource extends BaseSource { async getEpisodeDanmu(id) {} formatComments(comments) {} -} \ No newline at end of file +} diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index fec01a7..d5fb949 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -5,7 +5,7 @@ import { httpGet } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { titleMatches } from "../utils/common-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; import { buildHanjutvSearchHeaders, @@ -661,7 +661,16 @@ export default class HanjutvSource extends BaseSource { // ── 番剧处理 ───────────────────────────────────────────────── - async handleAnimes(sourceAnimes, queryTitle, curAnimes, extra = null, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {any} extra 额外信息 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { if (!Array.isArray(sourceAnimes)) { log("error", "[Hanjutv] sourceAnimes is not a valid array"); return []; @@ -669,10 +678,28 @@ export default class HanjutvSource extends BaseSource { const tmpAnimes = []; + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.name, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.name).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Hanjutv] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + await Promise.all( - sourceAnimes - .filter(s => titleMatches(s.name, queryTitle)) - .map(async (anime) => { + filteredAnimes.map(async (anime) => { try { const payload = await this.buildAnimePayload(anime); if (!payload || !payload.summary || !Array.isArray(payload.links) || payload.links.length === 0) return; diff --git a/danmu_api/sources/iqiyi.js b/danmu_api/sources/iqiyi.js index 11fbe52..3327ab2 100644 --- a/danmu_api/sources/iqiyi.js +++ b/danmu_api/sources/iqiyi.js @@ -1,7 +1,7 @@ import BaseSource from './base.js'; import { log } from "../utils/log-util.js"; import { buildQueryString, httpGet} from "../utils/http-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { md5, convertToAsciiSum, decodeHtmlEntities } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; @@ -644,13 +644,14 @@ export default class IqiyiSource extends BaseSource { } /** - * 处理搜索结果并格式化为 DanDanPlay 格式 - * @param {Array} sourceAnimes - 搜索结果数组 - * @param {string} queryTitle - 搜索关键词 - * @param {Array} curAnimes - 当前动漫列表 - * @returns {Promise} + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 */ - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -659,9 +660,26 @@ export default class IqiyiSource extends BaseSource { return []; } - const processIqiyiAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查初始结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[iQiyi] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processIqiyiAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.mediaId); diff --git a/danmu_api/sources/kan360.js b/danmu_api/sources/kan360.js index d0d0731..708d91b 100644 --- a/danmu_api/sources/kan360.js +++ b/danmu_api/sources/kan360.js @@ -4,7 +4,7 @@ import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { titleMatches } from "../utils/common-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; // ===================== // 获取360看源播放链接 @@ -212,7 +212,15 @@ export default class Kan360Source extends BaseSource { async getEpisodes(id) {} - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -221,9 +229,27 @@ export default class Kan360Source extends BaseSource { return []; } - const process360Animes = await Promise.all(sourceAnimes - .filter(anime => titleMatches(anime.titleTxt, queryTitle)) - .map(async (anime) => { + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(anime => titleMatches(anime.titleTxt, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.titleTxt).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[360kan] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const process360Animes = await Promise.all(filteredAnimes.map(async (anime) => { try { let links = []; if (anime.cat_name === "电影") { diff --git a/danmu_api/sources/leshi.js b/danmu_api/sources/leshi.js index 93ee859..ed70b50 100644 --- a/danmu_api/sources/leshi.js +++ b/danmu_api/sources/leshi.js @@ -5,7 +5,7 @@ import { httpGet, buildQueryString } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // 媒体类型映射 @@ -494,7 +494,15 @@ export default class LeshiSource extends BaseSource { return episodes; } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -503,10 +511,28 @@ export default class LeshiSource extends BaseSource { return []; } + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Leshi] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 - const processLeshiAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { + const processLeshiAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.mediaId); let links = []; diff --git a/danmu_api/sources/maiduidui.js b/danmu_api/sources/maiduidui.js index 8f2a000..12cfe36 100644 --- a/danmu_api/sources/maiduidui.js +++ b/danmu_api/sources/maiduidui.js @@ -6,7 +6,7 @@ import { convertToAsciiSum, md5 } from "../utils/codec-util.js"; import { hexToInt } from "../utils/danmu-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== @@ -194,7 +194,15 @@ class MaiduiduiSource extends BaseSource { } } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -203,10 +211,28 @@ class MaiduiduiSource extends BaseSource { return []; } + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.name, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.name).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Maiduidui] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 - const processMaiduiduiAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.name, queryTitle)) - .map(async (anime) => { + const processMaiduiduiAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.url); let links = []; diff --git a/danmu_api/sources/mango.js b/danmu_api/sources/mango.js index 97afaeb..ff61e5a 100644 --- a/danmu_api/sources/mango.js +++ b/danmu_api/sources/mango.js @@ -2,7 +2,7 @@ import BaseSource from './base.js'; import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { httpGet} from "../utils/http-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { time_to_second, generateValidStartDate } from "../utils/time-util.js"; import { rgbToInt } from "../utils/danmu-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; @@ -407,7 +407,15 @@ export default class MangoSource extends BaseSource { return episodeInfos; } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -416,9 +424,26 @@ export default class MangoSource extends BaseSource { return []; } - const processMangoAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查初始结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Mango] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processMangoAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { // 电影类型专门处理 if (anime.type === "电影") { diff --git a/danmu_api/sources/migu.js b/danmu_api/sources/migu.js index 95ffb3d..a45765d 100644 --- a/danmu_api/sources/migu.js +++ b/danmu_api/sources/migu.js @@ -7,7 +7,7 @@ import { hexToInt } from "../utils/danmu-util.js"; import { generateValidStartDate, time_to_second } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { decrypt } from "../utils/migu-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== @@ -181,7 +181,15 @@ class MiguSource extends BaseSource { } } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -190,10 +198,29 @@ class MiguSource extends BaseSource { return []; } + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.name || s.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const titleToCheck = anime.name || anime.title; + const s = extractSeasonNumberFromAnimeTitle(titleToCheck).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Migu] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 - const processMiguAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.name || s.title, queryTitle)) - .map(async (anime) => { + const processMiguAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.url || anime.mediaId); let links = []; diff --git a/danmu_api/sources/renren.js b/danmu_api/sources/renren.js index 670b0cd..606505a 100644 --- a/danmu_api/sources/renren.js +++ b/danmu_api/sources/renren.js @@ -5,14 +5,13 @@ import { getPathname, httpGet, sortedQueryString, updateQueryString } from "../u import { autoDecode, createHmacSha256, generateSign } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { titleMatches } from "../utils/common-util.js"; +import { titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取人人视频弹幕 // ===================== - // 模块级状态管理 (Instance Level State) // 缓存当前的 AliID (全局共享,跨请求持久化,模拟设备指纹) let CACHED_ALI_ID = null; @@ -21,9 +20,19 @@ let REQUEST_COUNT = 0; // 触发轮换的阈值 (将在 30-60 之间随机生成) let ROTATION_THRESHOLD = 0; +// 接口健康状态缓存 (全局共享,跨请求持久化,实现业务级智能路由) +// Search: WIN -> TV -> MAC -> WEB +// Detail/Danmu: TV -> MAC -> WIN -> WEB +const API_HEALTH = { + search: 'WIN', + detail: 'TV', + danmu: 'TV' +}; + /** * 人人视频弹幕源 - * 集成 TV 端 API 协议,保留网页版接口作为降级容灾策略。 + * 集成 TV, Mac, Win 端 API 协议,保留网页版接口作为降级容灾策略。 + * 详情接口使用跨协议网关穿透技术绕过未知客户端密钥验证。 * 兼容处理 SeriesId-EpisodeId 复合主键,确保弹幕与剧集详情的关联正确性。 */ export default class RenrenSource extends BaseSource { @@ -35,11 +44,15 @@ export default class RenrenSource extends BaseSource { this.isBatchMode = false; } - // API 配置常量 + // ===================== + // 1. API 综合配置常量 + // ===================== + API_CONFIG = { - SECRET_KEY: "cf65GPholnICgyw1xbrpA79XVkizOdMq", - - // TV 端接口配置 + // 跨端通用核心加密密钥 (用于网关穿透等严格验签场景) + TV_SECRET_KEY: "cf65GPholnICgyw1xbrpA79XVkizOdMq", + + // TV 端特征配置 TV_HOST: "api.gorafie.com", TV_DANMU_HOST: "static-dm.qwdjapp.com", TV_VERSION: "1.2.2", @@ -47,13 +60,31 @@ export default class RenrenSource extends BaseSource { TV_CLIENT_TYPE: 'android_qwtv_RRSP', TV_PKT: 'rrmj', - // 网页版/旧版接口配置 (降级备用) + // Mac 端特征配置 + MAC_HOST: "api.cluuid.cn", + MAC_DANMU_HOST: "static-dm.lequkeji.com", + MAC_VERSION: "1.2.3", + MAC_USER_AGENT: '%E4%BA%BA%E4%BA%BA%E8%A7%86%E9%A2%91%20for%20Mac/1.0 CFNetwork/3860.600.21 Darwin/25.5.0', + MAC_CLIENT_TYPE: 'mac_rrsp', + + // Win 端特征配置 + WIN_HOST: "api.pleasfun.com", + WIN_DANMU_HOST: "static-dm.lequkeji.com", + WIN_VERSION: "1.24.2", + WIN_USER_AGENT: 'Boost.Beast/351', + WIN_CLIENT_TYPE: 'win_rrsp_gw', + + // 网页版/旧版接口特征配置 (终极降级备用) WEB_HOST: "api.rrmj.plus", WEB_DANMU_HOST: "static-dm.rrmj.plus" }; + // ===================== + // 2. 身份指纹与轮换管控 + // ===================== + /** - * 生成随机的 aliid + * 生成随机的 aliid (兼容 TV 端验证算法) * 规律:24位长度,以 'aY' 开头,包含字母数字和 Base64 特殊字符 * 模拟抓包数据:aYN4D0XfSREDAJaw3UAjG33K */ @@ -69,6 +100,30 @@ export default class RenrenSource extends BaseSource { return result; } + /** + * 生成 Mac 端格式的 UUID 设备指纹 + */ + generateMacAliId() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16).toUpperCase(); + }); + } + + /** + * 生成 Win 端格式的 32 位十六进制设备指纹 + */ + generateWinAliId() { + return Array.from({length: 32}, () => Math.floor(Math.random() * 16).toString(16)).join('').toUpperCase(); + } + + /** + * 动态生成一次性使用的网页版 DeviceId + */ + generateDeviceId() { + return (Math.random().toString(36).slice(2)).toUpperCase(); + } + /** * 执行 ID 轮换/初始化 * 生成新的 ID,重置计数器,并随机生成下一次的轮换阈值 @@ -133,6 +188,10 @@ export default class RenrenSource extends BaseSource { return CACHED_ALI_ID; } + // ===================== + // 3. 协议头构建与业务签名机制 + // ===================== + /** * 生成 TV 端接口所需的请求头 * 处理签名、设备标识及版本控制字段 @@ -162,6 +221,146 @@ export default class RenrenSource extends BaseSource { }; } + /** + * 构建 Mac 端请求头 + */ + buildMacHeaders() { + return { + 'aliId': this.generateMacAliId(), + 'ct': this.API_CONFIG.MAC_CLIENT_TYPE, + 'cv': this.API_CONFIG.MAC_VERSION, + 'token': '', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh-Hans;q=0.9', + 'User-Agent': this.API_CONFIG.MAC_USER_AGENT + }; + } + + /** + * 构建 Win 端请求头 + */ + buildWinHeaders() { + return { + 'aliId': this.generateWinAliId(), + 'ct': this.API_CONFIG.WIN_CLIENT_TYPE, + 'cv': this.API_CONFIG.WIN_VERSION, + 'token': '', + 'Content-Type': 'application/json', + 'User-Agent': this.API_CONFIG.WIN_USER_AGENT + }; + } + + /** + * 生成网页版 API 业务校验字符串 + * 负责拼装各项客户端属性与业务参数结构,以供底层哈希算法加密使用 + */ + generateSignature(method, aliId, ct, cv, timestamp, path, sortedQuery, secret) { + const signStr = `${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${timestamp}\n${path}?${sortedQuery}`; + return createHmacSha256(secret, signStr); + } + + /** + * 构建网页版带签名的请求头 + */ + buildSignedHeaders({ method, url, params = {}, deviceId, token }) { + const ClientProfile = { + client_type: "web_pc", + client_version: "1.0.0", + user_agent: "Mozilla/5.0", + origin: "https://rrsp.com.cn", + referer: "https://rrsp.com.cn/", + }; + const pathname = getPathname(url); + const qs = sortedQueryString(params); + const nowMs = Date.now(); + const SIGN_SECRET = "ES513W0B1CsdUrR13Qk5EgDAKPeeKZY"; + + const xCaSign = this.generateSignature( + method, deviceId, ClientProfile.client_type, ClientProfile.client_version, + nowMs, pathname, qs, SIGN_SECRET + ); + + return { + clientVersion: ClientProfile.client_version, + deviceId, + clientType: ClientProfile.client_type, + t: String(nowMs), + aliId: deviceId, + umid: deviceId, + token: token || "", + cv: ClientProfile.client_version, + ct: ClientProfile.client_type, + uet: "9", + "x-ca-sign": xCaSign, + Accept: "application/json", + "User-Agent": ClientProfile.user_agent, + Origin: ClientProfile.origin, + Referer: ClientProfile.referer, + }; + } + + // ===================== + // 4. 底层网络请求代理 + // ===================== + + /** + * 通用结构处理方法,用于接管无严密签名的独立节点弹幕响应 + * @param {string} url 目标请求地址 + * @param {Object} headers 携带端特征的请求头 + * @param {string} tierName 平台标识 + * @returns {Array} 弹幕列表或空 + */ + async fetchStandardDanmu(url, headers, tierName) { + try { + // 中间节点执行快速失败策略,移除 retries 控制 + const resp = await httpGet(url, { headers, validStatusCodes: [404] }); + + // 校验 404 特征:若返回特定错误文本,说明服务器正常响应但该集确实无弹幕数据 + if (resp.status === 404) { + if (resp.data && resp.data.error === "Document not found") { + return []; + } + log("info", `[Renren] ${tierName} 弹幕接口返回未知 404 响应,疑似接口失效`); + return null; + } + + if (!resp.data) return null; + + const data = resp.data; + if (Array.isArray(data)) return data; + if (data && data.data && Array.isArray(data.data)) return data.data; + + return []; + } catch (error) { + log("info", `[Renren] ${tierName} 端弹幕拉取异常: ${error.message}`); + return null; + } + } + + async renrenHttpGet(url, { params = {}, headers = {}, validStatusCodes = [] } = {}) { + const u = updateQueryString(url, params); + const resp = await httpGet(u, { + headers: headers, + retries: 1, // 网页端为终极兜底链路,保留重试容错机制 + validStatusCodes + }); + return resp; + } + + async renrenRequest(method, url, params = {}) { + const deviceId = this.generateDeviceId(); + const headers = this.buildSignedHeaders({ method, url, params, deviceId }); + const resp = await httpGet(url + "?" + sortedQueryString(params), { + headers: headers, + retries: 1, // 网页端为终极兜底链路,保留重试容错机制 + }); + return resp; + } + + // ===================== + // 5. 搜索业务层 (TV/MAC/WIN/WEB) + // ===================== + /** * 搜索剧集 (TV API) * @param {string} keyword 搜索关键词 @@ -179,164 +378,160 @@ export default class RenrenSource extends BaseSource { well: "match" }; - const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.TV_SECRET_KEY); const queryString = Object.entries(queryParams) .map(([k, v]) => `${k}=${encodeURIComponent(v === null || v === undefined ? "" : String(v))}`) .join('&'); - + const headers = this.generateTvHeaders(timestamp, sign); const url = `https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`; - // log("info", `[Renren] TV搜索请求: ${url}`); - const resp = await httpGet(url, { - headers: headers, - retries: 1, - }); + const resp = await httpGet(url, { headers }); if (!resp.data || resp.data.code !== "0000") { log("info", `[Renren] TV搜索接口异常: code=${resp?.data?.code}, msg=${resp?.data?.msg}`); - return []; + return null; } const list = resp.data.data || []; - log("info", `[Renren] TV搜索返回结果数量: ${list.length}`); - return list.map((item) => ({ - provider: "renren", - mediaId: String(item.id), - title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), - type: item.classify || "Renren", - season: null, - year: item.year, - imageUrl: item.cover, - episodeCount: null, // 列表页不返回总集数 - currentEpisodeIndex: null, - })); + return list.map((item) => { + let aliases = []; + if (item.highlights && item.highlights.alias) { + aliases = item.highlights.alias.split(',').map(s => s.trim().replace(/<[^>]+>/g, "")).filter(Boolean); + } + return { + provider: "renren", + mediaId: String(item.id), + title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), + aliases: aliases, + type: item.classify || "Renren", + season: null, + year: item.year, + imageUrl: item.cover, + episodeCount: null, // 列表页不返回总集数 + currentEpisodeIndex: null, + }; + }); } catch (error) { log("info", "[Renren] searchAppContent error:", error.message); - return []; + return null; } } /** - * 获取剧集详情 (TV API) - * @param {string} dramaId 剧集ID - * @param {string} episodeSid 单集ID (可选) - * @returns {Object} 详情数据对象 + * 搜索剧集 (Mac API) + * 返回独立扁平的剧集列表结果集 */ - async getAppDramaDetail(dramaId, episodeSid = "") { + async performMacSearch(keyword, size = 20) { try { - const timestamp = Date.now(); - const path = "/qwtv/drama/details"; - const queryParams = { - isAgeLimit: "false", - seriesId: dramaId, - episodeId: episodeSid, - clarity: "HD", - caption: "0", - hevcOpen: "1" + const path = "/search/v5/season"; + const params = { keywords: keyword, order: "match", search_after: "", size: size }; + const headers = this.buildMacHeaders(); + const queryString = sortedQueryString(params); + const url = `https://${this.API_CONFIG.MAC_HOST}${path}?${queryString}`; + + const resp = await httpGet(url, { headers }); + if (!resp.data || resp.data.code !== "0000") { + log("info", `[Renren] Mac端搜索接口异常: code=${resp?.data?.code}`); + return null; + } + + const list = resp.data.data || []; + + return list.map(item => { + let aliases = []; + if (item.highlights && item.highlights.alias) { + aliases = item.highlights.alias.split(',').map(s => s.trim().replace(/<[^>]+>/g, "")).filter(Boolean); + } + return { + provider: "renren", + mediaId: String(item.id), + title: String(item.title || item.name || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), + aliases: aliases, + type: item.classify || item.cat || "Renren", + season: null, + year: item.year || null, + imageUrl: item.cover || item.cover3 || "", + episodeCount: null, + currentEpisodeIndex: null, + }; + }); + } catch (error) { + log("info", "[Renren] performMacSearch error:", error.message); + return null; + } + } + + /** + * 搜索剧集 (Win API) + * 深度展平提取模糊查询项与独立合集项 + */ + async performWinSearch(keyword, size = 30) { + try { + const url = `https://${this.API_CONFIG.WIN_HOST}/search/comprehensive/precise-mixed`; + const params = { keywords: keyword, searchAfter: "", size: size }; + const queryString = sortedQueryString(params); + const headers = this.buildWinHeaders(); + + const resp = await httpGet(`${url}?${queryString}`, { headers }); + if (!resp.data || resp.data.code !== "0000") { + log("info", `[Renren] Win端搜索接口异常: code=${resp?.data?.code}`); + return null; + } + + const data = resp.data.data || {}; + let results = []; + + // 数据域提取 + const seasonList = data.seasonList || []; + const fuzzySeasonList = data.fuzzySeasonList || []; + const seriesList = data.seriesList || []; + + // 数据模型装载器 + const processItem = (item) => { + let aliases = []; + if (item.highlights && item.highlights.alias) { + aliases = item.highlights.alias.split(',').map(s => s.trim().replace(/<[^>]+>/g, "")).filter(Boolean); + } + return { + provider: "renren", + mediaId: String(item.id), + title: String(item.title || item.alias || item.name || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), + aliases: aliases, + type: item.classify || item.cat || "Renren", + season: null, + year: item.year || null, + imageUrl: item.cover || item.coverUrl, + episodeCount: null, + currentEpisodeIndex: null, + }; }; - const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); - const queryString = Object.entries(queryParams) - .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) - .join('&'); + seasonList.forEach(item => results.push(processItem(item))); + fuzzySeasonList.forEach(item => results.push(processItem(item))); - const headers = this.generateTvHeaders(timestamp, sign); - - const resp = await httpGet(`https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`, { - headers: headers, - retries: 1, + seriesList.forEach(series => { + if (series.seasonList && Array.isArray(series.seasonList)) { + series.seasonList.forEach(seasonItem => { + results.push(processItem(seasonItem)); + }); + } }); - // 1. 基础网络或数据校验 - if (!resp || !resp.data) { - log("info", `[Renren] TV详情接口网络无响应或数据为空: ID=${dramaId}`); - return null; - } - - const resData = resp.data; - const msg = resData.msg || resData.message || ""; - - // 2. 检测特定维护信息 "该剧暂不可播" - // 这通常意味着 App 接口正在维护或该资源被下架,必须降级到 Web 版 - if (msg.includes("该剧暂不可播")) { - log("info", `[Renren] TV接口提示'该剧暂不可播' (ID=${dramaId}),视为维护中,触发Web降级`); - return null; // 触发降级 - } - - // 3. 检测错误码 - if (resData.code !== "0000") { - log("info", `[Renren] TV详情接口返回错误码: ${resData.code}, msg=${msg} (ID=${dramaId})`); - return null; - } - - // 4. 检测分集数据完整性 (关键修改:如果详情成功但没分集,也视为失败) - if (!resData.data || !resData.data.episodeList || resData.data.episodeList.length === 0) { - log("info", `[Renren] TV详情接口返回数据缺失分集列表 (ID=${dramaId}),尝试Web降级`); - return null; // 触发降级 - } - - log("info", `[Renren] TV详情获取成功: ID=${dramaId}, 包含集数=${resData.data.episodeList.length}`); - return resData; + return results; } catch (error) { - log("info", "[Renren] getAppDramaDetail error:", error.message); - return null; + log("info", "[Renren] performWinSearch error:", error.message); + return null; } } /** - * 获取单集弹幕 (TV API) - * 请求 static-dm.qwdjapp.com 获取全量弹幕数据 - * @param {string} episodeSid 单集ID (支持复合ID自动解包) - * @returns {Array} 原始弹幕数据列表 - */ - async getAppDanmu(episodeSid) { - try { - const timestamp = Date.now(); - - // 处理复合ID (SeriesId-EpisodeId),提取真实的 EpisodeId - let realEpisodeId = episodeSid; - if (String(episodeSid).includes("-")) { - realEpisodeId = String(episodeSid).split("-")[1]; - } - - // 构造请求路径 (注意:此处使用 EPISODE 路径,不包含 emo) - const path = `/v1/produce/danmu/EPISODE/${realEpisodeId}`; - const queryParams = {}; // 该接口无查询参数 - const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); - const headers = this.generateTvHeaders(timestamp, sign); - - // 请求旧域名 static-dm.qwdjapp.com - const url = `https://${this.API_CONFIG.TV_DANMU_HOST}${path}`; - - const resp = await httpGet(url, { - headers: headers, - retries: 1, - }); - - if (!resp.data) return null; - - const data = autoDecode(resp.data); - - // 兼容直接返回数组或包装在 data 字段中的情况 - if (Array.isArray(data)) return data; - if (data && data.data && Array.isArray(data.data)) return data.data; - - return []; - } catch (error) { - log("info", "[Renren] getAppDanmu error:", error.message); - return null; - } - } - - /** - * 执行网页版网络搜索 (降级逻辑) + * 执行网页版网络搜索 (终极降级逻辑) */ async performNetworkSearch(keyword, { lockRef = null, lastRequestTimeRef = { value: 0 }, minInterval = 500 } = {}) { try { - log("info", `[Renren] 尝试执行网页版搜索: ${keyword}`); const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/search/drama`; const params = { keywords: keyword, @@ -362,81 +557,262 @@ export default class RenrenSource extends BaseSource { if (!resp.data) { log("info", "[Renren] 网页版搜索无响应数据"); - return []; + return null; } const decoded = autoDecode(resp.data); const list = decoded?.data?.searchDramaList || []; - log("info", `[Renren] 网页版搜索结果数量: ${list.length}`); - - return list.map((item) => ({ - provider: "renren", - mediaId: String(item.id), - title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), - type: item.classify || "Renren", - season: null, - year: item.year, - imageUrl: item.cover, - episodeCount: item.episodeTotal, - currentEpisodeIndex: null, - })); + + return list.map((item) => { + let aliases = []; + if (item.highlights && item.highlights.alias) { + aliases = item.highlights.alias.split(',').map(s => s.trim().replace(/<[^>]+>/g, "")).filter(Boolean); + } + return { + provider: "renren", + mediaId: String(item.id), + title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), + aliases: aliases, + type: item.classify || "Renren", + season: null, + year: item.year, + imageUrl: item.cover, + episodeCount: item.episodeTotal, + currentEpisodeIndex: null, + }; + }); } catch (error) { log("info", "[Renren] performNetworkSearch error:", error.message); - return []; + return null; } } - // ===================== - // 标准接口实现 (BaseSource 抽象方法) - // ===================== - async search(keyword) { log("info", `[Renren] 开始搜索: ${keyword}`); - const parsedKeyword = { title: keyword, season: null }; - const searchTitle = parsedKeyword.title; - const searchSeason = parsedKeyword.season; let allResults = []; - - // 1. 优先使用 TV 接口 - allResults = await this.searchAppContent(searchTitle); - - // 2. 降级策略: 若 TV 接口无结果,尝试网页接口 - if (allResults.length === 0) { - log("info", "[Renren] TV 搜索无结果,降级到网页接口"); - const lock = { value: false }; - const lastRequestTime = { value: 0 }; - allResults = await this.performNetworkSearch(searchTitle, { - lockRef: lock, - lastRequestTimeRef: lastRequestTime, - minInterval: 400 - }); + let hasValidResponse = false; + + const tiers = ['WIN', 'TV', 'MAC', 'WEB']; + let currentTierIndex = tiers.indexOf(API_HEALTH.search); + if (currentTierIndex === -1) currentTierIndex = 0; + + // 智能路由检测与降级回路 + for (let i = currentTierIndex; i < tiers.length; i++) { + const tier = tiers[i]; + log("info", `[Renren] 尝试使用 ${tier} 端接口搜索`); + + try { + let tierResults = null; + if (tier === 'TV') { + tierResults = await this.searchAppContent(keyword); + } else if (tier === 'MAC') { + tierResults = await this.performMacSearch(keyword); + } else if (tier === 'WIN') { + tierResults = await this.performWinSearch(keyword); + } else if (tier === 'WEB') { + const lock = { value: false }; + const lastRequestTime = { value: 0 }; + tierResults = await this.performNetworkSearch(keyword, { + lockRef: lock, + lastRequestTimeRef: lastRequestTime, + minInterval: 400 + }); + } + + // 区分「请求异常(null)」与「正常但无数据([])」 + if (tierResults !== null && Array.isArray(tierResults)) { + hasValidResponse = true; + allResults = tierResults; + + // 记录当前健康的接口层级 + if (API_HEALTH.search !== tier) { + log("info", `[Renren] 搜索域接口健康状态更新: ${API_HEALTH.search} -> ${tier}`); + API_HEALTH.search = tier; + } + + break; + } else { + log("info", `[Renren] ${tier} 端搜索接口异常或请求失败,触发降级`); + } + } catch (e) { + log("info", `[Renren] ${tier} 端搜索异常,触发降级: ${e.message}`); + } } - if (searchSeason == null) return allResults; + // 所有降级接口全部“异常报错”时,重置健康状态 + if (!hasValidResponse) { + log("info", `[Renren] 搜索域所有降级接口均异常失败,重置健康状态至 WIN 端`); + API_HEALTH.search = 'WIN'; + } - return allResults.filter(r => r.season === searchSeason); + return allResults; } - async getDetail(id) { - // 1. 优先使用 TV 接口 - const resp = await this.getAppDramaDetail(String(id)); - if (resp && resp.data) { - return resp.data; - } - - // 2. 降级策略: 尝试网页接口 - log("info", `[Renren] TV详情不可用,尝试请求网页版接口 (ID=${id})`); - const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/drama/page`; - const params = { hsdrOpen: 0, isAgeLimit: 0, dramaId: String(id), hevcOpen: 1 }; - + // ===================== + // 6. 详情业务层 (TV/MAC/WIN/WEB) + // ===================== + + /** + * 获取剧集详情 (TV API) + * @param {string} dramaId 剧集ID + * @param {string} episodeSid 单集ID (可选) + * @returns {Object} 详情数据对象 + */ + async getAppDramaDetail(dramaId, episodeSid = "") { try { - const fallbackResp = await this.renrenRequest("GET", url, params); - if (!fallbackResp.data) return null; - - const decoded = autoDecode(fallbackResp.data); - if (decoded && decoded.data) { - log("info", `[Renren] 网页版详情获取成功: 包含集数=${decoded.data.episodeList ? decoded.data.episodeList.length : 0}`); + const timestamp = Date.now(); + const path = "/qwtv/drama/details"; + const queryParams = { + isAgeLimit: "false", + seriesId: String(dramaId), + episodeId: String(episodeSid), + clarity: "HD", + caption: "0", + hevcOpen: "1" + }; + + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.TV_SECRET_KEY); + const queryString = Object.entries(queryParams) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); + + const headers = this.generateTvHeaders(timestamp, sign); + + const resp = await httpGet(`https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`, { + headers: headers + }); + + // 1. 基础网络或数据校验 + if (!resp || !resp.data) { + log("info", `[Renren] TV详情接口网络无响应或数据为空: ID=${dramaId}`); + return null; + } + + const resData = resp.data; + const msg = resData.msg || resData.message || ""; + + // 2. 检测特定维护信息 "该剧暂不可播" + if (msg.includes("该剧暂不可播")) { + log("info", `[Renren] TV接口提示'该剧暂不可播' (ID=${dramaId}),视为维护中,触发降级`); + return null; + } + + // 3. 检测错误码 + if (resData.code !== "0000") { + log("info", `[Renren] TV详情接口返回错误码: ${resData.code}, msg=${msg} (ID=${dramaId})`); + return null; + } + + // 4. 检测分集数据完整性,过滤“即将开播” + if (!resData.data || !resData.data.episodeList || resData.data.episodeList.length === 0) { + if (resData.data?.dramaInfo?.playStatus?.includes("即将开播")) { + log("info", `[Renren] TV详情接口提示'即将开播' (ID=${dramaId}),视为空结果`); + if (!resData.data.episodeList) resData.data.episodeList = []; + return resData.data; + } + log("info", `[Renren] TV详情接口返回数据缺失分集列表 (ID=${dramaId}),尝试降级`); + return null; + } + + log("info", `[Renren] TV端详情获取与分集解析成功: ID=${dramaId}, 包含集数=${resData.data.episodeList.length}`); + return resData.data; + } catch (error) { + log("info", "[Renren] getAppDramaDetail error:", error.message); + return null; + } + } + + /** + * 跨协议网关穿透获取详情数据 + * 将 TV 端的路径特征、加密参数与合法签名下发至目标 API 域名执行穿透请求,以绕过客户端独立密钥验证 + * @param {string} targetHost 目标网关域名 (Mac 或 Win) + * @param {string} dramaId 剧集ID + * @param {string} episodeSid 单集ID (可选) + * @returns {Object} 详情数据对象 + */ + async getGatewayDramaDetail(targetHost, dramaId, episodeSid = "") { + try { + const timestamp = Date.now(); + // 使用 TV 端的接口路径,避开对目标端独立签名的依赖 + const path = "/qwtv/drama/details"; + const queryParams = { + isAgeLimit: "false", + seriesId: String(dramaId), + episodeId: String(episodeSid), + clarity: "HD", + caption: "0", + hevcOpen: "1" + }; + + // 使用 TV 端的私钥生成合法签名 + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.TV_SECRET_KEY); + const queryString = Object.entries(queryParams) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); + + // 复用 TV 端特征的 Headers + const headers = this.generateTvHeaders(timestamp, sign); + + // 请求发往目标网关 + const url = `https://${targetHost}${path}?${queryString}`; + + const resp = await httpGet(url, { headers }); + + // 1. 基础网络或数据校验 + if (!resp || !resp.data) { + log("info", `[Renren] 详情接口网络无响应或数据为空 (${targetHost}): ID=${dramaId}`); + return null; + } + + const resData = resp.data; + const msg = resData.msg || resData.message || ""; + + // 2. 检测特定维护信息 "该剧暂不可播" + if (msg.includes("该剧暂不可播")) { + log("info", `[Renren] 接口提示'该剧暂不可播' (${targetHost}) (ID=${dramaId}),视为维护中,触发降级`); + return null; + } + + // 3. 检测错误码 + if (resData.code !== "0000") { + log("info", `[Renren] 详情接口返回错误码: ${resData.code}, msg=${msg} (${targetHost}) (ID=${dramaId})`); + return null; + } + + // 4. 检测分集数据完整性,过滤“即将开播” + if (!resData.data || !resData.data.episodeList || resData.data.episodeList.length === 0) { + if (resData.data?.dramaInfo?.playStatus?.includes("即将开播")) { + log("info", `[Renren] 详情接口提示'即将开播' (${targetHost}) (ID=${dramaId}),视为空结果`); + if (!resData.data.episodeList) resData.data.episodeList = []; + return resData.data; + } + log("info", `[Renren] 详情接口返回数据缺失分集列表 (${targetHost}) (ID=${dramaId}),尝试降级`); + return null; + } + + log("info", `[Renren] 跨协议详情获取与分集解析成功 (${targetHost}): ID=${dramaId}, 包含集数=${resData.data.episodeList.length}`); + return resData.data; + } catch (error) { + log("info", `[Renren] getGatewayDramaDetail error (${targetHost}):`, error.message); + return null; + } + } + + /** + * 详情获取 (网页端降级) + */ + async getWebDramaDetailFallback(dramaId) { + const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/drama/page`; + const params = { hsdrOpen: 0, isAgeLimit: 0, dramaId: String(dramaId), hevcOpen: 1 }; + + try { + const resp = await this.renrenRequest("GET", url, params); + if (!resp.data) return null; + + const decoded = autoDecode(resp.data); + if (decoded && decoded.data && decoded.data.episodeList && decoded.data.episodeList.length > 0) { + log("info", `[Renren] 网页版详情获取与分集解析成功: ID=${dramaId}, 包含集数=${decoded.data.episodeList.length}`); return decoded.data; } return null; @@ -446,15 +822,58 @@ export default class RenrenSource extends BaseSource { } } + async getDetail(id, episodeSid = "") { + let detail = null; + const tiers = ['TV', 'MAC', 'WIN', 'WEB']; + + let currentTierIndex = tiers.indexOf(API_HEALTH.detail); + if (currentTierIndex === -1) currentTierIndex = 0; + + // 智能路由检测与降级回路 + for (let i = currentTierIndex; i < tiers.length; i++) { + const tier = tiers[i]; + log("info", `[Renren] 尝试使用 ${tier} 端接口获取详情分集 (ID=${id})`); + + try { + if (tier === 'TV') { + detail = await this.getAppDramaDetail(String(id), String(episodeSid)); + } else if (tier === 'MAC') { + detail = await this.getGatewayDramaDetail(this.API_CONFIG.MAC_HOST, String(id), String(episodeSid)); + } else if (tier === 'WIN') { + detail = await this.getGatewayDramaDetail(this.API_CONFIG.WIN_HOST, String(id), String(episodeSid)); + } else if (tier === 'WEB') { + detail = await this.getWebDramaDetailFallback(String(id)); + } + + if (detail) { + // 记录当前健康的接口层级 + if (API_HEALTH.detail !== tier) { + log("info", `[Renren] 详情域接口健康状态更新: ${API_HEALTH.detail} -> ${tier}`); + API_HEALTH.detail = tier; + } + return detail; + } else { + log("info", `[Renren] ${tier} 详情接口失败或无数据,触发降级`); + } + } catch (e) { + log("info", `[Renren] ${tier} 详情接口异常,触发降级: ${e.message}`); + } + } + + // 所有端点轮换完毕仍未获取到数据,重置健康状态 + log("info", `[Renren] 详情域所有降级接口均失败,重置健康状态至 TV 端`); + API_HEALTH.detail = 'TV'; + return null; + } + async getEpisodes(id) { - log("info", `[Renren] 正在获取分集信息: ID=${id}`); const detail = await this.getDetail(id); - + if (!detail) { log("info", `[Renren] 获取分集失败: 详情对象为空 ID=${id}`); return []; } - + if (!detail.episodeList || !Array.isArray(detail.episodeList)) { log("info", `[Renren] 获取分集失败: episodeList 字段缺失或非数组 ID=${id}`); return []; @@ -466,9 +885,9 @@ export default class RenrenSource extends BaseSource { detail.episodeList.forEach((ep, idx) => { const epSid = String(ep.sid || "").trim(); if (!epSid) return; - + const showTitle = ep.title ? String(ep.title) : `第${String(ep.episodeNo || idx + 1).padStart(2, "0")}集`; - + // 构建复合ID (SeriesId-EpisodeId) // TV弹幕接口需要EpisodeId,搜索可能需要SeriesId,保留此结构确保上下文完整 const compositeId = `${seriesId}-${epSid}`; @@ -476,18 +895,59 @@ export default class RenrenSource extends BaseSource { episodes.push({ sid: compositeId, order: ep.episodeNo || idx + 1, title: showTitle }); }); - log("info", `[Renren] 成功解析分集数量: ${episodes.length} (ID=${id})`); - - return episodes.map(e => ({ + const resultEpisodes = episodes.map(e => ({ provider: "renren", episodeId: e.sid, title: e.title, episodeIndex: e.order, url: null })); + + // 挂载详情页的原名、年份及类型属性,以供信息不全的搜索接口自动补全 + if (detail.dramaInfo) { + if (detail.dramaInfo.enName) { + resultEpisodes.enName = detail.dramaInfo.enName; + } + if (detail.dramaInfo.year) { + resultEpisodes.year = detail.dramaInfo.year; + } + if (detail.dramaInfo.dramaType) { + let dType = detail.dramaInfo.dramaType; + let pType = detail.dramaInfo.plotType || ""; + + if (dType === "TV") { + dType = "电视剧"; + } else if (dType === "MOVIE") { + // 特殊判定:如果是电影类型且剧情包含"动画",则划分为剧场版 + if (pType.includes("动画")) { + dType = "剧场版"; + } else { + dType = "电影"; + } + } else if (dType === "COMIC") { + dType = "动画"; + } else if (dType === "VARIETY") { + dType = "综艺"; + } else if (dType === "DOCUMENTARY") { + dType = "纪录片"; + } + + resultEpisodes.type = dType; + } + } + + return resultEpisodes; } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; if (!sourceAnimes || !Array.isArray(sourceAnimes)) { @@ -495,19 +955,48 @@ export default class RenrenSource extends BaseSource { return []; } + // 基础标题与季度匹配过滤 (增加别名匹配) + let filteredAnimes = sourceAnimes.filter(s => { + if (titleMatches(s.title, queryTitle, querySeason)) return true; + if (s.aliases && Array.isArray(s.aliases)) { + return s.aliases.some(alias => titleMatches(alias, queryTitle, querySeason)); + } + return false; + }); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Renren] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + // 打印过滤合并后的结果日志 + const tierNameMap = { 'TV': 'TV', 'MAC': 'Mac', 'WIN': 'Win', 'WEB': '网页' }; + const currentTierName = tierNameMap[API_HEALTH.search] || '未知'; + log("info", `[Renren] ${currentTierName}端搜索提取结果数量: ${sourceAnimes.length} 有效结果数量:${filteredAnimes.length}`); + // [标记开始] 进入批量处理模式 // 注意:此处不再输出冗余日志,也不扣费。开启静默模式。 this.isBatchMode = true; - + try { - await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { + await Promise.all(filteredAnimes.map(async (anime) => { try { // 在此块中调用的 getEpisodes -> ... -> getAliId // 会因为 isBatchMode=true 而直接返回缓存ID,不增加计数 const eps = await this.getEpisodes(anime.mediaId); - + let links = []; for (const ep of eps) { links.push({ @@ -518,14 +1007,24 @@ export default class RenrenSource extends BaseSource { } if (links.length > 0) { + let aliases = anime.aliases || []; + if (eps.enName && !aliases.includes(eps.enName)) { + aliases.push(eps.enName); + } + + // 优先使用详情接口(eps)补全的精确年份,其次使用搜索结果的年份 + let finalYear = eps.year || anime.year || ""; + let finalType = eps.type || ((anime.type && anime.type !== "Renren") ? anime.type : "Renren"); + let transformedAnime = { animeId: Number(anime.mediaId), bangumiId: String(anime.mediaId), - animeTitle: `${anime.title}(${anime.year})【${anime.type}】from renren`, - type: anime.type, - typeDescription: anime.type, + animeTitle: `${anime.title}(${finalYear})【${finalType}】from renren`, + aliases: aliases, + type: finalType, + typeDescription: finalType, imageUrl: anime.imageUrl, - startDate: generateValidStartDate(anime.year), + startDate: generateValidStartDate(finalYear), episodeCount: links.length, rating: 0, isFavorited: true, @@ -548,7 +1047,6 @@ export default class RenrenSource extends BaseSource { // [标记结束] 退出批量模式 this.isBatchMode = false; // [结算扣费] 批量操作结束,统一结算一次 AliID 计数 - // 这样日志会出现在所有请求日志的末尾,符合“处理完毕,消耗一次”的直觉 this.checkAndIncrementUsage(); } @@ -557,27 +1055,85 @@ export default class RenrenSource extends BaseSource { return tmpAnimes; } - async getEpisodeDanmu(id) { - // 1. 优先尝试 TV 接口 - let danmuList = await this.getAppDanmu(id); - - // 2. 降级策略: TV 接口无数据时,尝试网页版接口 - if (!danmuList || danmuList.length === 0) { - log("info", "[Renren] TV 弹幕接口失败或无数据,尝试降级网页接口"); - danmuList = await this.getWebDanmuFallback(id); - } - - // 3. 返回原始数据列表,BaseSource 会自动调用本类的 formatComments 进行格式化 - if (danmuList && Array.isArray(danmuList) && danmuList.length > 0) { - log("info", `[Renren] 成功获取 ${danmuList.length} 条弹幕`); - return danmuList; - } + // ===================== + // 7. 弹幕业务层 (TV/MAC/WIN/WEB) + // ===================== - return []; + /** + * 获取单集弹幕 (TV API) + * 请求 static-dm.qwdjapp.com 获取全量弹幕数据 + * @param {string} episodeSid 单集ID (支持复合ID自动解包) + * @returns {Array} 原始弹幕数据列表 + */ + async getAppDanmu(episodeSid) { + try { + const timestamp = Date.now(); + + // 处理复合ID (SeriesId-EpisodeId),提取真实的 EpisodeId + let realEpisodeId = episodeSid; + if (String(episodeSid).includes("-")) { + realEpisodeId = String(episodeSid).split("-")[1]; + } + + // 构造请求路径 (注意:此处使用 EPISODE 路径,不包含 emo) + const path = `/v1/produce/danmu/EPISODE/${realEpisodeId}`; + const queryParams = {}; // 该接口无查询参数 + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.TV_SECRET_KEY); + const headers = this.generateTvHeaders(timestamp, sign); + + const url = `https://${this.API_CONFIG.TV_DANMU_HOST}${path}`; + + const resp = await httpGet(url, { + headers: headers, + validStatusCodes: [404] + }); + + // 校验 404 特征:若返回特定错误文本,说明服务器正常响应但该集确实无弹幕数据 + if (resp.status === 404) { + if (resp.data && resp.data.error === "Document not found") { + return []; + } + log("info", `[Renren] TV 弹幕接口返回未知 404 响应,疑似接口失效`); + return null; + } + + if (!resp.data) return null; + + const data = autoDecode(resp.data); + + // 兼容直接返回数组或包装在 data 字段中的情况 + if (Array.isArray(data)) return data; + if (data && data.data && Array.isArray(data.data)) return data.data; + + return []; + } catch (error) { + log("info", "[Renren] getAppDanmu error:", error.message); + return null; + } } /** - * 获取网页版弹幕 (降级方法) + * 获取单集弹幕 (Mac API) + */ + async getMacDanmu(episodeSid) { + const realEpisodeId = String(episodeSid).includes("-") ? String(episodeSid).split("-")[1] : episodeSid; + const url = `https://${this.API_CONFIG.MAC_DANMU_HOST}/v1/produce/danmu/EPISODE/${realEpisodeId}`; + const headers = { 'User-Agent': this.API_CONFIG.MAC_USER_AGENT, 'Accept': '*/*' }; + return this.fetchStandardDanmu(url, headers, 'MAC'); + } + + /** + * 获取单集弹幕 (Win API) + */ + async getWinDanmu(episodeSid) { + const realEpisodeId = String(episodeSid).includes("-") ? String(episodeSid).split("-")[1] : episodeSid; + const url = `https://${this.API_CONFIG.WIN_DANMU_HOST}/v1/produce/danmu/EPISODE/${realEpisodeId}`; + const headers = { 'User-Agent': this.API_CONFIG.WIN_USER_AGENT }; + return this.fetchStandardDanmu(url, headers, 'WIN'); + } + + /** + * 获取网页版弹幕 (终极降级方法) * 自动处理复合 ID 的解包 */ async getWebDanmuFallback(id) { @@ -585,16 +1141,13 @@ export default class RenrenSource extends BaseSource { if (String(id).includes("-")) { realEpisodeId = String(id).split("-")[1]; } - - // 日志保留 - log("info", `[Renren] 降级网页版弹幕,使用 ID: ${realEpisodeId}`); const ClientProfile = { user_agent: "Mozilla/5.0", origin: "https://rrsp.com.cn", referer: "https://rrsp.com.cn/", }; - + const url = `https://${this.API_CONFIG.WEB_DANMU_HOST}/v1/produce/danmu/EPISODE/${realEpisodeId}`; const headers = { "Accept": "application/json", @@ -602,23 +1155,106 @@ export default class RenrenSource extends BaseSource { "Origin": ClientProfile.origin, "Referer": ClientProfile.referer, }; - + try { - const fallbackResp = await this.renrenHttpGet(url, { headers }); - if (!fallbackResp.data) return []; - + const fallbackResp = await this.renrenHttpGet(url, { headers, validStatusCodes: [404] }); + + // 校验 404 特征:若返回特定错误文本,说明服务器正常响应但该集确实无弹幕数据 + if (fallbackResp.status === 404) { + if (fallbackResp.data && fallbackResp.data.error === "Document not found") { + return []; + } + log("info", `[Renren] WEB 弹幕接口返回未知 404 响应,疑似接口失效`); + return null; + } + + if (!fallbackResp.data) return null; + const data = autoDecode(fallbackResp.data); let list = []; if (Array.isArray(data)) list = data; else if (data?.data && Array.isArray(data.data)) list = data.data; - + return list; } catch (e) { log("info", `[Renren] 网页版弹幕降级失败: ${e.message}`); - return []; + return null; } } + async getEpisodeDanmu(id) { + // 智能获取广告/片头时长以便偏移弹幕 + let adDurationMs = 0; + if (String(id).includes("-")) { + const [seriesId, realEpisodeId] = String(id).split("-"); + const detail = await this.getDetail(seriesId, realEpisodeId); + if (detail && detail.watchInfo) { + const playInfo = detail.watchInfo.m3u8 || detail.watchInfo.tria4kPlayInfo || {}; + const startingLength = parseInt(playInfo.startingLength) || 0; + const openingLength = parseInt(playInfo.openingLength) || 0; + // 综合判断:优先使用 startingLength(通常为前置广告时长),若为0则降级使用 openingLength + adDurationMs = startingLength > 0 ? startingLength : (openingLength > 0 ? openingLength : 0); + } + } + + let danmuList = null; + const tiers = ['TV', 'MAC', 'WIN', 'WEB']; + + let currentTierIndex = tiers.indexOf(API_HEALTH.danmu); + if (currentTierIndex === -1) currentTierIndex = 0; + + // 智能路由检测与降级回路 + for (let i = currentTierIndex; i < tiers.length; i++) { + const tier = tiers[i]; + log("info", `[Renren] 尝试使用 ${tier} 端接口获取弹幕`); + + try { + if (tier === 'TV') { + danmuList = await this.getAppDanmu(id); + } else if (tier === 'MAC') { + danmuList = await this.getMacDanmu(id); + } else if (tier === 'WIN') { + danmuList = await this.getWinDanmu(id); + } else if (tier === 'WEB') { + danmuList = await this.getWebDanmuFallback(id); + } + + // 区分「请求异常(null)」与「正常但无数据([])」 + if (danmuList !== null && Array.isArray(danmuList)) { + // 记录当前健康的接口层级 + if (API_HEALTH.danmu !== tier) { + log("info", `[Renren] 弹幕域接口健康状态更新: ${API_HEALTH.danmu} -> ${tier}`); + API_HEALTH.danmu = tier; + } + + if (danmuList.length > 0) { + log("info", `[Renren] 成功获取 ${danmuList.length} 条弹幕 (${tier}端)`); + } else { + log("info", `[Renren] 该剧集暂无弹幕 (${tier}端)`); + } + + // 将时长与ID附带在返回的数组对象上供格式化时使用 + danmuList.adDurationMs = adDurationMs; + danmuList.episodeId = id; + return danmuList; + } else { + log("info", `[Renren] ${tier} 弹幕接口失败,触发降级`); + } + } catch (e) { + log("info", `[Renren] ${tier} 弹幕接口异常,触发降级: ${e.message}`); + } + } + + // 所有端点轮换完毕仍未获取到数据,重置健康状态 + log("info", `[Renren] 弹幕域所有降级接口均失败,重置健康状态至 TV 端`); + API_HEALTH.danmu = 'TV'; + + const emptyDanmuList = []; + emptyDanmuList.adDurationMs = adDurationMs; + emptyDanmuList.episodeId = id; + return emptyDanmuList; + } + async getEpisodeDanmuSegments(id) { return new SegmentListResponse({ "type": "renren", @@ -636,7 +1272,7 @@ export default class RenrenSource extends BaseSource { } // ===================== - // 数据解析与签名工具 + // 8. 数据解析与处理工具 // ===================== /** @@ -646,118 +1282,66 @@ export default class RenrenSource extends BaseSource { */ parseRRSPPFields(pField) { const parts = String(pField).split(","); - + // 安全数值转换工具:若解析结果为 NaN,则返回默认值 const safeNum = (val, parser, defaultVal) => { if (val === undefined || val === null || val === "") return defaultVal; const res = parser(val); return isNaN(res) ? defaultVal : res; }; - + const timestamp = safeNum(parts[0], parseFloat, 0); const mode = safeNum(parts[1], x => parseInt(x, 10), 1); const size = safeNum(parts[2], x => parseInt(x, 10), 25); const color = safeNum(parts[3], x => parseInt(x, 10), 16777215); - const userId = parts[6] || ""; const contentId = parts[7] || `${timestamp}:${userId}`; - + return { timestamp, mode, size, color, userId, contentId }; } /** * 格式化弹幕列表为标准模型 * 将原始 d/p 字段映射为系统内部对象 - * 兼容处理 item.d 和 item.content 内容字段 + * 兼容处理 item.d 和 item.content 内容字段,支持内嵌广告自动前移偏移补偿 */ formatComments(comments) { - return comments.map(item => { + const adDurationMs = comments.adDurationMs || 0; + const episodeId = comments.episodeId || "未知"; + const offsetSec = adDurationMs / 1000; + // 剔除统计器 + let droppedCount = 0; + + const formattedList = comments.map(item => { // 提取内容 (优先 d,兼容 content) let text = String(item.d || ""); if (!text && item.content) text = String(item.content); - + if (!text) return null; // 提取属性 (p) if (item.p) { const meta = this.parseRRSPPFields(item.p); + + // 弹幕前置偏移,去除广告导致的延后影响 + let t = meta.timestamp - offsetSec; + if (t < 0) { + droppedCount++; + return null; + } + return { cid: Number(meta.contentId) || 0, - p: `${meta.timestamp.toFixed(2)},${meta.mode},${meta.color},[renren]`, + p: `${t.toFixed(2)},${meta.mode},${meta.color},[renren]`, m: text, - t: meta.timestamp + t: t }; } return null; }).filter(Boolean); - } - - /** - * 生成网页版 API 签名 - */ - generateSignature(method, aliId, ct, cv, timestamp, path, sortedQuery, secret) { - const signStr = `${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${timestamp}\n${path}?${sortedQuery}`; - return createHmacSha256(secret, signStr); - } - - /** - * 构建网页版带签名的请求头 - */ - buildSignedHeaders({ method, url, params = {}, deviceId, token }) { - const ClientProfile = { - client_type: "web_pc", - client_version: "1.0.0", - user_agent: "Mozilla/5.0", - origin: "https://rrsp.com.cn", - referer: "https://rrsp.com.cn/", - }; - const pathname = getPathname(url); - const qs = sortedQueryString(params); - const nowMs = Date.now(); - const SIGN_SECRET = "ES513W0B1CsdUrR13Qk5EgDAKPeeKZY"; - const xCaSign = this.generateSignature( - method, deviceId, ClientProfile.client_type, ClientProfile.client_version, - nowMs, pathname, qs, SIGN_SECRET - ); - return { - clientVersion: ClientProfile.client_version, - deviceId, - clientType: ClientProfile.client_type, - t: String(nowMs), - aliId: deviceId, - umid: deviceId, - token: token || "", - cv: ClientProfile.client_version, - ct: ClientProfile.client_type, - uet: "9", - "x-ca-sign": xCaSign, - Accept: "application/json", - "User-Agent": ClientProfile.user_agent, - Origin: ClientProfile.origin, - Referer: ClientProfile.referer, - }; - } - - async renrenHttpGet(url, { params = {}, headers = {} } = {}) { - const u = updateQueryString(url, params); - const resp = await httpGet(u, { - headers: headers, - retries: 1, - }); - return resp; - } - - generateDeviceId() { - return (Math.random().toString(36).slice(2)).toUpperCase(); - } - - async renrenRequest(method, url, params = {}) { - const deviceId = this.generateDeviceId(); - const headers = this.buildSignedHeaders({ method, url, params, deviceId }); - const resp = await httpGet(url + "?" + sortedQueryString(params), { - headers: headers, - retries: 1, - }); - return resp; + if (adDurationMs > 0) { + log("info", `[Renren] 识别到前置广告(${adDurationMs}ms),已自动偏移时间轴。成功转换 ${formattedList.length} 条,剔除无效弹幕 ${droppedCount} 条 (ID=${episodeId})`); + } + return formattedList; } } diff --git a/danmu_api/sources/sohu.js b/danmu_api/sources/sohu.js index a0505c4..f0b919d 100644 --- a/danmu_api/sources/sohu.js +++ b/danmu_api/sources/sohu.js @@ -5,7 +5,7 @@ import { httpGet, buildQueryString } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== @@ -240,7 +240,15 @@ export default class SohuSource extends BaseSource { } } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -249,10 +257,28 @@ export default class SohuSource extends BaseSource { return []; } + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Sohu] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 - const processSohuAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { + const processSohuAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.mediaId); let links = []; diff --git a/danmu_api/sources/tencent.js b/danmu_api/sources/tencent.js index 4b9cd09..c0fd610 100644 --- a/danmu_api/sources/tencent.js +++ b/danmu_api/sources/tencent.js @@ -5,7 +5,7 @@ import { httpGet, httpPost } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== @@ -31,9 +31,9 @@ export default class TencentSource extends BaseSource { return aliases; } - titleOrAliasMatches(anime, queryTitle) { - if (titleMatches(anime.title, queryTitle)) return true; - return Array.isArray(anime.aliases) && anime.aliases.some(alias => titleMatches(alias, queryTitle)); + titleOrAliasMatches(anime, queryTitle, querySeason = null) { + if (titleMatches(anime.title, queryTitle, querySeason)) return true; + return Array.isArray(anime.aliases) && anime.aliases.some(alias => titleMatches(alias, queryTitle, querySeason)); } /** @@ -415,7 +415,15 @@ export default class TencentSource extends BaseSource { } } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -424,10 +432,28 @@ export default class TencentSource extends BaseSource { return []; } + // 基础标题、别名与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => this.titleOrAliasMatches(s, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Tencent] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 - const processTencentAnimes = await Promise.all(sourceAnimes - .filter(s => this.titleOrAliasMatches(s, queryTitle)) - .map(async (anime) => { + const processTencentAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.mediaId); let links = []; diff --git a/danmu_api/sources/tmdb.js b/danmu_api/sources/tmdb.js index 6407506..f21c4d4 100644 --- a/danmu_api/sources/tmdb.js +++ b/danmu_api/sources/tmdb.js @@ -118,11 +118,19 @@ export default class TmdbSource extends BaseSource { async getEpisodes(id) {} - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { - return this.doubanSource.handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore); + /** + * 转发搜索结果处理至豆瓣源 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { + return this.doubanSource.handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore, querySeason); } async getEpisodeDanmu(id) {} formatComments(comments) {} -} \ No newline at end of file +} diff --git a/danmu_api/sources/vod.js b/danmu_api/sources/vod.js index 8e0ca3a..1626a05 100644 --- a/danmu_api/sources/vod.js +++ b/danmu_api/sources/vod.js @@ -4,7 +4,7 @@ import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches, sanitizeSearchKeyword } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, sanitizeSearchKeyword, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; // ===================== // 获取vod源播放链接 @@ -143,7 +143,16 @@ export default class VodSource extends BaseSource { async getEpisodes(id) {} - async handleAnimes(sourceAnimes, queryTitle, curAnimes, vodName, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {string} vodName VOD资源站名称 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, vodName, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -152,9 +161,27 @@ export default class VodSource extends BaseSource { return []; } - const processVodAnimes = await Promise.all(sourceAnimes - .filter(anime => titleMatches(anime.vod_name, queryTitle)) - .map(async (anime) => { + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(anime => titleMatches(anime.vod_name, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.vod_name).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[VOD] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processVodAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { let vodPlayFromList = anime.vod_play_from.split("$$$"); vodPlayFromList = vodPlayFromList.map(item => { diff --git a/danmu_api/sources/xigua.js b/danmu_api/sources/xigua.js index d7c7c86..3c37800 100644 --- a/danmu_api/sources/xigua.js +++ b/danmu_api/sources/xigua.js @@ -5,7 +5,7 @@ import { httpGet, buildQueryString } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== @@ -181,7 +181,15 @@ class XiguaSource extends BaseSource { } } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -190,10 +198,28 @@ class XiguaSource extends BaseSource { return []; } + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.name, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.name).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Xigua] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 - const processXiguaAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.name, queryTitle)) - .map(async (anime) => { + const processXiguaAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const albumId = anime.url.split('/').pop(); const eps = await this.getEpisodes(albumId); diff --git a/danmu_api/sources/youku.js b/danmu_api/sources/youku.js index 90d6e21..4ef0a2f 100644 --- a/danmu_api/sources/youku.js +++ b/danmu_api/sources/youku.js @@ -2,7 +2,7 @@ import BaseSource from './base.js'; import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { buildQueryString, httpGet, httpPost } from "../utils/http-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, getExplicitSeasonNumber, extractSeasonNumberFromAnimeTitle } from "../utils/common-util.js"; import { md5, convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; @@ -205,7 +205,15 @@ export default class YoukuSource extends BaseSource { return data; } - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + /** + * 处理搜索结果 + * @param {Array} sourceAnimes 原始数据 + * @param {string} queryTitle 关键词 + * @param {Array} curAnimes 结果池 + * @param {Map|null} detailStore 详情缓存 + * @param {number|null} querySeason 目标季度 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null, querySeason = null) { const tmpAnimes = []; // 添加错误处理,确保sourceAnimes是数组 @@ -214,9 +222,27 @@ export default class YoukuSource extends BaseSource { return []; } - const processYoukuAnimes = await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { + // 基础标题与季度匹配过滤 + let filteredAnimes = sourceAnimes.filter(s => titleMatches(s.title, queryTitle, querySeason)); + + // 提取搜索词中的明确季度信息或使用传入的季度参数 + const resolvedQuerySeason = querySeason !== null ? querySeason : getExplicitSeasonNumber(queryTitle); + + // 初始列表预过滤机制:若用户指定了季度,优先检查结果中是否已包含匹配项 + if (resolvedQuerySeason !== null) { + const seasonFiltered = filteredAnimes.filter(anime => { + const s = extractSeasonNumberFromAnimeTitle(anime.title).season; + return s === resolvedQuerySeason || (resolvedQuerySeason === 1 && s === null); + }); + + // 如果已命中目标,减少详情请求量 + if (seasonFiltered.length > 0) { + filteredAnimes = seasonFiltered; + log("info", `[Youku] 结果已命中目标季(第${resolvedQuerySeason}季),跳过非目标季相关请求`); + } + } + + const processYoukuAnimes = await Promise.all(filteredAnimes.map(async (anime) => { try { const eps = await this.getEpisodes(anime.mediaId); diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index 80dcbfd..685d8f8 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -260,9 +260,10 @@ export function getExplicitSeasonNumber(text) { * 标题匹配路由函数:支持严格模式,或 宽松模式下的"包含+相似度"混合策略 * @param {string} title - 动漫标题 * @param {string} query - 搜索关键词 + * @param {number|null} parsedSeason - 解析出的目标季度 * @returns {boolean} 是否匹配 */ -export function titleMatches(title, query) { +export function titleMatches(title, query, parsedSeason = null) { // 策略1:严格模式仅允许头部或完全匹配 if (globals.strictTitleMatch) return strictTitleMatch(title, query); @@ -281,7 +282,7 @@ export function titleMatches(title, query) { if (qList.some(kw => t.includes(kw))) return true; // 季度特征校验 (针对策略3的宽松相似度,防止字符集混淆导致季度错乱) - const querySeason = getExplicitSeasonNumber(query); + const querySeason = parsedSeason !== null ? parsedSeason : getExplicitSeasonNumber(query); if (querySeason !== null) { const titleSeason = getExplicitSeasonNumber(title); diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index 50315b1..ab46df6 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -25,6 +25,8 @@ function linkSignal(externalSignal, internalController) { export async function httpGet(url, options = {}) { // 从 options 中获取重试次数,默认为 0 const maxRetries = parseInt(options.retries || '0', 10) || 0; + // 提取允许放行的特定状态码白名单 + const validStatusCodes = Array.isArray(options.validStatusCodes) ? options.validStatusCodes : []; let lastError; // 执行请求,包含重试逻辑 @@ -71,7 +73,8 @@ export async function httpGet(url, options = {}) { clearTimeout(timeoutId); - if (!response.ok) { + // 非 2xx 且不在白名单内的状态码抛出异常 + if (!response.ok && !validStatusCodes.includes(response.status)) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -216,6 +219,7 @@ export async function httpGet(url, options = {}) { export async function httpPost(url, body, options = {}) { // 从 options 中获取重试次数,默认为 0 const maxRetries = parseInt(options.retries || '0', 10) || 0; + const validStatusCodes = Array.isArray(options.validStatusCodes) ? options.validStatusCodes : []; let lastError; // 执行请求,包含重试逻辑 @@ -267,8 +271,7 @@ export async function httpPost(url, body, options = {}) { const data = await response.text(); - - if (!response.ok) { + if (!response.ok && !validStatusCodes.includes(response.status)) { log("error", `[请求模拟] response data: `, data); throw new Error(`HTTP error! status: ${response.status}`); } @@ -349,6 +352,7 @@ async function httpRequestMethod(method, url, body, options = {}) { log("info", `[请求模拟] HTTP ${method}: ${url}`); const { headers = {} } = options; + const validStatusCodes = Array.isArray(options.validStatusCodes) ? options.validStatusCodes : []; const fetchOptions = { method, @@ -373,7 +377,7 @@ async function httpRequestMethod(method, url, body, options = {}) { const response = await fetch(url, fetchOptions); const textData = await response.text(); - if (!response.ok) { + if (!response.ok && !validStatusCodes.includes(response.status)) { log("error", `[请求模拟] response data: `, textData); throw new Error(`HTTP error! status: ${response.status}`); } diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index eef1741..eff9443 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -1780,7 +1780,7 @@ function fastCloneAnime(anime) { /** * 执行单个主源的合并任务 - * 包含:寻找匹配、ID生成、链接映射、集数补全、跨源合集时序接管、共识差精准对齐、番外专项制导 + * 包含:寻找匹配、ID生成、链接映射、集数补全、跨源合集时序接管、共识差精准对齐、番外专项制导、原子化元数据合并 * @param {Object} params - 任务参数对象 * @returns {Promise} 合并后的新对象或 null */ @@ -1797,14 +1797,20 @@ async function processMergeTask(params) { return null; } - const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId)); - if (!cachedPAnime?.links) { + // 以传入的最新对象为元数据基准进行克隆,确保包含数据流传递过程中的全量最新元数据 + let derivedAnime = fastCloneAnime(pAnime); + + if (!derivedAnime.links) { + const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId)); + derivedAnime.links = cachedPAnime?.links ? fastCloneAnime(cachedPAnime.links) : []; + } + + if (!derivedAnime.links || derivedAnime.links.length === 0) { log("warn", `${logPrefix} 主源数据不完整,跳过: ${pAnime.animeTitle}`); return null; } const logTitleA = pAnime.animeTitle.replace(RegexStore.Clean.FROM_SUFFIX, ''); - let derivedAnime = fastCloneAnime(cachedPAnime); const actualMergedSources = []; const contentSignatureParts = [pAnime.animeId]; @@ -2168,6 +2174,24 @@ async function processMergeTask(params) { link.url = mutation.newUrl; link.title = mutation.newTitle; } + // 处理别名 + const getBaseTitle = (t) => { + if (!t) return ''; + return t.replace(RegexStore.Clean.YEAR_TAG, '') + .replace(RegexStore.Clean.SOURCE_TAG, '') + .replace(RegexStore.Clean.FROM_SUFFIX, '') + .trim(); + }; + + const currentPrimaryBase = getBaseTitle(derivedAnime.animeTitle); + + derivedAnime.aliases = [...new Set([ + ...(derivedAnime.aliases || []), + ...(derivedMatch.aliases || []), + ...(match.aliases || []), + getBaseTitle(derivedMatch.animeTitle) + ])].filter(alias => alias && alias !== currentPrimaryBase); + log("info", `${logPrefix} 关联成功: [${currentPrimarySource}] ${logTitleA} <-> [${secSource}] ${logTitleB} (本次合并 ${mergedCount} 集)`); if (mappingEntries.length > 0) { mappingEntries.sort((a, b) => a.idx - b.idx); diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index b0ea150..cb9aabd 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -40,6 +40,7 @@ import { addAnime, addEpisode } from "./utils/cache-util.js"; import { convertToAsciiSum } from "./utils/codec-util.js"; import { handleDanmusLike } from "./utils/danmu-util.js"; import { Segment, SegmentListResponse } from "./models/dandan-model.js" +import { initBangumiData, searchBangumiData, clearBangumiDataCache } from "./utils/bangumi-data-util.js"; // Mock Request class for testing class MockRequest { @@ -1559,6 +1560,47 @@ test('worker.js API endpoints', async (t) => { // assert(res, `Expected res is true, but got ${res}`); // }); + // // 测试 Bangumi Data 本地检索功能与数据结构解析 + // await t.test('searchBangumiData', async () => { + // const originalUseBangumiData = Globals.getConfig().useBangumiData; + // Globals.getConfig().useBangumiData = true; + // try { + // // 确保 Bangumi Data 核心数据源加载至内存 + // await initBangumiData('node', true); + // const keyword = '间谍过家家'; + // const targetSites = ['gamer', 'gamer_hk']; + // // 执行本地内存级检索 + // const results = await searchBangumiData(keyword, targetSites); + // assert(Array.isArray(results), `Expected Array.isArray(results) to be true, but got ${typeof results}`); + // assert(results.length > 0, `Expected results.length > 0, but got ${results.length}`); + // if (results.length > 0) { + // assert(results[0].title !== undefined, `Expected results[0].title !== undefined`); + // assert(results[0].siteId !== undefined, `Expected results[0].siteId !== undefined`); + // } + // } finally { + // clearBangumiDataCache(); + // Globals.getConfig().useBangumiData = originalUseBangumiData; + // } + // }); + + // // 测试带有季度参数的精确拦截与检索机制 + // await t.test('searchAnimeWithSeason', async () => { + // const config = Globals.getConfig(); + // const originalSourceOrderArr = Array.isArray(config.sourceOrderArr) ? [...config.sourceOrderArr] : config.sourceOrderArr; + // config.sourceOrderArr = ['360','iqiyi','dandan','animeko']; + // try { + // // 构造带有 season 参数的 URL 请求对象以模拟 match 接口的内部下发 + // const targetUrl = new URL('http://localhost/search/anime?keyword=间谍过家家&season=2'); + // const response = await searchAnime(targetUrl); + // const data = await parseResponse(response); + // assert.equal(data.success, true); + // assert(Array.isArray(data.animes), `Expected Array.isArray(data.animes) to be true`); + // assert(data.animes.length > 0, `Expected data.animes.length > 0, but got ${data.animes.length}`); + // } finally { + // config.sourceOrderArr = originalSourceOrderArr; + // } + // }); + }); // // 测试本地 Redis 功能