为bilibili支持解析服务器实现港澳台搜索*2,为云部署支持反向代理 (#139)
* 去除反向代理相关判断,即将迁移至globals.js文件
* 增加反向代理相关判断,为云部署支持反向代理配置
* 增加流式传输方法,增加外部中断控制器
b站app接口无结果会返回高达3.69mb的推荐数据,这是人啊*2
外部中断控制器实现了原始搜索成功真打断tmdb搜索
* 支持外部随时传入的真中断,同时实现并发请求合并
* 去除非必要的点位检查,同时使用新的代理URL构建
* 实现web与app接口的港澳台番剧搜索与获取
目前只能通过万能反代或特定反代开启。实际实现很简单,但因为全网没有一个喂饭教程所以踩了超级超级多的坑,但也是多亏了踩坑现在对于港澳台的兼容非常非常健壮😵*2
* 更新说明
* 更新说明
* 更新文档
This commit is contained in:
1 parent
8526f7c501
commit
435e22210a
9 files changed
+960
-464
No files matched your search
@@ -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生效,支持格式:<br>`http://127.0.0.1:7890`(正常代理)<br>`@http://127.0.0.1`(万能反代)<br> `bahamut@http://127.0.0.1` 或 `tmdb@http://127.0.0.1`(特定反代)<br> `http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`(混合配置) <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 |
|
||||
| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili生效,支持格式:<br> 正常代理:`http://127.0.0.1:7890` <br> 万能反代:`@http://127.0.0.1` <br> 特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)<br> 混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址` <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用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部署最佳。
|
||||
|
||||
+4
-1
@@ -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=
|
||||
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
|
||||
+32
-74
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
+322
-87
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 || '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式 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<any>} 返回 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;
|
||||
}
|
||||
}
|
||||
+345
-274
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user