From 435e22210a6c8e12f7e6b9464f9ed68926e19233 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:02:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BAbilibili=E6=94=AF=E6=8C=81=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=AE=9E=E7=8E=B0=E6=B8=AF?= =?UTF-8?q?=E6=BE=B3=E5=8F=B0=E6=90=9C=E7=B4=A2*2=EF=BC=8C=E4=B8=BA?= =?UTF-8?q?=E4=BA=91=E9=83=A8=E7=BD=B2=E6=94=AF=E6=8C=81=E5=8F=8D=E5=90=91?= =?UTF-8?q?=E4=BB=A3=E7=90=86=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 去除反向代理相关判断,即将迁移至globals.js文件 * 增加反向代理相关判断,为云部署支持反向代理配置 * 增加流式传输方法,增加外部中断控制器 b站app接口无结果会返回高达3.69mb的推荐数据,这是人啊*2 外部中断控制器实现了原始搜索成功真打断tmdb搜索 * 支持外部随时传入的真中断,同时实现并发请求合并 * 去除非必要的点位检查,同时使用新的代理URL构建 * 实现web与app接口的港澳台番剧搜索与获取 目前只能通过万能反代或特定反代开启。实际实现很简单,但因为全网没有一个喂饭教程所以踩了超级超级多的坑,但也是多亏了踩坑现在对于港澳台的兼容非常非常健壮😵*2 * 更新说明 * 更新说明 * 更新文档 --- README.md | 5 +- config/.env.example | 5 +- config/config.yaml.example | 11 +- danmu_api/configs/globals.js | 77 +++++ danmu_api/server.js | 106 ++---- danmu_api/sources/bahamut.js | 28 +- danmu_api/sources/bilibili.js | 409 +++++++++++++++++----- danmu_api/utils/http-util.js | 164 ++++++++- danmu_api/utils/tmdb-util.js | 619 +++++++++++++++++++--------------- 9 files changed, 960 insertions(+), 464 deletions(-) diff --git a/README.md b/README.md index 1c00e4f..bba5c08 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`,不填默认为`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top` | | VOD_RETURN_MODE | 【可选】VOD返回模式,可选值:`all`(返回所有站点结果)、`fastest`(只返回最快的站点结果),默认为`fastest`。当配置多个VOD站点时,`all`模式会返回所有站点的结果(结果较多),`fastest`模式只返回首先响应成功的站点结果(结果较少,避免重复) | | VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 | -| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 | +| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | | SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,custom` | | PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, custom` | @@ -387,7 +387,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | CONVERT_COLOR | 【可选】弹幕转换颜色配置,默认为`default`(不转换),`white` 将所有非白色的弹幕颜色转换为纯白色,`color` 将所有白色弹幕转换为随机颜色(包含白色),可选值:`default`、`white`、`color` | | DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 | | DANMU_SIMPLIFIED | 【可选】是否将繁体弹幕转换为简体,目前只对巴哈姆特生效,默认为`true`(转换),可选值:`false`(不转换) | -| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特和TMDB API生效,支持格式:
`http://127.0.0.1:7890`(正常代理)
`@http://127.0.0.1`(万能反代)
`bahamut@http://127.0.0.1` 或 `tmdb@http://127.0.0.1`(特定反代)
`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`(混合配置)
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 | +| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili生效,支持格式:
正常代理:`http://127.0.0.1:7890`
万能反代:`@http://127.0.0.1`
特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)
混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](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` | @@ -567,6 +567,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 - cloudflare貌似有单次请求数量限制,会导致后半部分没有弹幕。 - 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。 - 如果想使用自定义弹幕源,请添加环境变量`CUSTOM_SOURCE_API_URL`,并在`SOURCE_ORDER`环境变量中添加`custom`源。 +- 如果想启用bilibili港澳台番剧弹幕源,请添加环境变量`PROXY_URL`并填写`bilibili@`字段的解析/反代服务地址,示例:`bilibili@https://233.233.233`,支持部分[公共解析服务器](https://github.com/yujincheng08/BiliRoaming/wiki/%E5%85%AC%E5%85%B1%E8%A7%A3%E6%9E%90%E6%9C%8D%E5%8A%A1%E5%99%A8),另外港澳台区域搜索最好在`BILIBILI_COOKIE`环境变量中加入包含`bili_jct`或`access_key`字段的cookie使用App接口,如果没有会使用不稳定的web接口进行搜索。(如果你填写的服务器遇到了App接口报错说明不支持App接口,Web接口报错-500、502正常,风控严重,但只要一直搜索总会成功) - 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`(支持多个服务器并发查询)。 - 当配置多个VOD站点时,可通过`VOD_RETURN_MODE`环境变量控制返回结果方式:`all`(返回所有站点结果)或`fastest`(默认,只返回最快的站点结果,避免结果过多)。 - 推荐vercel/netlify部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。 diff --git a/config/.env.example b/config/.env.example index 8688a70..54d503f 100644 --- a/config/.env.example +++ b/config/.env.example @@ -199,7 +199,10 @@ STRICT_TITLE_MATCH=false # - 设置为万能反代为 @http://proxy-server:port 或 @https://proxy-server # - 设置为巴哈反代为 bahamut@http://proxy-server:port 或 bahamut@https://proxy-server # - 设置为TMDB反代为 tmdb@http://proxy-server:port 或 tmdb@https://proxy-server -# 说明:目前只对巴哈姆特和TMDB API生效,如果使用 Docker 部署并且访问不了 bahamut 源或 TMDB API ,请配置代理/反代地址 +# - 设置为bilibili反代为 bilibili@http://proxy-server:port 或 bilibili@https://proxy-server +# 说明:目前只对巴哈姆特、TMDB API、bilibili生效 +# 如果使用 Docker 部署并且访问不了 bahamut 源或 TMDB API ,请配置代理/反代地址 +# 如果想启用B站港澳台番剧的弹幕搜索与获取请配置反代/解析服务器地址 # 反代教程参考:https://github.com/wan0ge/bahamut-api-proxy # PROXY_URL= diff --git a/config/config.yaml.example b/config/config.yaml.example index 6180f6d..0c2d35c 100644 --- a/config/config.yaml.example +++ b/config/config.yaml.example @@ -191,9 +191,14 @@ STRICT_TITLE_MATCH: false # 代理/反代地址(用于访问受限资源) # 默认值:空(不使用代理/反代) # 格式: -# - 设置为代理为 http://proxy-server:port 或 https://proxy-server:port -# - 设置为反代为 RP@http://proxy-server:port 或 RP@https://proxy-server -# 说明:目前只对巴哈姆特生效,如果使用 Docker 部署并且访问不了 bahamut 源,请配置代理/反代地址 +# - 设置为代理为 http://proxy-server:port 或 https://proxy-server:port +# - 设置为万能反代为 @http://proxy-server:port 或 @https://proxy-server +# - 设置为巴哈反代为 bahamut@http://proxy-server:port 或 bahamut@https://proxy-server +# - 设置为TMDB反代为 tmdb@http://proxy-server:port 或 tmdb@https://proxy-server +# - 设置为bilibili反代为 bilibili@http://proxy-server:port 或 bilibili@https://proxy-server +# 说明:目前只对巴哈姆特、TMDB API、bilibili生效 +# 如果使用 Docker 部署并且访问不了 bahamut 源或 TMDB API ,请配置代理/反代地址 +# 如果想启用B站港澳台番剧的弹幕搜索与获取请配置反代/解析服务器地址 # 反代教程参考:https://github.com/wan0ge/bahamut-api-proxy # PROXY_URL: "" diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 3987f1f..4dd1d60 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -63,6 +63,80 @@ export const Globals = { return this.getConfig(); }, + /** + * 智能构建代理URL + * 逻辑:专用反代/万能反代直接替换/拼接URL(无视平台);正向代理走5321端口(仅本地Node有效) + * @param {string} targetUrl 原始目标URL + * @returns {string} 处理后的URL + */ + makeProxyUrl(targetUrl) { + const proxyConfig = this.envs.proxyUrl || ''; + + if (!proxyConfig || !targetUrl) return targetUrl; + + const configs = proxyConfig.split(',').map(s => s.trim()).filter(s => s); + let forwardProxy = null; + let specificProxy = null; + let universalProxy = null; + + let targetObj; + try { + targetObj = new URL(targetUrl); + } catch (e) { + return targetUrl; + } + + const hostname = targetObj.hostname; + + // 解析配置优先级 + for (const conf of configs) { + if (conf.startsWith('bahamut@') && hostname.includes('gamer.com.tw')) { + specificProxy = conf.substring(8); + break; + } else if (conf.startsWith('tmdb@') && hostname.includes('tmdb')) { + specificProxy = conf.substring(5); + break; + } else if (conf.startsWith('bilibili@') && hostname.includes('bilibili')) { + specificProxy = conf.substring(9); + break; + } else if (conf.startsWith('@') && !universalProxy) { + universalProxy = conf.substring(1); + } else if (!conf.includes('@') && !forwardProxy) { + forwardProxy = conf; + } + } + + // 1. 专用反代 (直接替换 Protocol + Host + Port + PathPrefix) + if (specificProxy) { + try { + const proxyObj = new URL(specificProxy); + targetObj.protocol = proxyObj.protocol; + targetObj.host = proxyObj.host; + targetObj.port = proxyObj.port; + // 如果反代URL包含路径前缀,则拼接到前面 + if (proxyObj.pathname !== '/') { + targetObj.pathname = proxyObj.pathname.replace(/\/$/, '') + targetObj.pathname; + } + return targetObj.toString(); + } catch (e) { + return targetUrl; + } + } + + // 2. 万能反代 (拼接: ProxyURL + TargetURL) + if (universalProxy) { + const cleanProxy = universalProxy.replace(/\/$/, ''); + return `${cleanProxy}/${targetUrl}`; + } + + // 3. 正向代理 (仅本地环境回退到 5321 中转) + if (forwardProxy) { + return `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(targetUrl)}`; + } + + return targetUrl; + }, + /** * 获取全局配置快照 * @returns {Object} 当前全局配置 @@ -86,6 +160,9 @@ export const Globals = { if (prop === 'maxAnimes') return self.MAX_ANIMES; if (prop === 'maxLastSelectMap') return self.MAX_LAST_SELECT_MAP; + // 暴露方法 + if (prop === 'makeProxyUrl') return self.makeProxyUrl.bind(self); + // 其他属性直接返回 return self[prop]; }, diff --git a/danmu_api/server.js b/danmu_api/server.js index 39353da..bbbcbe5 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -417,32 +417,21 @@ function createProxyServer() { // 解析 PROXY_URL 配置(统一处理代理和反向代理) const proxyConfig = process.env.PROXY_URL || ''; let forwardProxy = null; // 正向代理(传统代理) - let bahamutRP = null; // 巴哈姆特专用反代 - let tmdbRP = null; // TMDB专用反代 - let universalRP = null; // 万能反代 if (proxyConfig) { // 支持多个配置,用逗号分隔 const proxyConfigs = proxyConfig.split(',').map(s => s.trim()).filter(s => s); for (const config of proxyConfigs) { - if (config.startsWith('bahamut@')) { - // 巴哈姆特专用反代:bahamut@http://example.com - bahamutRP = config.substring(8).trim().replace(/\/+$/, ''); - console.log('[Proxy Server] Bahamut reverse proxy detected:', bahamutRP); - } else if (config.startsWith('tmdb@')) { - // TMDB专用反代:tmdb@http://example.com - tmdbRP = config.substring(5).trim().replace(/\/+$/, ''); - console.log('[Proxy Server] TMDB reverse proxy detected:', tmdbRP); - } else if (config.startsWith('@')) { - // 万能反代:@http://example.com - universalRP = config.substring(1).trim().replace(/\/+$/, ''); - console.log('[Proxy Server] Universal reverse proxy detected:', universalRP); - } else { - // 正向代理:http://proxy.com:port 或 socks5://proxy.com:port - forwardProxy = config.trim(); - console.log('[Proxy Server] Forward proxy detected:', forwardProxy); + // 通用忽略逻辑:忽略所有专用反代和万能反代规则 + if (/^@/.test(config) || /^[\w-]+@http/i.test(config)) { + continue; } + // 正向代理:http://proxy.com:port 或 socks5://proxy.com:port + forwardProxy = config.trim(); + console.log('[Proxy Server] Forward proxy detected:', forwardProxy); + // 找到第一个有效代理就停止,避免逻辑混乱 + break; } } const targetUrl = queryObject.url; @@ -461,59 +450,8 @@ function createProxyServer() { let protocol = originalUrlObj.protocol === 'https:' ? https : http; - // 新反代优先级判断:专用反代 > 万能反代 > PROXY_URL代理 - let finalReverseProxy = null; - - // 1. 检查是否匹配巴哈姆特专用反代 - if (bahamutRP && originalUrlObj.hostname.includes('gamer.com.tw')) { - finalReverseProxy = bahamutRP; - console.log('[Proxy Server] Using Bahamut-specific reverse proxy'); - } - // 2. 检查是否匹配TMDB专用反代 - else if (tmdbRP && originalUrlObj.hostname.includes('tmdb.org')) { - finalReverseProxy = tmdbRP; - console.log('[Proxy Server] Using TMDB-specific reverse proxy'); - } - // 3. 检查万能反代 - else if (universalRP) { - finalReverseProxy = universalRP; - console.log('[Proxy Server] Using universal reverse proxy'); - } - - // 应用反代逻辑 - if (finalReverseProxy) { - try { - // 解析反向代理服务器的 URL,设置主机、端口和协议 - const reverseUrlObj = new URL(finalReverseProxy); - options.hostname = reverseUrlObj.hostname; - options.port = reverseUrlObj.port || (reverseUrlObj.protocol === 'https:' ? 443 : 80); - protocol = reverseUrlObj.protocol === 'https:' ? https : http; - - const baseReversePath = reverseUrlObj.pathname.replace(/\/$/, ''); - let logMessage = ''; - - // 根据反代类型构建不同的目标路径 - if (finalReverseProxy === universalRP) { - // 万能反代:追加原始完整URL - // 路径格式:/反代路径/原始完整URL - options.path = baseReversePath + '/' + targetUrl.replace(':/', ''); - logMessage = `[Proxy Server] Universal RP rewriting to: ${protocol === https ? 'https' : 'http'}://${options.hostname}:${options.port}${options.path}`; - } else { - // 专用反代:路径合并模式 - // 路径合并:/反代路径 + /原始路径?query - options.path = baseReversePath + originalUrlObj.pathname + originalUrlObj.search; - logMessage = `[Proxy Server] Specific RP rewriting to: ${protocol === https ? 'https' : 'http'}://${options.hostname}:${options.port}${options.path}`; - } - - console.log(logMessage); - - } catch (e) { - console.error('[Proxy Server] Invalid reverse proxy URL:', finalReverseProxy, e.message); - res.statusCode = 500; - res.end('Proxy Error: Invalid Reverse Proxy URL'); - return; - } - } else if (forwardProxy) { + // 处理正向代理逻辑 + if (forwardProxy) { // 正向代理模式:使用 HttpsProxyAgent console.log('[Proxy Server] Using forward proxy agent:', forwardProxy); options.agent = new HttpsProxyAgent(forwardProxy); @@ -527,10 +465,30 @@ function createProxyServer() { proxyRes.pipe(res, { end: true }); }); + // 监听外部触发中断 + // 当外部触发 abort() 时,这里的 req 会触发 'close'掐断 proxyReq + req.on('close', () => { + if (!res.writableEnded) { + console.log('[Proxy Server] Client disconnected prematurely. Destroying upstream request.'); + proxyReq.destroy(); + } + }); + proxyReq.on('error', (err) => { + // 过滤掉因外部主动断开导致的 ECONNRESET / socket hang up 错误 + if (req.destroyed || req.aborted || err.code === 'ECONNRESET' || err.message === 'socket hang up') { + // 只有当响应还没结束时,才打印一条 Info 级别的日志,证明熔断成功 + if (!res.writableEnded) { + console.log('[Proxy Server] Upstream connection closed (expected behavior due to client interrupt).'); + } + return; + } + console.error('Proxy request error:', err); - res.statusCode = 500; - res.end('Proxy Error: ' + err.message); + if (!res.headersSent) { + res.statusCode = 500; + res.end('Proxy Error: ' + err.message); + } }); proxyReq.end(); diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index b77dde2..fb3f021 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -32,7 +32,7 @@ export default class BahamutSource extends BaseSource { const originalSearchPromise = (async () => { try { const targetUrl = `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedKeyword}`; - const url = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(targetUrl)}` : targetUrl; + const url = globals.makeProxyUrl(targetUrl); const originalResp = await httpGet(url, { headers: { @@ -80,41 +80,25 @@ export default class BahamutSource extends BaseSource { // 延迟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'); - } + const tmdbTitle = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal, "Bahamut"); 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 targetUrl = `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}`; - const tmdbSearchUrl = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(targetUrl)}` : targetUrl; + const tmdbSearchUrl = globals.makeProxyUrl(targetUrl); 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", }, + signal: tmdbAbortController.signal }); if (tmdbResp && tmdbResp.data && tmdbResp.data.anime && tmdbResp.data.anime.length > 0) { @@ -175,7 +159,7 @@ export default class BahamutSource extends BaseSource { 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 url = globals.makeProxyUrl(targetUrl); const resp = await httpGet(url, { headers: { "Content-Type": "application/json", @@ -356,7 +340,7 @@ export default class BahamutSource extends BaseSource { 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 url = globals.makeProxyUrl(targetUrl); const resp = await httpGet(url, { headers: { "Content-Type": "application/json", diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 15d29cd..96c5db9 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -1,12 +1,14 @@ 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 { 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 { SegmentListResponse } from '../models/dandan-model.js'; +import { simplized } from "../utils/zh-util.js"; +import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js"; // ===================== // 获取b站弹幕 @@ -22,6 +24,10 @@ export default class BilibiliSource extends BaseSource { 36, 20, 34, 44, 52 ]; + // APP 签名相关常量 (Android 粉版 - 港澳台搜索用) + static APP_KEY = '1d8b6e7d45233436'; + static APP_SEC = '560c52ccd288fed045859ed18bffd973'; + // 解析 b23.tv 短链接 async resolveB23Link(shortUrl) { try { @@ -199,7 +205,7 @@ export default class BilibiliSource extends BaseSource { .replace(/:/g, ':') .trim(); - results.push({ + const resultItem = { provider: "bilibili", mediaId, title: cleanedTitle, @@ -207,7 +213,14 @@ export default class BilibiliSource extends BaseSource { year, imageUrl: item.cover || null, episodeCount - }); + }; + + // 如果搜索结果自带分集信息(Web端特性),挂载到对象上 + if (item.eps && item.eps.length > 0) { + resultItem._eps = item.eps; + } + + results.push(resultItem); } log("info", `[Bilibili] 类型 '${searchType}' 找到 ${results.length} 个结果`); @@ -266,7 +279,15 @@ export default class BilibiliSource extends BaseSource { const searchTypes = ["media_bangumi", "media_ft"]; const searchPromises = searchTypes.map(type => this._searchByType(keyword, type, mixinKey)); - const results = await Promise.all(searchPromises); + const tasks = [...searchPromises]; + + // 检测到代理配置时,启用港澳台并行搜索 + if (this._hasBilibiliProxy()) { + log("info", `[Bilibili] 检测到代理配置,启用港澳台并行搜索`); + tasks.push(this._searchOversea(keyword)); + } + + const results = await Promise.all(tasks); // 合并结果并去重 const allResults = results.flat(); @@ -292,45 +313,49 @@ export default class BilibiliSource extends BaseSource { * 获取番剧分集列表 */ async _getPgcEpisodes(seasonId) { - try { - const url = `https://api.bilibili.com/pgc/view/web/season?season_id=${seasonId}`; + let rawEpisodes = []; + // 增加 Section 接口作为回退,解决港澳台个别条目分集不显式输出 + const apis = [ + `https://api.bilibili.com/pgc/view/web/season?season_id=${seasonId}`, + `https://api.bilibili.com/pgc/web/season/section?season_id=${seasonId}` + ]; - const response = await httpGet(url, { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Referer": "https://www.bilibili.com/", - "Cookie": globals.bilibliCookie || "" + for (const url of apis) { + try { + const response = await httpGet(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.bilibili.com/", + "Cookie": globals.bilibliCookie || "" + } + }); + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (data.code === 0 && data.result) { + // 优先从 main_section 获取分集,兼容 view 和 section 接口 + rawEpisodes = data.result.main_section?.episodes || data.result.episodes || []; + if (rawEpisodes.length > 0) break; + } + } catch(e) { + // 忽略错误,尝试下一个接口 } - }); + } - const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; - - if (data.code !== 0 || !data.result) { - log("error", `[Bilibili] 获取番剧分集失败 (season_id=${seasonId}):`, data.message); + if (rawEpisodes.length === 0) { + log("error", `[Bilibili] 获取番剧分集失败 (season_id=${seasonId}): 所有接口均无数据`); return []; - } + } - // 优先从 main_section 获取分集 - const rawEpisodes = data.result.main_section?.episodes || data.result.episodes || []; - - if (rawEpisodes.length === 0) { - log("info", `[Bilibili] 番剧 season_id=${seasonId} 无分集数据`); - return []; - } - - const episodes = rawEpisodes.map((ep, index) => ({ + const episodes = rawEpisodes.map((ep, index) => ({ vid: `${ep.aid},${ep.cid}`, id: ep.id, title: (ep.show_title || ep.long_title || ep.title || `第${index + 1}集`).trim(), link: `https://www.bilibili.com/bangumi/play/ep${ep.id}` - })); + })); - log("info", `[Bilibili] 获取到 ${episodes.length} 个番剧分集`); - return episodes; - } catch (error) { - log("error", `[Bilibili] 获取番剧分集出错 (season_id=${seasonId}):`, error.message); - return []; - } + log("info", `[Bilibili] 获取到 ${episodes.length} 个番剧分集`); + return episodes; } /** @@ -400,21 +425,53 @@ export default class BilibiliSource extends BaseSource { } const processPromises = sourceAnimes - .filter(anime => titleMatches(anime.title, queryTitle)) + // 港澳台资源不做严格标题匹配,因为可能搜到的是日语/繁体标题 + .filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle)) .map(async (anime) => { try { - const eps = await this.getEpisodes(anime.mediaId); + let links = []; - if (eps.length === 0) { - log("info", `[Bilibili] ${anime.title} 无分集,跳过`); - return; + // 优先使用搜索结果中自带的分集信息 (港澳台/WBI结果) + if (anime._eps && anime._eps.length > 0) { + links = anime._eps.map((ep, index) => { + const epIndex = ep.title || ep.index_title || ep.index || (index + 1).toString(); + const longTitle = ep.long_title || ""; + + let displayTitle = /^\d+(\.\d+)?$/.test(epIndex) ? `第${epIndex}话` : epIndex; + if (longTitle && longTitle !== epIndex) displayTitle += ` ${longTitle}`; + + const epId = ep.id || ep.param; + let linkUrl = `https://www.bilibili.com/bangumi/play/ep${epId}?season_id=${anime.mediaId.substring(2)}`; + + // 传递区域标记,供后续提取视频信息使用 + if (anime.isOversea) linkUrl += "&area=hkmt"; + + return { + name: `${index + 1}`, + url: linkUrl, + title: `【bilibili1】 ${displayTitle.trim()}` + }; + }); + log("info", `[Bilibili] 直接使用搜索结果中的 ${links.length} 集分集`); + } else { + const eps = await this.getEpisodes(anime.mediaId); + if (eps.length === 0) { + log("info", `[Bilibili] ${anime.title} 无分集,跳过`); + return; + } + links = eps.map((ep, index) => { + let linkUrl = ep.link + `?season_id=${anime.mediaId.substring(2)}`; + // 传递区域标记 + if (anime.isOversea) linkUrl += "&area=hkmt"; + return { + name: `${index + 1}`, + url: linkUrl, + title: `【bilibili1】 ${ep.title}` + }; + }); } - const links = eps.map((ep, index) => ({ - name: `${index + 1}`, - url: ep.link, - title: `【bilibili1】 ${ep.title}` - })); + if (links.length === 0) return; const numericAnimeId = convertToAsciiSum(anime.mediaId); const transformedAnime = { @@ -522,35 +579,64 @@ export default class BilibiliSource extends BaseSource { } else if (id.includes("bangumi/") && id.includes("ep")) { try { const epid = path.slice(-1)[0].slice(2); - const epInfoUrl = `${api_epid_cid}?ep_id=${epid}`; - - const res = await httpGet(epInfoUrl, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, + + // 解析特殊参数:season_id 和 area 标记 + const urlParams = id.split('?')[1] || ""; + let seasonId = null, isOversea = false; + urlParams.split('&').forEach(p => { + const [k, v] = p.split('='); + if (k === 'season_id') seasonId = v; + if (k === 'area' && v === 'hkmt') isOversea = true; }); + + let success = false; - const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; - if (data.code !== 0) { - log("error", "获取番剧视频信息失败:", data.message); - return null; + // 轨道一:直连模式 (非港澳台标记) + if (!isOversea) { + const res = await httpGet(`${api_epid_cid}?ep_id=${epid}`, { + headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0" } + }); + const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + if (data.code === 0 && data.result) { + const ep = data.result.episodes.find(e => e.id == epid); + if (ep) { cid = ep.cid; duration = ep.duration / 1000; title = ep.share_copy; success = true; } + } } - for (const episode of data.result.episodes) { - if (episode.id == epid) { - title = episode.share_copy; - cid = episode.cid; - duration = episode.duration / 1000; - break; - } + // 轨道二:代理模式 (港澳台标记 或 直连失败且有seasonId) + if ((!success || isOversea) && seasonId && this._hasBilibiliProxy()) { + // 尝试 View 接口 (必须走代理) + try { + const proxyUrl = this._makeProxyUrl(`https://api.bilibili.com/pgc/view/web/season?season_id=${seasonId}`); + const res = await httpGet(proxyUrl, { headers: { "Cookie": globals.bilibliCookie || "", "User-Agent": "Mozilla/5.0" } }); + const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + if (data.code === 0 && data.result) { + const ep = (data.result.episodes || data.result.main_section?.episodes || []).find(e => e.id == epid); + if (ep) { cid = ep.cid; aid = ep.aid; duration = ep.duration / 1000; title = ep.long_title; success = true; } + } + } catch(e) {} + + // 尝试 Section 接口 (直连回退) + if (!success) { + try { + const res = await httpGet(`https://api.bilibili.com/pgc/web/season/section?season_id=${seasonId}`, { headers: { "User-Agent": "Mozilla/5.0", "Cookie": globals.bilibliCookie||"" } }); + const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + if (data.code === 0 && data.result?.main_section?.episodes) { + const ep = data.result.main_section.episodes.find(e => e.id == epid); + if (ep) { cid = ep.cid; aid = ep.aid; duration = ep.duration ? ep.duration / 1000 : 0; title = ep.long_title; success = true; } + } + } catch(e) {} + } } - if (!cid || !duration) { + if (!cid) { log("error", "未找到匹配的番剧集信息"); return null; } + // 兜底 duration + if (!duration && duration !== 0) duration = 0; + } catch (error) { log("error", "请求番剧视频信息失败:", error); return null; @@ -618,29 +704,27 @@ export default class BilibiliSource extends BaseSource { const segmentList = segmentResult.segmentList; log("info", `弹幕分段数量: ${segmentList.length}`); - // 创建请求Promise数组 - const promises = []; - for (const segment of segmentList) { - promises.push( - this.getEpisodeSegmentDanmu(segment) - ); - } - - // 解析弹幕数据 + // 分批并发请求,防止请求过多 + const BATCH_SIZE = 6; let contents = []; - try { - const results = await Promise.allSettled(promises); - const datas = results - .filter(result => result.status === "fulfilled") - .map(result => result.value) - .filter(data => data !== null); // 过滤掉null值 - - datas.forEach(data => { - contents.push(...data); - }); - } catch (error) { - log("error", "解析弹幕数据失败:", error); - return []; + + for (let i = 0; i < segmentList.length; i += BATCH_SIZE) { + const batch = segmentList.slice(i, i + BATCH_SIZE); + const promises = batch.map(segment => this.getEpisodeSegmentDanmu(segment).then(d => ({status: 'ok', value: d})).catch(e => ({status: 'err', error: e}))); + + const results = await Promise.all(promises); + let stop = false; + + for (const res of results) { + if (res.status === 'ok' && res.value) { + contents.push(...res.value); + } else { + // 请求失败视为视频结束(熔断机制) + log("info", "[Bilibili] 捕获到分段请求出错,说明请求完毕,停止后续请求"); + stop = true; + } + } + if (stop) break; } return contents; @@ -661,8 +745,13 @@ export default class BilibiliSource extends BaseSource { const { cid, aid, duration } = videoInfo; log("info", `视频信息: cid=${cid}, aid=${aid}, duration=${duration}`); + // [提示] 无时长时的默认分段策略提示 + if (duration <= 0) { + log("info", "[Bilibili] 未获取到精准时长,使用预设 36 分段"); + } + // 计算视频的分片数量 - const maxLen = Math.floor(duration / 360) + 1; + const maxLen = (duration > 0) ? (Math.floor(duration / 360) + 1) : 36; log("info", `maxLen: ${maxLen}`); const segmentList = []; @@ -707,12 +796,158 @@ export default class BilibiliSource extends BaseSource { return contents; } catch (error) { - log("error", "请求分片弹幕失败:", error); - return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为 + // 抛出错误以触发外层的熔断机制 + throw error; } } formatComments(comments) { + // 弹幕繁体转简体 + if (globals.danmuSimplified) { + return comments.map(c => { + if (c.m) c.m = simplized(c.m); + return c; + }); + } return comments; } + + // 构建代理URL + _makeProxyUrl(targetUrl) { + return globals.makeProxyUrl(targetUrl); + } + + // 检查是否配置了B站专用代理 + _hasBilibiliProxy() { + return (globals.proxyUrl || '').split(',').some(p => { + const t = p.trim(); + return t.startsWith('bilibili@') || t.startsWith('@'); + }); + } + + // APP接口专用 URL 编码 + _javaUrlEncode(str) { + return encodeURIComponent(str) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A') + .replace(/%20/g, '+'); + } + + // 港澳台代理搜索请求 + async _searchOverseaRequest(keyword, label="Original", signal = null) { + const rawCookie = globals.bilibliCookie || ""; + const akMatch = rawCookie.match(/([0-9a-fA-F]{32})/); + const proxy = (globals.proxyUrl||'').includes('bilibili@') || (globals.proxyUrl||'').includes('@'); + if (!proxy) return []; + + // 1. 尝试 App 接口 + if (akMatch) { + log("info", `[Bilibili-Proxy][${label}] 检测到 Access Key,启用 APP 端接口模式...`); + try { + const params = { keyword, type: 7, area: 'tw', mobi_app: 'android', platform: 'android', build: '8140200', ts: Math.floor(Date.now()/1000), appkey: BilibiliSource.APP_KEY, access_key: akMatch[1], disable_rcmd: 1 }; + const qs = Object.keys(params).sort().map(k => `${k}=${this._javaUrlEncode(String(params[k]))}`).join('&'); + const sign = md5(qs + BilibiliSource.APP_SEC); + + const target = `https://app.bilibili.com/x/v2/search/type?${qs}&sign=${sign}`; + const url = globals.makeProxyUrl(target); + + const data = await this._fetchAppSearchWithStream(url, { "User-Agent": "Mozilla/5.0 Android", "X-From-Biliroaming": "1.0.0" }, label, signal); + + if (data && data.code === 0) { + return (data.data?.items || data.data || []) + .filter(i => i.goto !== 'recommend_tips' && i.area !== '漫游' && i.badge !== '公告') + .map(i => ({ + provider: "bilibili", + mediaId: i.season_id ? `ss${i.season_id}` : (i.uri.match(/season\/(\d+)/)?.[1] ? `ss${i.uri.match(/season\/(\d+)/)[1]}` : ""), + title: (i.title||"").replace(/<[^>]+>/g,'').trim(), + type: "动漫", + year: i.ptime ? new Date(i.ptime*1000).getFullYear() : null, + imageUrl: i.cover||i.pic||"", + episodeCount: 0, + _eps: i.episodes || i.episodes_new, + isOversea: true + })).filter(i => i.mediaId); + } + if (data && data.code !== 0) log("warn", `[Bilibili-Proxy] App 接口返回错误 Code ${data.code}: ${data.message}`); + } catch(e) { + if (e.name === 'AbortError') throw e; + log("error", `[Bilibili-Proxy] App 接口请求异常: ${e.message}`); + } + log("info", `[Bilibili-Proxy] App 接口请求失败,自动降级至 Web 接口...`); + } else { + log("info", `[Bilibili-Proxy][${label}] 未检测到 Access Key,启用 Web 端接口模式...`); + } + + // 2. Web 接口兜底 + try { + const params = { keyword, search_type: 'media_bangumi', area: 'tw', page: 1, order: 'totalrank', __refresh__: true, _timestamp: Date.now() }; + const qs = Object.keys(params).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join('&'); + + const target = `https://api.bilibili.com/x/web-interface/search/type?${qs}`; + const url = globals.makeProxyUrl(target); + + const res = await httpGet(url, { + headers: { "User-Agent": "Mozilla/5.0", "Cookie": globals.bilibliCookie||"", "X-From-Biliroaming": "1.0.0" }, + signal: signal + }); + const data = typeof res.data==="string"?JSON.parse(res.data):res.data; + + if (data.code !== 0) { + log("warn", `[Bilibili-Proxy] Web 接口返回错误 Code ${data.code}: ${data.message}`); + return []; + } + if(data.data?.result) { + return data.data.result.filter(i => i.url?.includes("bilibili.com") && (!i.areas?.includes("漫游"))).map(i => ({ + provider: "bilibili", mediaId: i.season_id?`ss${i.season_id}`:"", title: (i.title||"").replace(/<[^>]+>/g,'').trim(), + type: this._extractMediaType(i.season_type_name), year: i.pubtime?new Date(i.pubtime*1000).getFullYear():null, imageUrl: i.cover||null, + episodeCount: i.ep_size||0, _eps: i.eps, isOversea: true + })).filter(i => i.mediaId); + } + } catch(e) { + if (e.name === 'AbortError') throw e; + log("error", `[Bilibili-Proxy] Web 接口请求异常: ${e.message}(如果是-500/-502说明只是风控)`); + } + return []; + } + + // 综合港澳台搜索入口 + async _searchOversea(keyword) { + const tmdbAbortController = new AbortController(); + const t1 = this._searchOverseaRequest(keyword, "Original").then(r => { if(r.length) tmdbAbortController.abort(); return r; }).catch(()=>[]); + const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(()=>getTmdbJaOriginalTitle(keyword, tmdbAbortController.signal, "Bilibili")).then(t => (t&&t!==keyword)?this._searchOverseaRequest(t,"TMDB",tmdbAbortController.signal):[]).catch(()=>[])) : Promise.resolve([]); + return (await Promise.all([t1, t2])).flat(); + } + + // APP搜索流式嗅探,针对 B 站港澳台无结果时返回的大体积推荐数据 + async _fetchAppSearchWithStream(url, headers, label, signal) { + if (typeof httpGetWithStreamCheck !== 'function') return null; + + let trusted = false; + let isNoResult = false; // 标记是否为"无结果"中断 + + const result = await httpGetWithStreamCheck(url, { + headers: headers, + sniffLimit: 8192, + signal: signal + }, (chunk) => { + if (trusted) return true; + if (chunk.includes('"goto":"recommend_tips"') || chunk.includes('暂无搜索结果')) { + log("info", `[Bilibili-Proxy][${label}] 嗅探到无效数据,中断`); + isNoResult = true; // 标记为无结果 + return false; + } + if (chunk.includes('"season_id"') || chunk.includes('"episodes"')) trusted = true; + return true; + }); + + // 如果是无结果导致的中断,构造一个伪造的空成功响应 + if (isNoResult) { + return { code: 0, data: { items: [] } }; + } + + return result; + } } diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index 6b72c64..431c901 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -5,6 +5,23 @@ import { log } from './log-util.js' // 请求工具方法 // ===================== +/** + * 将外部中断信号链接到内部控制器 + * @param {AbortSignal} externalSignal 外部传入的信号 + * @param {AbortController} internalController 内部使用的控制器 + */ +function linkSignal(externalSignal, internalController) { + if (externalSignal) { + if (externalSignal.aborted) { + internalController.abort(); + } else { + externalSignal.addEventListener('abort', () => { + internalController.abort(); + }, { once: true }); + } + } +} + export async function httpGet(url, options = {}) { // 从 options 中获取重试次数,默认为 0 const maxRetries = parseInt(options.retries || '0', 10) || 0; @@ -25,6 +42,9 @@ export async function httpGet(url, options = {}) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); + // 链接外部中断信号 + linkSignal(options.signal, controller); + try { const response = await fetch(url, { method: 'GET', @@ -126,6 +146,11 @@ export async function httpGet(url, options = {}) { clearTimeout(timeoutId); lastError = error; + // 如果是外部信号导致的中断,停止重试并直接抛出 + if (options.signal?.aborted) { + throw error; + } + // 检查是否是超时错误 if (error.name === 'AbortError') { log("error", `[请求模拟] 请求超时:`, error.message); @@ -179,6 +204,9 @@ export async function httpPost(url, body, options = {}) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); + // 链接外部中断信号 + linkSignal(options.signal, controller); + // 处理请求头、body 和其他参数 const { headers = {}, params, allow_redirects = true } = options; const fetchOptions = { @@ -230,6 +258,11 @@ export async function httpPost(url, body, options = {}) { clearTimeout(timeoutId); lastError = error; + // 如果是外部信号导致的中断,停止重试并直接抛出 + if (options.signal?.aborted) { + throw error; + } + // 检查是否是超时错误 if (error.name === 'AbortError') { log("error", `[请求模拟] 请求超时:`, error.message); @@ -289,6 +322,11 @@ async function httpRequestMethod(method, url, body, options = {}) { fetchOptions.body = body; } + // 如果传递了 signal,直接透传给 fetch + if (options.signal) { + fetchOptions.signal = options.signal; + } + try { const response = await fetch(url, fetchOptions); const textData = await response.text(); @@ -509,4 +547,128 @@ export function getPathname(url) { let pathEnd = queryStart !== -1 ? queryStart : (hashStart !== -1 ? hashStart : url.length); const pathname = url.substring(pathStart, pathEnd); return pathname || '/'; -} \ No newline at end of file +} + +/** + * 流式 GET 请求,支持前置数据缓冲嗅探与熔断 + * @param {string} url 请求地址 + * @param {object} options 配置项 + * @param {number} [options.sniffLimit=32768] 最大嗅探长度(字节),默认32KB + * @param {object} [options.headers] 请求头 + * @param {function(string): boolean} checkCallback 数据检查回调,返回 false 则中断下载 + * @returns {Promise} 返回 JSON 数据或 null (被中断时) + */ +export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { + const { headers = {}, sniffLimit } = options; + // 默认限制为 32KB + const SNIFF_LIMIT = parseInt(sniffLimit || '32768', 10) || 32768; + const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // 链接外部中断信号 + linkSignal(options.signal, controller); + + try { + log("info", `[流式请求] HTTP GET: ${url}`); + + const response = await fetch(url, { + method: 'GET', + headers: headers, + signal: controller.signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader ? response.body.getReader() : null; + + // 环境兼容性回退 + if (!reader) { + log("warn", "[流式请求] 环境不支持流式读取,回退到普通请求"); + const text = await response.text(); + clearTimeout(timeoutId); + if (checkCallback && !checkCallback(text.slice(0, SNIFF_LIMIT))) { + log("info", "[流式请求] 检测到无效数据(回退模式),丢弃结果"); + return null; + } + try { return JSON.parse(text); } catch { return text; } + } + + let receivedLength = 0; + let chunks = []; + let isAborted = false; + + // 缓冲状态 + let checkBuffer = ""; + let stopChecking = false; // 标记是否停止检查 + + // 流式读取循环 + while(true) { + const { done, value } = await reader.read(); + + if (done) break; + + // 先累加长度 + receivedLength += value.length; + + // 1. 数据嗅探逻辑 (仅在前 SNIFF_LIMIT 范围内执行) + if (!stopChecking && checkCallback) { + // 累积文本 + const chunkText = new TextDecoder("utf-8").decode(value, {stream: true}); + checkBuffer += chunkText; + + // 执行回调检查 + if (!checkCallback(checkBuffer)) { + log("info", `[流式请求] 嗅探到无效特征(已读${receivedLength}字节),立即熔断`); + + // 显式取消流读取 + try { + await reader.cancel("Stream aborted by user check"); + } catch (e) { /* 忽略 cancel 产生的错误 */ } + + controller.abort(); + isAborted = true; + break; + } + + // 如果缓冲区超过限制 + if (receivedLength > SNIFF_LIMIT) { + stopChecking = true; + checkBuffer = null; // 释放缓冲区内存 + } + } + + chunks.push(value); + } + + clearTimeout(timeoutId); + + if (isAborted) return null; // 被中断,返回空 + + // 2. 拼接完整数据 + let chunksAll = new Uint8Array(receivedLength); + let position = 0; + for(let chunk of chunks) { + chunksAll.set(chunk, position); + position += chunk.length; + } + + const resultText = new TextDecoder("utf-8").decode(chunksAll); + try { + return JSON.parse(resultText); + } catch (e) { + return resultText; + } + + } catch (error) { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + return null; + } + log("error", `[流式请求] 失败: ${error.message}`); + return null; + } +} diff --git a/danmu_api/utils/tmdb-util.js b/danmu_api/utils/tmdb-util.js index 80d5d8b..8cd3398 100644 --- a/danmu_api/utils/tmdb-util.js +++ b/danmu_api/utils/tmdb-util.js @@ -7,11 +7,16 @@ import { isNonChinese } from "./zh-util.js"; // TMDB API 工具方法 // --------------------- -// TMDB API 请求 -async function tmdbApiGet(url) { +// 全局任务队列,用于管理并发请求的合并与中断 +// Key: title, Value: { promise, controller, refCount } +const TMDB_PENDING = new Map(); + +// TMDB API 请求基础函数 +async function tmdbApiGet(url, options = {}) { const tmdbApi = "https://api.tmdb.org/3/"; const tartgetUrl = `${tmdbApi}${url}`; - const nextUrl = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(tartgetUrl)}` : tartgetUrl; + // 修改:使用统一的代理 URL 构建方法 + const nextUrl = globals.makeProxyUrl(tartgetUrl); try { const response = await httpGet(nextUrl, { @@ -19,12 +24,17 @@ async function tmdbApiGet(url) { headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - } + }, + signal: options.signal // 透传中断信号 }); if (response.status != 200) return null; return response; } catch (error) { + // 如果是中断信号,抛出以供上层处理 + if (error.name === 'AbortError') { + throw error; + } log("error", "[TMDB] Api error:", { message: error.message, name: error.name, @@ -45,7 +55,7 @@ export async function searchTmdbTitles(title, mediaType = "multi", options = {}) // 如果指定了具体页码,只获取单页 if (options.page !== undefined) { const url = `search/${mediaType}?api_key=${globals.tmdbApiKey}&query=${encodeURIComponent(title)}&language=zh-CN&page=${page}`; - return await tmdbApiGet(url); + return await tmdbApiGet(url, { signal }); } // 默认获取多页合并结果 @@ -58,7 +68,7 @@ export async function searchTmdbTitles(title, mediaType = "multi", options = {}) } const url = `search/${mediaType}?api_key=${globals.tmdbApiKey}&query=${encodeURIComponent(title)}&language=zh-CN&page=${currentPage}`; - const response = await tmdbApiGet(url); + const response = await tmdbApiGet(url, { signal }); if (!response || !response.data) { break; @@ -90,21 +100,21 @@ export async function searchTmdbTitles(title, mediaType = "multi", options = {}) } // 使用 TMDB API 获取日语详情 -export async function getTmdbJpDetail(mediaType, tmdbId) { +export async function getTmdbJpDetail(mediaType, tmdbId, options = {}) { const url = `${mediaType}/${tmdbId}?api_key=${globals.tmdbApiKey}&language=ja-JP`; - return await tmdbApiGet(url); + return await tmdbApiGet(url, options); } // 使用 TMDB API 获取external_ids -export async function getTmdbExternalIds(mediaType, tmdbId) { +export async function getTmdbExternalIds(mediaType, tmdbId, options = {}) { const url = `${mediaType}/${tmdbId}/external_ids?api_key=${globals.tmdbApiKey}`; - return await tmdbApiGet(url); + return await tmdbApiGet(url, options); } // 使用 TMDB API 获取别名 -async function getTmdbAlternativeTitles(mediaType, tmdbId) { +async function getTmdbAlternativeTitles(mediaType, tmdbId, options = {}) { const url = `${mediaType}/${tmdbId}/alternative_titles?api_key=${globals.tmdbApiKey}`; - return await tmdbApiGet(url); + return await tmdbApiGet(url, options); } // 从别名中提取中文别名相关函数 @@ -170,7 +180,7 @@ async function getChineseTitleForResult(result, signal) { throw new DOMException('Aborted', 'AbortError'); } - const altResp = await getTmdbAlternativeTitles(mediaType, result.id); + const altResp = await getTmdbAlternativeTitles(mediaType, result.id, { signal }); // 别名请求返回后再次检查(请求期间可能被中断) if (signal && signal.aborted) { @@ -187,6 +197,7 @@ async function getChineseTitleForResult(result, signal) { return resultTitle; } } catch (error) { + // 遇到中断信号直接抛出 if (error.name === 'AbortError') { throw error; } @@ -195,292 +206,352 @@ async function getChineseTitleForResult(result, signal) { } } -// 使用TMDB API 查询日语原名搜索bahamut相关函数 -export async function getTmdbJaOriginalTitle(title, signal = null) { +// 使用TMDB API 查询日语原名,支持请求合并与引用计数控制 +export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = 'Unknown') { if (!globals.tmdbApiKey) { log("info", "[TMDB] 未配置API密钥,跳过TMDB搜索"); return null; } - try { - // 内部函数:判断单个媒体是否为动画或日语内容 - 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(',')})` - }; - }; + // 检查是否已有相同关键词的搜索任务正在进行 + let task = TMDB_PENDING.get(title); - // 内部函数:批量验证搜索结果 - const validateResults = (results) => { - if (!results || results.length === 0) { - return { - hasValid: false, - validCount: 0, - totalCount: 0, - details: "搜索结果为空" + if (!task) { + // 创建一个新的控制器,用于控制真正的后台网络请求 + const masterController = new AbortController(); + + // 定义搜索核心逻辑 + const executeSearch = async () => { + try { + const backgroundSignal = masterController.signal; + + // 内部函数:判断单个媒体是否为动画或日语内容 + 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(',')})` + }; }; - } - - 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 normalize = (str) => { - return str.toLowerCase() - .replace(/\s+/g, '') - .replace(/[::、,。!?;""''()【】《》]/g, '') - .trim(); - }; - - const n1 = normalize(s1); - const n2 = normalize(s2); - - // 完全匹配 - if (n1 === n2) return 1.0; - - // 包含关系检查 - const shorter = n1.length < n2.length ? n1 : n2; - const longer = n1.length >= n2.length ? n1 : n2; - - if (longer.includes(shorter) && shorter.length > 0) { - // 如果有连词则得到一定加分 - const lengthRatio = shorter.length / longer.length; - return 0.6 + (lengthRatio * 0.30); - } - - // 编辑距离计算 - const longer2 = s1.length > s2.length ? s1 : s2; - const shorter2 = s1.length > s2.length ? s2 : s1; - if (longer2.length === 0) return 1.0; - - const editDistance = (str1, str2) => { - str1 = str1.toLowerCase(); - str2 = str2.toLowerCase(); - const costs = []; - for (let i = 0; i <= str1.length; i++) { - let lastValue = i; - for (let j = 0; j <= str2.length; j++) { - if (i === 0) { - costs[j] = j; - } else if (j > 0) { - let newValue = costs[j - 1]; - if (str1.charAt(i - 1) !== str2.charAt(j - 1)) { - newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; + // 内部函数:批量验证搜索结果 + 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 normalize = (str) => { + return str.toLowerCase() + .replace(/\s+/g, '') + .replace(/[::、,。!?;""''()【】《》]/g, '') + .trim(); + }; + + const n1 = normalize(s1); + const n2 = normalize(s2); + + // 完全匹配 + if (n1 === n2) return 1.0; + + // 包含关系检查 + const shorter = n1.length < n2.length ? n1 : n2; + const longer = n1.length >= n2.length ? n1 : n2; + + if (longer.includes(shorter) && shorter.length > 0) { + // 如果有连词则得到一定加分 + const lengthRatio = shorter.length / longer.length; + return 0.6 + (lengthRatio * 0.30); + } + + // 编辑距离计算 + const longer2 = s1.length > s2.length ? s1 : s2; + const shorter2 = s1.length > s2.length ? s2 : s1; + if (longer2.length === 0) return 1.0; + + const editDistance = (str1, str2) => { + str1 = str1.toLowerCase(); + str2 = str2.toLowerCase(); + const costs = []; + for (let i = 0; i <= str1.length; i++) { + let lastValue = i; + for (let j = 0; j <= str2.length; j++) { + if (i === 0) { + costs[j] = j; + } else if (j > 0) { + let newValue = costs[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; + } } - costs[j - 1] = lastValue; - lastValue = newValue; + if (i > 0) costs[str2.length] = lastValue; + } + return costs[str2.length]; + }; + + return (longer2.length - editDistance(longer2, shorter2)) / longer2.length; + }; + + // 第一步:TMDB搜索 + log("info", `[TMDB] 正在搜索 (Shared Task): ${title}`); + + // 检查 masterController 是否已被中断 + if (backgroundSignal.aborted) throw new DOMException('Aborted', 'AbortError'); + + const respZh = await searchTmdbTitles(title, "multi", { signal: backgroundSignal }); + + if (!respZh || !respZh.data) { + 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] 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 = null; + let bestScore = -1; + let bestMatchChineseTitle = null; + let alternativeTitleFetchCount = 0; // 别名获取计数器 + const MAX_ALTERNATIVE_FETCHES = 5; // 最多获取5个别名 + let skipAlternativeFetch = false; // 是否跳过后续别名获取 + + for (const result of dataZh.results) { + const resultTitle = result.name || result.title || ""; + if (!resultTitle) continue; + + // 先计算原标题的相似度 + const directScore = similarity(title, resultTitle); + const originalTitle = result.original_name || result.original_title || ""; + const originalScore = originalTitle ? similarity(title, originalTitle) : 0; + const initialScore = Math.max(directScore, originalScore); + + // 如果原标题已经100%匹配,标记跳过后续所有别名搜索 + if (initialScore === 1.0 && !skipAlternativeFetch) { + skipAlternativeFetch = true; + log("info", `[TMDB] 匹配检查 "${resultTitle}" - 相似度: 100.00% (完全匹配,跳过后续所有别名搜索)`); + if (initialScore > bestScore) { + bestScore = initialScore; + bestMatch = result; + bestMatchChineseTitle = resultTitle; + } + continue; + } + + // 获取可用的中文标题 + let chineseTitle; + let finalScore; + + // 如果已经找到100%匹配或已是中文,直接使用原标题不获取别名 + if (skipAlternativeFetch || !isNonChinese(resultTitle)) { + chineseTitle = resultTitle; + finalScore = initialScore; + + if (skipAlternativeFetch && isNonChinese(resultTitle)) { + log("info", `[TMDB] 匹配检查 "${resultTitle}" - 相似度: ${(finalScore * 100).toFixed(2)}% (已找到完全匹配,跳过别名搜索)`); + } else { + log("info", `[TMDB] 匹配检查 "${resultTitle}" - 相似度: ${(finalScore * 100).toFixed(2)}%`); + } + } else { + // 非中文且未达到别名获取上限,尝试获取别名 + if (alternativeTitleFetchCount < MAX_ALTERNATIVE_FETCHES) { + try { + // 使用 backgroundSignal + chineseTitle = await getChineseTitleForResult(result, backgroundSignal); + if (chineseTitle !== resultTitle) { + alternativeTitleFetchCount++; + } + } catch (error) { + // 如果是中断错误,抛出 + if (error.name === 'AbortError') throw error; + log("error", `[TMDB] 处理结果失败: ${error.message}`); + chineseTitle = resultTitle; + } + } else { + chineseTitle = resultTitle; + log("info", `[TMDB] 已达到别名获取上限(${MAX_ALTERNATIVE_FETCHES}),使用原标题: ${resultTitle}`); + } + + const finalDirectScore = similarity(title, chineseTitle); + finalScore = Math.max(finalDirectScore, originalScore); + + const displayInfo = chineseTitle !== resultTitle + ? `"${resultTitle}" (别名: ${chineseTitle})` + : `"${resultTitle}"`; + log("info", `[TMDB] 匹配检查 ${displayInfo} - 相似度: ${(finalScore * 100).toFixed(2)}%`); + + if (finalScore === 1.0 && !skipAlternativeFetch) { + skipAlternativeFetch = true; + log("info", `[TMDB] 通过别名找到完全匹配,跳过后续所有别名搜索`); } } - if (i > 0) costs[str2.length] = lastValue; + + if (finalScore > bestScore) { + bestScore = finalScore; + bestMatch = result; + bestMatchChineseTitle = chineseTitle; + } } - return costs[str2.length]; - }; - - return (longer2.length - editDistance(longer2, shorter2)) / longer2.length; + + const MIN_SIMILARITY = 0.2; + if (!bestMatch || bestScore < MIN_SIMILARITY) { + log("info", `[TMDB] 最佳匹配相似度过低或未找到匹配 (${bestMatch ? (bestScore * 100).toFixed(2) + '%' : 'N/A'}),跳过`); + return null; + } + + log("info", `[TMDB] TMDB最佳匹配: ${bestMatchChineseTitle}, 相似度: ${(bestScore * 100).toFixed(2)}%`); + + // 第四步:获取日语详情 + const mediaType = bestMatch.media_type || (bestMatch.name ? "tv" : "movie"); + + const detailResp = await getTmdbJpDetail(mediaType, bestMatch.id, { signal: backgroundSignal }); + + if (!detailResp || !detailResp.data) { + const fallbackTitle = bestMatch.name || bestMatch.title; + log("info", `[TMDB] 使用中文搜索结果标题: ${fallbackTitle}`); + return fallbackTitle; + } + + const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data; + + const jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title; + log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle}`); + + return jaOriginalTitle; + + } catch (error) { + if (error.name === 'AbortError') { + log("info", `[TMDB] 后台搜索任务已完全终止 (${title})`); + throw error; + } + log("error", "[TMDB] Background Search error:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return null; + } }; - // 第一步:TMDB搜索 - log("info", `[TMDB] 正在搜索: ${title}`); - - // 内部中断检查 - if (signal && signal.aborted) { - throw new DOMException('Aborted', 'AbortError'); - } + // 初始化任务结构 + task = { + controller: masterController, + refCount: 0, + promise: executeSearch().finally(() => { + // 无论成功失败,移除 Map 记录 + TMDB_PENDING.delete(title); + }) + }; - const respZh = await searchTmdbTitles(title, "multi", { signal }); - - if (!respZh || !respZh.data) { - log("info", "[TMDB] TMDB搜索结果为空"); - return null; - } + TMDB_PENDING.set(title, task); + log("info", `[TMDB] 启动新搜索任务: ${title}`); + } else { + log("info", `[TMDB] 加入正在进行的搜索: ${title} (${sourceLabel})`); + } - const dataZh = typeof respZh.data === "string" ? JSON.parse(respZh.data) : respZh.data; + // 增加引用计数 + task.refCount++; - if (!dataZh.results || dataZh.results.length === 0) { - 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 = null; - let bestScore = -1; - let bestMatchChineseTitle = null; - let alternativeTitleFetchCount = 0; // 别名获取计数器 - const MAX_ALTERNATIVE_FETCHES = 5; // 最多获取5个别名 - let skipAlternativeFetch = false; // 是否跳过后续别名获取 - - for (const result of dataZh.results) { - const resultTitle = result.name || result.title || ""; - if (!resultTitle) continue; - - // 先计算原标题的相似度(包括original_name/original_title) - const directScore = similarity(title, resultTitle); - const originalTitle = result.original_name || result.original_title || ""; - const originalScore = originalTitle ? similarity(title, originalTitle) : 0; - const initialScore = Math.max(directScore, originalScore); - - // 如果原标题已经100%匹配,标记跳过后续所有别名搜索 - if (initialScore === 1.0 && !skipAlternativeFetch) { - skipAlternativeFetch = true; - log("info", `[TMDB] 匹配检查 "${resultTitle}" - 相似度: 100.00% (完全匹配,跳过后续所有别名搜索)`); - if (initialScore > bestScore) { - bestScore = initialScore; - bestMatch = result; - bestMatchChineseTitle = resultTitle; + // 定义退出任务的逻辑 + const leaveTask = () => { + // 再次获取任务确认其仍存在 + const currentTask = TMDB_PENDING.get(title); + if (currentTask === task) { + task.refCount--; + if (task.refCount <= 0) { + log("info", `[TMDB] 所有调用者已取消,终止后台请求: ${title}`); + task.controller.abort(); } - continue; - } - - // 获取可用的中文标题(如果需要会自动获取别名) - let chineseTitle; - let finalScore; - - // 如果已经找到100%匹配或已是中文,直接使用原标题不获取别名 - if (skipAlternativeFetch || !isNonChinese(resultTitle)) { - chineseTitle = resultTitle; - finalScore = initialScore; - - if (skipAlternativeFetch && isNonChinese(resultTitle)) { - log("info", `[TMDB] 匹配检查 "${resultTitle}" - 相似度: ${(finalScore * 100).toFixed(2)}% (已找到完全匹配,跳过别名搜索)`); - } else { - log("info", `[TMDB] 匹配检查 "${resultTitle}" - 相似度: ${(finalScore * 100).toFixed(2)}%`); + } + }; + + // 处理用户中断监听 + if (signal) { + if (signal.aborted) { + leaveTask(); + log("info", `[TMDB] 搜索已被中断 (Source: ${sourceLabel})`); + return null; + } + signal.addEventListener('abort', leaveTask, { once: true }); + } + + // 使用 Race 机制等待结果或用户中断 + try { + const userAbortPromise = new Promise((_, reject) => { + if (signal) { + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); } - } else { - // 非中文且未达到别名获取上限,尝试获取别名 - if (alternativeTitleFetchCount < MAX_ALTERNATIVE_FETCHES) { - try { - chineseTitle = await getChineseTitleForResult(result, signal); - // 如果实际获取了别名(即返回值与原标题不同),计数器+1 - if (chineseTitle !== resultTitle) { - alternativeTitleFetchCount++; - } - } catch (error) { - if (error.name === 'AbortError') { - throw error; - } - log("error", `[TMDB] 处理结果失败: ${error.message}`); - chineseTitle = resultTitle; - } - } else { - // 超过上限,直接使用原标题 - chineseTitle = resultTitle; - log("info", `[TMDB] 已达到别名获取上限(${MAX_ALTERNATIVE_FETCHES}),使用原标题: ${resultTitle}`); - } - - // 计算相似度(使用中文标题) - const finalDirectScore = similarity(title, chineseTitle); - finalScore = Math.max(finalDirectScore, originalScore); - - // 日志输出 - const displayInfo = chineseTitle !== resultTitle - ? `"${resultTitle}" (别名: ${chineseTitle})` - : `"${resultTitle}"`; - log("info", `[TMDB] 匹配检查 ${displayInfo} - 相似度: ${(finalScore * 100).toFixed(2)}%`); - - // 如果别名匹配达到100%,标记跳过后续所有别名搜索 - if (finalScore === 1.0 && !skipAlternativeFetch) { - skipAlternativeFetch = true; - log("info", `[TMDB] 通过别名找到完全匹配,跳过后续所有别名搜索`); - } - } - - // 更新最佳匹配 - if (finalScore > bestScore) { - bestScore = finalScore; - bestMatch = result; - bestMatchChineseTitle = chineseTitle; - } - } + }); - // 相似度阈值检查 - const MIN_SIMILARITY = 0.2; - if (!bestMatch || bestScore < MIN_SIMILARITY) { - log("info", `[TMDB] 最佳匹配相似度过低或未找到匹配 (${bestMatch ? (bestScore * 100).toFixed(2) + '%' : 'N/A'}),跳过`); - return null; - } - - log("info", `[TMDB] TMDB最佳匹配: ${bestMatchChineseTitle}, 相似度: ${(bestScore * 100).toFixed(2)}%`); - - // 第四步:获取日语详情 - const mediaType = bestMatch.media_type || (bestMatch.name ? "tv" : "movie"); - - // 内部中断检查 - if (signal && signal.aborted) { - throw new DOMException('Aborted', 'AbortError'); - } - const detailResp = await getTmdbJpDetail(mediaType, bestMatch.id); - - if (!detailResp || !detailResp.data) { - const fallbackTitle = bestMatch.name || bestMatch.title; - log("info", `[TMDB] 使用中文搜索结果标题: ${fallbackTitle}`); - return fallbackTitle; - } - - const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data; - - const jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title; - log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle}`); - - return jaOriginalTitle; + return await Promise.race([task.promise, userAbortPromise]); } catch (error) { if (error.name === 'AbortError') { - log("info", "[TMDB] 搜索已被中断"); + log("info", `[TMDB] 搜索已被中断 (Source: ${sourceLabel})`); return null; } - log("error", "[TMDB] Search error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + log("error", `[TMDB] 搜索异常: ${error.message}`); return null; } }