From 762634754eb8bfe1e229b9bd1c04a0dc1f715aef Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:29:10 +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=20(App/Web=E5=8F=8C?= =?UTF-8?q?=E6=A8=A1)=20=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 增加Bilibili专用反代判断和流式传输支持 * 增加流式传输方法,解决港澳台庞大数据读取缓慢堵塞 b站app接口无结果会返回高达3.69mb的数据,这是人啊 * 实现web与app接口的港澳台番剧搜索与获取 实际实现很简单,但因为全网没有一个喂饭教程所以踩了超级超级多的坑,但也是多亏了踩坑现在对于港澳台的兼容非常非常健壮😵 * 更新PROXY_URL说明 * 更新平台列表与注意事项 * Fix duplicate bilibili proxy URL instructions Removed duplicate instructions for enabling bilibili proxy URL in the README. * 更新cookie * 更新注意事项 * 添加返回接口报错信息,添加App接口失败回落至Web接口 * 更新BILIBILI_COOKIE说明 * 修复流式输式中断被当成请求失败回落至web接口 * 修复缩进 * 去掉bilibili-overseas * Update .env.example * Update config.yaml.example * Update .env.example * Update config.yaml.example --- README.md | 5 +- config/.env.example | 5 +- config/config.yaml.example | 11 +- danmu_api/server.js | 22 +- danmu_api/sources/bilibili.js | 394 ++++++++++++++++++++++++++-------- danmu_api/utils/http-util.js | 123 ++++++++++- 6 files changed, 465 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index bc96c33..dda6352 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,renren,hanjutv,bahamut,dandan,custom` | | PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut, dandan, custom` | @@ -386,7 +386,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` | @@ -564,6 +564,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 0624f7b..36c5466 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 66f10a9..9fab2f4 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/server.js b/danmu_api/server.js index 1a0b2ec..8a63194 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -398,6 +398,7 @@ function createProxyServer() { let forwardProxy = null; // 正向代理(传统代理) let bahamutRP = null; // 巴哈姆特专用反代 let tmdbRP = null; // TMDB专用反代 + let bilibiliRP = null; // Bilibili专用反代 let universalRP = null; // 万能反代 if (proxyConfig) { @@ -413,6 +414,10 @@ function createProxyServer() { // TMDB专用反代:tmdb@http://example.com tmdbRP = config.substring(5).trim().replace(/\/+$/, ''); console.log('[Proxy Server] TMDB reverse proxy detected:', tmdbRP); + } else if (config.startsWith('bilibili@')) { + // Bilibili专用反代:bilibili@http://example.com + bilibiliRP = config.substring(9).trim().replace(/\/+$/, ''); + console.log('[Proxy Server] Bilibili reverse proxy detected:', bilibiliRP); } else if (config.startsWith('@')) { // 万能反代:@http://example.com universalRP = config.substring(1).trim().replace(/\/+$/, ''); @@ -453,7 +458,12 @@ function createProxyServer() { finalReverseProxy = tmdbRP; console.log('[Proxy Server] Using TMDB-specific reverse proxy'); } - // 3. 检查万能反代 + // 3. 检查是否匹配Bilibili专用反代 + else if (bilibiliRP && originalUrlObj.hostname.includes('bilibili.com')) { + finalReverseProxy = bilibiliRP; + console.log('[Proxy Server] Using Bilibili-specific reverse proxy'); + } + // 4. 检查万能反代 else if (universalRP) { finalReverseProxy = universalRP; console.log('[Proxy Server] Using universal reverse proxy'); @@ -506,6 +516,16 @@ function createProxyServer() { proxyRes.pipe(res, { end: true }); }); + // 监听客户端连接断开 + // 当 bilibili.js 触发 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) => { console.error('Proxy request error:', err); res.statusCode = 500; diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 15d29cd..1853d1d 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,143 @@ 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.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(targetUrl)}` : targetUrl; + } + + // 检查是否配置了B站专用代理 + _hasBilibiliProxy() { + return (globals.proxyUrl || '').includes('bilibili@') || (globals.proxyUrl || '').includes('@'); + } + + // 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") { + 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 url = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(`https://app.bilibili.com/x/v2/search/type?${qs}&sign=${sign}`)}` : ""; + + const data = await this._fetchAppSearchWithStream(url, { "User-Agent": "Mozilla/5.0 Android", "X-From-Biliroaming": "1.0.0" }, label); + + 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) { + 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 url = globals.proxyUrl ? `http://127.0.0.1:5321/proxy?url=${encodeURIComponent(`https://api.bilibili.com/x/web-interface/search/type?${qs}`)}` : ""; + const res = await httpGet(url, { headers: { "User-Agent": "Mozilla/5.0", "Cookie": globals.bilibliCookie||"", "X-From-Biliroaming": "1.0.0" } }); + 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) { + log("error", `[Bilibili-Proxy] Web 接口请求异常: ${e.message}(如果是-500/-502说明只是风控)`); + } + return []; + } + + // 综合港澳台搜索入口 + async _searchOversea(keyword) { + const c = new AbortController(); + const t1 = this._searchOverseaRequest(keyword, "Original").then(r => { if(r.length) c.abort(); return r; }).catch(()=>[]); + const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(()=>c.signal.aborted?[]:getTmdbJaOriginalTitle(keyword, c.signal)).then(t => (t&&t!==keyword&&!c.signal.aborted)?this._searchOverseaRequest(t,"TMDB"):[]).catch(()=>[])) : Promise.resolve([]); + return (await Promise.all([t1, t2])).flat(); + } + + // APP搜索流式嗅探,针对 B 站港澳台无结果时返回的大体积推荐数据 + async _fetchAppSearchWithStream(url, headers, label) { + if (typeof httpGetWithStreamCheck !== 'function') return null; + let trusted = false; + let isNoResult = false; // 标记是否为"无结果"中断 + + const result = await httpGetWithStreamCheck(url, { + headers: headers, + sniffLimit: 8192 + }, (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..f7ca21c 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -509,4 +509,125 @@ 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); + + 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; + } +}