From 90de8a29c596ef21dc94d2afe3a84a0f32c19960 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:34:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B4=E5=93=88=E5=A7=86=E7=89=B9=E4=B8=93?= =?UTF-8?q?=E9=A1=B9=E4=BC=98=E5=8C=96=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 增加反代模式判断 * 增强SOURCE_ORDER和PROXY_URL环境变量 1.SOURCE_ORDER中用户可以为bahamut添加后缀@tc实现不繁转简; 2.PROXY_URL新增反代模式判断 * TMDB搜索增加类型判断 并在搜索流程中增加检查点实现可被中断 * 增加反代模式,第二次搜索并行执行并可被打断 并统一搜索构建 * Update README.md * Update .env.example * Update README.md * Update .env.example * 规范化,繁转简控制改为单独的环境变量DANMU_SIMPLIFIED * 规范化,繁转简控制改为单独的环境变量DANMU_SIMPLIFIED * 增加PROXY_URL控制变量 * 增加反代模式判断 * 规范化调整 * 更新DANMU_SIMPLIFIED * Update .env.example * 去除不必要的 * 实际并不需要修改这个文件 * Update globals.js * Update globals.js * Update globals.js --- .env.example | 20 +++- README.md | 5 +- danmu_api/configs/envs.js | 5 +- danmu_api/configs/globals.js | 2 +- danmu_api/server.js | 60 +++++++--- danmu_api/sources/bahamut.js | 212 ++++++++++++++++++++++++----------- danmu_api/utils/tmdb-util.js | 134 ++++++++++++++++------ 7 files changed, 311 insertions(+), 127 deletions(-) diff --git a/.env.example b/.env.example index 760c432..9754246 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,12 @@ CONVERT_TOP_BOTTOM_TO_SCROLL=false # 可选值:true, false CONVERT_COLOR_TO_WHITE=false +# 是否将繁体弹幕转换为简体,目前只对巴哈姆特源生效 +# 默认值:true(转换) +# 说明:禁用后,不进行巴哈弹幕繁转简 +# 可选值:true, false +DANMU_SIMPLIFIED=true + # ==================== 弹幕输出格式配置 ==================== # 弹幕输出格式(全局默认格式) @@ -145,17 +151,21 @@ STRICT_TITLE_MATCH=false # ==================== 代理配置 ==================== -# 代理地址(用于访问受限资源) -# 默认值:空(不使用代理) -# 格式:http://proxy-server:port 或 https://proxy-server:port -# 说明:目前只对巴哈姆特生效,如果使用 Docker 部署并且访问不了 bahamut 源,请配置代理地址 +# 代理/反代地址(用于访问受限资源) +# 默认值:空(不使用代理/反代) +# 格式: +# - 设置为代理为 http://proxy-server:port 或 https://proxy-server:port +# - 设置为反代为 RP@http://proxy-server:port 或 RP@https://proxy-server +# 说明:目前只对巴哈姆特生效,如果使用 Docker 部署并且访问不了 bahamut 源,请配置代理/反代地址 +# 反代教程参考:https://github.com/wan0ge/bahamut-api-proxy # PROXY_URL= # ==================== TMDB 配置 ==================== # TMDB API Key(用于巴哈姆特源的译名转换) # 默认值:空(不使用 TMDB) -# 说明:配置后当巴哈第一次搜索无结果时自动从 TMDB 获取日语原名搜索 +# 说明:配置后并行从 TMDB 获取日语原名搜索巴哈 +# 如果TMDB条目类型不是动画或制作地区不是jp则不会进行巴哈搜索 # 可以解决巴哈译名不同导致的搜索无结果问题(例如:间谍过家家 vs 間諜家家酒) # 获取方法参考:https://www.ugnas.com/tutorial-detail/id-226.html # TMDB_API_KEY= diff --git a/README.md b/README.md index c9e2315..1f8ca79 100644 --- a/README.md +++ b/README.md @@ -321,8 +321,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | CONVERT_TOP_BOTTOM_TO_SCROLL | 【可选】是否将顶部和底部弹幕转换为浮动弹幕,默认为`false`(不转换),启用后顶部弹幕(ct=5)和底部弹幕(ct=4)会被转换为浮动弹幕(ct=1),可选值:`true`、`false` | | CONVERT_COLOR_TO_WHITE | 【可选】是否将彩色弹幕转换为纯白弹幕,默认为`false`(不转换),启用后所有非白色的弹幕颜色会被转换为纯白色(16777215),可选值:`true`、`false` | | DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 | -| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/netlify/cf中理应都自然能联通,不用填写 | -| TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后当巴哈第一次搜索无结果时自动从TMDB获取日语原名搜索,可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) | +| DANMU_SIMPLIFIED | 【可选】是否将繁体弹幕转换为简体,目前只对巴哈姆特生效,默认为`true`(转换),可选值:`false`(不转换) | +| PROXY_URL | 【可选】巴哈代理/反代地址,示例: `http://127.0.0.1:7897`(代理) `RP@http://127.0.0.1`(反代),目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 | +| TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后并行从TMDB获取日语原名搜索巴哈(如果TMDB条目类型不是动画或制作地区不是jp则不会进行巴哈搜索)可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) | | RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 | | LOG_LEVEL | 【可选】日志级别,默认为`info`,可选值:`error`(仅错误)、`warn`(错误和警告)、`info`(所有日志),生产环境建议使用`warn`,调试时使用`info` | | SEARCH_CACHE_MINUTES | 【可选】搜索结果缓存时间(分钟),默认为`1`,避免短期内重复的不必要API请求,同时保证获取最新的结果列表,可根据需要调整:Vercel/Cloudflare建议`1-5`分钟,Docker可设置`5-30`分钟,设置为`0`表示不缓存 | diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 343b5ef..ce5e7d2 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -191,7 +191,8 @@ export class Envs { episodeTitleFilter: this.resolveEpisodeTitleFilter(env), // 剧集标题正则过滤 blockedWords: this.get('BLOCKED_WORDS', '', 'string'), // 屏蔽词列表 groupMinute: Math.min(this.get('GROUP_MINUTE', 1, 'number'), 30), // 分钟内合并去重(默认 1,最大值30,0表示不去重) - proxyUrl: this.get('PROXY_URL', '', 'string'), // 代理地址 + proxyUrl: this.get('PROXY_URL', '', 'string', true), // 代理/反代地址 + danmuSimplified: this.get('DANMU_SIMPLIFIED', true, 'boolean'), // 弹幕繁体转简体开关 tmdbApiKey: this.get('TMDB_API_KEY', '', 'string', true), // TMDB API KEY redisUrl: this.get('UPSTASH_REDIS_REST_URL', '', 'string', true), // upstash redis url redisToken: this.get('UPSTASH_REDIS_REST_TOKEN', '', 'string', true), // upstash redis url @@ -206,4 +207,4 @@ export class Envs { strictTitleMatch: this.get('STRICT_TITLE_MATCH', false, 'boolean') // 严格标题匹配模式配置(默认 false,宽松模糊匹配) }; } -} \ No newline at end of file +} diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index c1dc8e0..b601602 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -109,4 +109,4 @@ export const globals = new Proxy({}, { getOwnPropertyDescriptor(target, prop) { return Object.getOwnPropertyDescriptor(Globals.getConfig(), prop); } -}); \ No newline at end of file +}); diff --git a/danmu_api/server.js b/danmu_api/server.js index 9e10c89..e3bb0c6 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -274,29 +274,54 @@ function createProxyServer() { if (queryObject.url) { const targetUrl = queryObject.url; - console.log('Target URL:', targetUrl); + console.log('[Proxy Server] Target URL:', targetUrl); - // 从环境变量获取代理地址 - const proxyUrl = process.env.PROXY_URL; - - const urlObj = new URL(targetUrl); - const options = { - hostname: urlObj.hostname, - port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), - path: urlObj.pathname + urlObj.search, - method: 'GET' + // 从环境变量获取代理配置 + let proxyConfig = process.env.PROXY_URL; + + const originalUrlObj = new URL(targetUrl); + let options = { + hostname: originalUrlObj.hostname, + port: originalUrlObj.port || (originalUrlObj.protocol === 'https:' ? 443 : 80), + path: originalUrlObj.pathname + originalUrlObj.search, + method: 'GET', + headers: { ...req.headers } // 传递原始请求头 }; + // Host 头必须被移除,以便 protocol.request 根据 options.hostname 设置正确的值 + delete options.headers.host; + + let protocol = originalUrlObj.protocol === 'https:' ? https : http; - // 如果设置了代理,则使用代理 - if (proxyUrl) { - options.agent = new HttpsProxyAgent(proxyUrl); - console.log('Using proxy:', proxyUrl); + // 检查反代模式 (RP@) + if (proxyConfig && proxyConfig.startsWith("RP@")) { + console.log('[Proxy Server] Reverse proxy mode detected'); + const reverseProxyUrlStr = proxyConfig.substring(3).trim().replace(/\/+$/, ''); + + try { + const reverseUrlObj = new URL(reverseProxyUrlStr); + options.hostname = reverseUrlObj.hostname; + options.port = reverseUrlObj.port || (reverseUrlObj.protocol === 'https:' ? 443 : 80); + // 路径合并:/reverse/proxy/path + /original/path?query + options.path = (reverseUrlObj.pathname.replace(/\/$/, '')) + originalUrlObj.pathname + originalUrlObj.search; + protocol = reverseUrlObj.protocol === 'https:' ? https : http; + + console.log(`[Proxy Server] Rewriting to RP: ${protocol === https ? 'https' : 'http'}://${options.hostname}:${options.port}${options.path}`); + } catch (e) { + console.error('[Proxy Server] Invalid RP@ URL:', reverseProxyUrlStr, e.message); + res.statusCode = 500; + res.end('Proxy Error: Invalid Reverse Proxy URL'); + return; + } + + } else if (proxyConfig) { + // 代理模式:使用 HttpsProxyAgent + console.log('[Proxy Server] Using proxy agent:', proxyConfig); + options.agent = new HttpsProxyAgent(proxyConfig); } else { - console.log('No proxy configured, direct connection'); + // 直连模式 + console.log('[Proxy Server] No proxy configured, direct connection'); } - const protocol = urlObj.protocol === 'https:' ? https : http; - const proxyReq = protocol.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res, { end: true }); @@ -316,6 +341,7 @@ function createProxyServer() { }); } + // --- 启动函数 --- // 同步启动(最优/默认路径,适用于常规已兼容环境) function startServerSync() { diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index db05f57..4a40c0a 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -11,85 +11,156 @@ import { strictTitleMatch } from "../utils/common-util.js"; // ===================== // 获取巴哈姆特弹幕 // ===================== + +// 具体搜索部分 export default class BahamutSource extends BaseSource { async search(keyword) { try { // 在函数内部进行简转繁 const traditionalizedKeyword = traditionalized(keyword); - - // TMDB 搜索直接使用传入的原始 keyword const tmdbSearchKeyword = keyword; - - // 使用 traditionalizedKeyword 进行巴哈姆特搜索 const encodedKeyword = encodeURIComponent(traditionalizedKeyword); - const url = globals.proxyUrl - ? `http://127.0.0.1:5321/proxy?url=https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedKeyword}` - : `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedKeyword}`; + + log("info", `[Bahamut] 原始搜索词: ${keyword}`); + log("info", `[Bahamut] 巴哈使用搜索词: ${traditionalizedKeyword}`); - log("info", `[Bahamut] 传入原始搜索词: ${keyword}`); - log("info", `[Bahamut] 使用巴哈搜索词: ${traditionalizedKeyword}`); + // 创建一个 AbortController 用于取消 TMDB 流程 + const tmdbAbortController = new AbortController(); - const originalResp = await httpGet(url, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Anime/2.29.2 (7N5749MM3F.tw.com.gamer.anime; build:972; iOS 26.0.0) Alamofire/5.6.4", - }, - }); + // 第一次搜索:繁体词搜索 + const originalSearchPromise = (async () => { + try { + const apiUrl = `/mobile_app/anime/v1/search.php?kw=${encodedKeyword}`; + const originalApiUrl = `https://api.gamer.com.tw${apiUrl}`; + const url = globals.proxyUrl + ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(originalApiUrl)}` + : originalApiUrl; + + const originalResp = await httpGet(url, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Anime/2.29.2 (7N5749MM3F.tw.com.gamer.anime; build:972; iOS 26.0.0) Alamofire/5.6.4", + }, + }); - // 如果原始搜索有结果,直接返回(并在结果上标注实际用于搜索的字符串) - if ( - originalResp && - originalResp.data && - originalResp.data.anime && - originalResp.data.anime.length > 0 - ) { - const anime = originalResp.data.anime; - // 实际用于 bahamut 搜索的关键字(用于后续匹配参考) - for (const a of anime) { - try { - a._originalQuery = keyword; - a._searchUsedTitle = traditionalizedKeyword; - } catch (e) {} + // 如果原始搜索有结果,中断 TMDB 流程 + if ( + originalResp && + originalResp.data && + originalResp.data.anime && + originalResp.data.anime.length > 0 + ) { + tmdbAbortController.abort(); + const anime = originalResp.data.anime; + for (const a of anime) { + try { + a._originalQuery = keyword; + a._searchUsedTitle = traditionalizedKeyword; + } catch (e) {} + } + log("info", `bahamutSearchresp (original): ${JSON.stringify(anime)}`); + log("info", `[Bahamut] 返回 ${anime.length} 条结果 (source: original)`); + return { success: true, data: anime, source: 'original' }; + } + + log("info", `[Bahamut] 原始搜索成功,但未返回任何结果 (source: original)`); + return { success: false, source: 'original' }; + } catch (error) { + // ️捕获原始搜索错误,但不阻塞 TMDB 搜索 + log("error", "[Bahamut] 原始搜索失败:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return { success: false, source: 'original' }; } - log("info", `bahamutSearchresp (original): ${JSON.stringify(anime)}`); - log("info", `[Bahamut] 返回 ${anime.length} 条结果 (source: original)`); - return anime; + })(); + + // 第二次搜索:TMDB转换后搜索(并行执行) + const tmdbSearchPromise = (async () => { + try { + // 延迟100毫秒,避免与原始搜索争抢同一连接池 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 内部中断检查 (点1) + if (tmdbAbortController.signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + // 如果类型不符合会返回null,自然跳过后续搜索 + const tmdbTitle = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal); + + // 检查 getTmdbJaOriginalTitle 执行期间是否被中断 + if (tmdbAbortController.signal.aborted) { + log("info", "[Bahamut] 原始搜索成功,取消TMDB日语原名获取"); + throw new DOMException('Aborted', 'AbortError'); + } + + if (!tmdbTitle) { + log("info", "[Bahamut] TMDB转换未返回结果,取消日语原名搜索"); + return { success: false, source: 'tmdb' }; + } + + // 内部中断检查 (点2) + if (tmdbAbortController.signal.aborted) { + log("info", "[Bahamut] 原始搜索成功,取消日语原名搜索"); + throw new DOMException('Aborted', 'AbortError'); + } + + log("info", `[Bahamut] 使用日语原名进行搜索: ${tmdbTitle}`); + const encodedTmdbTitle = encodeURIComponent(tmdbTitle); + const tmdbApiUrl = `/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}`; + const tmdbOriginalUrl = `https://api.gamer.com.tw${tmdbApiUrl}`; + const tmdbSearchUrl = globals.proxyUrl + ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(tmdbOriginalUrl)}` + : tmdbOriginalUrl; + + const tmdbResp = await httpGet(tmdbSearchUrl, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Anime/2.29.2 (7N5749MM3F.tw.com.gamer.anime; build:972; iOS 26.0.0) Alamofire/5.6.4", + }, + }); + + if (tmdbResp && tmdbResp.data && tmdbResp.data.anime && tmdbResp.data.anime.length > 0) { + const anime = tmdbResp.data.anime; + for (const a of anime) { + try { + a._originalQuery = keyword; + a._searchUsedTitle = tmdbTitle; + } catch (e) {} + } + log("info", `bahamutSearchresp (TMDB): ${JSON.stringify(anime)}`); + log("info", `[Bahamut] 返回 ${anime.length} 条结果 (source: tmdb)`); + return { success: true, data: anime, source: 'tmdb' }; + } + log("info", `[Bahamut] 日语原名搜索成功,但未返回任何结果 (source: tmdb)`); + return { success: false, source: 'tmdb' }; + } catch (error) { + // 捕获被中断的错误 + if (error.name === 'AbortError') { + log("info", "[Bahamut] 原始搜索成功,中断日语原名搜索"); + return { success: false, source: 'tmdb', aborted: true }; + } + // 抛出其他错误(例如 httpGet 超时) + throw error; + } + })(); + + // 如果两个搜索同时完成,优先采用原始搜索结果 + const [originalResult, tmdbResult] = await Promise.all([ + originalSearchPromise, + tmdbSearchPromise + ]); + + // 优先返回原始搜索结果 + if (originalResult.success) { + return originalResult.data; } - // 原始搜索没有结果时,才调用 TMDB 转换(顺序执行) - log("info", "[Bahamut] 原始搜索未返回结果,尝试转换TMDB标题..."); - const tmdbTitle = await getTmdbJaOriginalTitle(tmdbSearchKeyword); // 使用原始 keyword (tmdbSearchKeyword) - - if (!tmdbTitle) { - log("info", "[Bahamut] TMDB转换未返回标题,中止搜索并转入备用方案."); - return []; - } - - log("info", `[Bahamut] 使用TMDB标题进行搜索: ${tmdbTitle}`); - // 确保 TMDB 标题也被编码 - const encodedTmdbTitle = encodeURIComponent(tmdbTitle); - const tmdbSearchUrl = globals.proxyUrl - ? `http://127.0.0.1:5321/proxy?url=https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}` - : `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}`; - const tmdbResp = await httpGet(tmdbSearchUrl, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Anime/2.29.2 (7N5749MM3F.tw.com.gamer.anime; build:972; iOS 26.0.0) Alamofire/5.6.4", - }, - }); - - if (tmdbResp && tmdbResp.data && tmdbResp.data.anime && tmdbResp.data.anime.length > 0) { - const anime = tmdbResp.data.anime; - // 保留 original query 与 实际用于 bahamut 搜索的标题(TMDB 的标题) - for (const a of anime) { - try { - a._originalQuery = keyword; - a._searchUsedTitle = tmdbTitle; - } catch (e) {} - } - log("info", `bahamutSearchresp (TMDB): ${JSON.stringify(anime)}`); - log("info", `[Bahamut] 返回 ${anime.length} 条结果 (source: tmdb)`); - return anime; + // 原始搜索无结果,返回TMDB搜索结果 + if (tmdbResult.success) { + return tmdbResult.data; } log("info", "[Bahamut] 原始搜索和基于TMDB的搜索均未返回任何结果"); @@ -107,8 +178,10 @@ export default class BahamutSource extends BaseSource { async getEpisodes(id) { try { + // 构建剧集信息 URL const targetUrl = `https://api.gamer.com.tw/anime/v1/video.php?videoSn=${id}`; const url = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(targetUrl)}` : targetUrl; + const resp = await httpGet(url, { headers: { "Content-Type": "application/json", @@ -278,8 +351,10 @@ export default class BahamutSource extends BaseSource { let danmus = []; try { + // 构建弹幕 URL const targetUrl = `https://api.gamer.com.tw/anime/v1/danmu.php?geo=TW%2CHK&videoSn=${id}`; const url = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(targetUrl)}` : targetUrl; + const resp = await httpGet(url, { headers: { "Content-Type": "application/json", @@ -309,8 +384,9 @@ export default class BahamutSource extends BaseSource { return comments.map(c => ({ cid: Number(c.sn), p: `${Math.round(c.time / 10).toFixed(2)},${positionToMode[c.position] || c.tp},${parseInt(c.color.slice(1), 16)},[bahamut]`, - m: simplized(c.text), + // 根据 globals.danmuSimplified 控制是否繁转简 + m: globals.danmuSimplified ? simplized(c.text) : c.text, t: Math.round(c.time / 10) })); } -} \ No newline at end of file +} diff --git a/danmu_api/utils/tmdb-util.js b/danmu_api/utils/tmdb-util.js index f859347..e7a53d3 100644 --- a/danmu_api/utils/tmdb-util.js +++ b/danmu_api/utils/tmdb-util.js @@ -7,54 +7,110 @@ import { httpGet } from "./http-util.js"; // --------------------- // 使用TMDB API 查询日语原名搜索bahamut相关函数 -export async function getTmdbJaOriginalTitle(title) { +export async function getTmdbJaOriginalTitle(title, signal = null) { if (!globals.tmdbApiKey) { log("info", "[TMDB] 未配置API密钥,跳过TMDB搜索"); return null; } try { - // --------------------- - // 相似度函数 - // --------------------- - function similarity(s1, s2) { + // 内部函数:判断单个媒体是否为动画或日语内容 + const isValidContent = (mediaInfo) => { + const genreIds = mediaInfo.genre_ids || []; + const genres = mediaInfo.genres || []; + const allGenreIds = genreIds.length > 0 ? genreIds : genres.map(g => g.id); + const originalLanguage = mediaInfo.original_language || ''; + const ANIMATION_GENRE_ID = 16; + + // 动画类型直接通过 + if (allGenreIds.includes(ANIMATION_GENRE_ID)) { + return { isValid: true, reason: "明确动画类型(genre_id: 16)" }; + } + + // 日语内容通过(涵盖日剧、日影、日综艺) + if (originalLanguage === 'ja') { + return { isValid: true, reason: `原始语言为日语(ja),可能是日剧/日影/日综艺` }; + } + + return { + isValid: false, + reason: `非动画且非日语内容(language: ${originalLanguage}, genres: ${allGenreIds.join(',')})` + }; + }; + + // 内部函数:批量验证搜索结果 + const validateResults = (results) => { + if (!results || results.length === 0) { + return { + hasValid: false, + validCount: 0, + totalCount: 0, + details: "搜索结果为空" + }; + } + + let validCount = 0; + const validItems = []; + + for (const item of results) { + const validation = isValidContent(item); + if (validation.isValid) { + validCount++; + const itemTitle = item.name || item.title || "未知"; + validItems.push(`${itemTitle}(${validation.reason})`); + } + } + + return { + hasValid: validCount > 0, + validCount: validCount, + totalCount: results.length, + details: validCount > 0 + ? `找到${validCount}个符合条件的内容: ${validItems.slice(0, 3).join(', ')}${validCount > 3 ? '...' : ''}` + : `所有${results.length}个结果均不符合条件(非动画且非日语)` + }; + }; + + // 相似度计算函数 + const similarity = (s1, s2) => { const longer = s1.length > s2.length ? s1 : s2; const shorter = s1.length > s2.length ? s2 : s1; if (longer.length === 0) return 1.0; - - const editDistance = (s1, s2) => { - s1 = s1.toLowerCase(); - s2 = s2.toLowerCase(); + + const editDistance = (str1, str2) => { + str1 = str1.toLowerCase(); + str2 = str2.toLowerCase(); const costs = []; - for (let i = 0; i <= s1.length; i++) { + for (let i = 0; i <= str1.length; i++) { let lastValue = i; - for (let j = 0; j <= s2.length; j++) { + for (let j = 0; j <= str2.length; j++) { if (i === 0) { costs[j] = j; } else if (j > 0) { let newValue = costs[j - 1]; - if (s1.charAt(i - 1) !== s2.charAt(j - 1)) { + if (str1.charAt(i - 1) !== str2.charAt(j - 1)) { newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; } costs[j - 1] = lastValue; lastValue = newValue; } } - if (i > 0) costs[s2.length] = lastValue; + if (i > 0) costs[str2.length] = lastValue; } - return costs[s2.length]; + return costs[str2.length]; }; - + return (longer.length - editDistance(longer, shorter)) / longer.length; - } + }; - // --------------------- - // 第一步: 中文搜索 - // --------------------- + // 第一步:TMDB搜索 const searchUrlZh = `https://api.tmdb.org/3/search/multi?api_key=${globals.tmdbApiKey}&query=${encodeURIComponent(title)}&language=zh-CN`; + log("info", `[TMDB] 正在搜索: ${title}`); - log("info", `[TMDB] 正在搜索(中文): ${title}`); - + // 内部中断检查 + if (signal && signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } const respZh = await httpGet(searchUrlZh, { headers: { "Content-Type": "application/json", @@ -63,18 +119,28 @@ export async function getTmdbJaOriginalTitle(title) { }); if (!respZh || !respZh.data) { - log("info", "[TMDB] 中文搜索结果为空"); + log("info", "[TMDB] TMDB搜索结果为空"); return null; } const dataZh = typeof respZh.data === "string" ? JSON.parse(respZh.data) : respZh.data; if (!dataZh.results || dataZh.results.length === 0) { - log("info", "[TMDB] 中文搜索未找到任何结果"); + log("info", "[TMDB] TMDB未找到任何结果"); return null; } - // 找到最相似的结果(使用中文标题) + // 第二步:类型验证(宽松策略:只要有一个符合就继续) + const validationResult = validateResults(dataZh.results); + + if (!validationResult.hasValid) { + log("info", `[TMDB] 类型判断未通过,跳过后续搜索: ${validationResult.details}`); + return null; + } + + log("info", `[TMDB] 类型判断通过: ${validationResult.details}`); + + // 第三步:找到最相似的结果 let bestMatch = dataZh.results[0]; let bestScore = 0; @@ -87,14 +153,16 @@ export async function getTmdbJaOriginalTitle(title) { } } - log("info", `[TMDB] 最佳匹配(中文): ${bestMatch.name || bestMatch.title}, 相似度: ${(bestScore * 100).toFixed(2)}%`); + log("info", `[TMDB] TMDB最佳匹配: ${bestMatch.name || bestMatch.title}, 相似度: ${(bestScore * 100).toFixed(2)}%`); - // --------------------- - // 第二步: 使用匹配到的ID,用日语语言查询详情页获取原名 - // --------------------- + // 第四步:获取日语详情 const mediaType = bestMatch.media_type || (bestMatch.name ? "tv" : "movie"); const detailUrl = `https://api.tmdb.org/3/${mediaType}/${bestMatch.id}?api_key=${globals.tmdbApiKey}&language=ja-JP`; + // 内部中断检查 + if (signal && signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } const detailResp = await httpGet(detailUrl, { headers: { "Content-Type": "application/json", @@ -103,7 +171,6 @@ export async function getTmdbJaOriginalTitle(title) { }); if (!detailResp || !detailResp.data) { - // 获取详情失败,返回中文搜索结果标题 const fallbackTitle = bestMatch.name || bestMatch.title; log("info", `[TMDB] 使用中文搜索结果标题: ${fallbackTitle}`); return fallbackTitle; @@ -111,13 +178,16 @@ export async function getTmdbJaOriginalTitle(title) { const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data; - // 优先使用日语原名 original_name/original_title const jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title; - log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle} (中文匹配相似度: ${(bestScore * 100).toFixed(2)}%)`); + log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle} (相似度: ${(bestScore * 100).toFixed(2)}%)`); return jaOriginalTitle; } catch (error) { + if (error.name === 'AbortError') { + log("info", "[TMDB] 搜索已被中断"); + return null; + } log("error", "[TMDB] Search error:", { message: error.message, name: error.name, @@ -125,4 +195,4 @@ export async function getTmdbJaOriginalTitle(title) { }); return null; } -} \ No newline at end of file +}