Bangumi 的 API(api.bgm.tv)、评论 API(next.bgm.tv)、图片 CDN(lain.bgm.tv)和主站(bgm.tv)在国内直连经常超时(实测 000)。这篇是 2026-10-25 的最终形态:一个 Cloudflare Pages worker 同时伺候三种客户端——官方 App、czy0729/Bangumi 安卓第三方客户端、NipaPlay——外加浏览器直接访问镜像站。
方案演进:2026-09-16 单域名三合一(前一篇,仍可用于纯 API 场景)→ 2026-10-03 官方 App 镜像根域四域名 + 登录透传 → 2026-10-25 多客户端兼容(本文新增内容)。四域名骨架来自官方 App 的镜像机制,本文在其上加 Czy0729 三前缀、makabaka11 语义、三合一协商与 NipaPlay 封面加速。
实测基线
curl -s -o NUL -w "%{http_code}" https://api.bgm.tv/v0/subjects/271410 # 000 连接超时curl -s -o NUL -w "%{http_code}" https://next.bgm.tv/p1/calendar # 000curl -s -o NUL -w "%{http_code}" https://bgm.tv/subject/271410 # 000官方 App 的镜像机制(四域名骨架)
看官方 App 源码(bangumi/Bangumi-iOS 的 BangumiDomains.swift):设置里填一个 mirrorRootDomain,App 固定派生域名,不走任何可配置的路径前缀:
| App 派生的域名 | 上游 | 承担 |
|---|---|---|
<根域> | bgm.tv | 主站域名:登录 + 大部分功能(条目页、受限条目、超展开、设置等 App 内嵌网页视图全走这里) |
next.<根域> | next.bgm.tv | API 域名:全部 p1 接口(授权、收藏条目、观看进度)+ OAuth 授权页 |
lain.<根域> | lain.bgm.tv | 图片域名:封面、头像 |
(第三方客户端)api.<根域> | api.bgm.tv | 纯 API 域名(v0 收藏接口只在这里有) |
两个关键源码事实:
- App 的所有 API 都走
p1/*路径(grep 过整个仓库,没有 v0 调用),所以 next 域全量代理 next.bgm.tv 就够;而 v0 收藏接口只在 api.bgm.tv 上存在(next.bgm.tv 的 nginx 对POST /v0/users/-/collections返回 405)。 - OAuth 默认
authDomain=.next:App 内置浏览器打开next.<根域>/oauth/authorize→ 登录/授权 → 302 到chii://oauth/callback?code=...→ App POSTnext.<根域>/oauth/access_token换 token。
登录的三大根因(踩坑核心)
根因一:next./lain. 子域根本没有路由到 worker。 *.pages.dev 有泛域名证书,子域 TLS 能握上手,但请求落到的是 Cloudflare Pages 的 404 页。解法:Pages 分支别名——wrangler pages deploy --branch next 会生成 next.<项目>.pages.dev 别名,分支名即子域名。每个子域角色对应一个同名分支,内容是同一份 worker,缺一不可。
根因二:新版 SPA 登录页的 Turnstile 锁域名。 next.bgm.tv 的 /login 是 React SPA + Cloudflare Turnstile,sitekey 只允许官方域名,镜像域上必报 110200。解法:worker 把 next.<根域>/login 302 到主域的 bgm.tv 旧版登录表单(邮箱+密码+图片验证码,无 Turnstile),并把 SPA 的 backTo 参数翻译成旧版的 chii_referer。
根因三(隐含):Cookie 域名。 上游 Set-Cookie: domain=.bgm.tv 必须改写为镜像根域,浏览器才会保存;*.pages.dev 在 Public Suffix List 上,这样写合法,且主域与 next./lain. 子域共享会话——正好对应上游 .bgm.tv 的 SSO 行为。
域名角色与部署
| 角色 | 域名 | 上游 | Pages 分支 |
|---|---|---|---|
| 主站域名(登录+大部分功能) | bgm-proxy-iv9.pages.dev(web. 同义别名) | bgm.tv 1:1 交互镜像 | main |
| API 域名(授权/收藏/进度 + OAuth) | next.bgm-proxy-iv9.pages.dev | next.bgm.tv 全量代理 | next |
| 图片域名(封面/头像,缓存 30 天) | lain.bgm-proxy-iv9.pages.dev | lain.bgm.tv | lain |
| 纯 API 域名(makabaka11 语义) | api.bgm-proxy-iv9.pages.dev | /p1/*→next,其余→api.bgm.tv | api |
域名要逐字核对——教程里写错过一次
next.bg m-proxy...(多了空格),复制过去就是连不上。
部署(五个分支一次发完):
cd bgm-proxyCopy-Item src\_worker.js pages\_worker.js -Force # src 为源,pages/_worker.js 是 Pages 高级模式入口node --check pages\_worker.jsnpx wrangler pages deploy pages --project-name bgm-proxy --branch main --commit-dirty=true# ……lain / next / web / api 各来一遍(deploy.sh 一键跑完)czy0729 安卓客户端:单域名三前缀
czy0729/Bangumi(安卓第三方客户端)的设置页有三个填入位,推荐填法是同一个域名配三个路径前缀:
主站域名 https://<根域>/siteAPI 域名 https://<根域>/api图片域名 https://<根域>/img它内置测速会请求 bgm.tv/subject/<id>、api.bgmapi.com/calendar、lain.bgm.tv/r/200/… 并把 host 替换成你填的三个地址。worker 侧对应做三个剥壳:
// /site 剥壳 → bgm.tv 镜像if (p === "/site" || p.startsWith("/site/")) p = p.slice(5) || "/";// /api 剥壳 → 见下一节(语义最重)// /img 剥壳 → 图片代理两个实测坑:
/img与 bgm.tv 自有静态目录撞车。bgm.tv 自己的图片就在/img/下(/img/b5/ico_week.png、/img/favicon.ico),不能把整个/img/前缀都转去 lain。解法:剥壳后先判isImagePath(/r/<尺寸>/或/pic/<类型>/开头才走图片代理),其余原样转发主站。/img根路径给一个浏览器可见的自测页(内嵌一张真实封面),测速时肉眼即可确认代理活着。- 「使用了自建 Worker?」开关保持关闭。读了客户端源码(
src/utils/proxy/strategy.ts):关闭 = 客户端只替换 URL、正常转发请求头(适合透明反代);开启 = 客户端把请求头改写成它自家转发节点的形态(x-upstream路由头 + 密钥),与本文这套 worker 不匹配。
makabaka11 语义:收藏为什么一直转圈
三前缀装好后客户端能跑,但收藏一直转圈。抓客户端源码(src/constants/api/index.ts)发现它的收藏根本不走 v0:
// 旧版 API!不是 /v0/users/-/collectionsexport const API_COLLECTION = (subjectId) => `${API_HOST}/collection/${subjectId}`类似的老路径还有 /ep/<id>/status/<status>(进度)、/subject/<id>/update-watched、/user/<id>/collections/<type>、/calendar——全都挂在 api.bgmapi.com(即 api.bgm.tv 家族)。而我的 /api 前缀剥壳后,这些路径全被当成 bgm.tv 主站页面转发——主站当然没有 /collection/<id> 这个 API。
这正是 makabaka11/bangumi-proxy-workers 的 worker 里最关键的一条路由语义:
// makabaka11 原版:所有请求共用 API 域名,按路径分流const upstream = url.pathname.startsWith("/p1/") ? BGM_API_NEXT : BGM_API;对齐后,/api 前缀剥壳后的路由变成:/p1/* → next.bgm.tv,其余全部 → api.bgm.tv(v0、旧版 collection/ep/subject/user/calendar 全覆盖)。验证收藏落地:
curl -X POST "https://<根域>/api/collection/<subject_id>" -d "status=want&rating=8"# 无有效 token 时上游返回 {"code":401,"error":"Unauthorized"}# ← 这条 JSON 就是"请求已真实到达收藏端点"的落地证据,带 token 即成功还有一个容易忽略的改写差异:makabaka11 的 worker 会把 API 响应 JSON 里的 lain.bgm.tv 整体替换成图片域名,客户端拿到数据后只访问你的域名。我们的 /api 通道同样把 lain 改写成 <根域>/img(图片域名的形态),客户端再怎么校验 URL 归属都不会踩到被墙域名。
三合一:Accept 协商
三前缀之外还有一种更懒的填法:三个字段填同一个根域,不带任何前缀。此时 axios 发出的 API 请求(/collection/…、/calendar、/user/…)和浏览器发出的页面请求(/user/sai 的 HTML)长得很像,只能靠 Accept 头区分:
// axios 默认 Accept: application/json, text/plain, */*// 浏览器导航 Accept: text/html, …const wantsJson = accept.includes("application/json") && !accept.includes("text/html");if (wantsJson && /^(\/calendar|\/user\/|\/collection\/|\/ep\/|\/subject\/\d+(\/ep)?)$/.test(p)) → api.bgm.tv; // 其余一律主站镜像两个实测教训写进实现里:
- 判定里不能放
*/*。jQuery/bgm.tv 页面自身的 ajax 大量带*/*,放了会把主站页面自己的请求误转到 api.bgm.tv。 /calendar有两种返回:旧版api.bgm.tv/calendar是数组、next.bgm.tv/p1/calendar是对象。czy0729 期望数组,所以 JSON 态的/calendar指向旧版端点;浏览器看到的 HTML 日历页照旧。
NipaPlay:封面端点的”隐形两跳”
NipaPlay 会把封面 URL 持久化成主域形态:/v0/subjects/<id>/image?type=large。上游这个端点不直接回图,而是 302 到 lain 的 /pic/cover/…。于是每张封面 = 两个串行请求,第一跳还是动态的(上游只缓存 1 小时),实测全程 2.5~3 秒。
worker 里加一个端点级处理:服务端跟随 302 拿到图片本体,按图片域同样的策略边缘缓存(200 → 30 天):
async function handleV0Image(req, url, ctx, upstreamPath) { const upstreamURL = `https://api.bgm.tv${upstreamPath}${url.search}`; // caches.default:命中直接回;未命中 fetch(redirect:"follow") 取本体后缓存}实测同一张封面从”302 + 跟跳 ≈ 3s”变成”一次直达 1.2~2.6s,边缘命中后更快”。NipaPlay 侧什么都不用改。
实现坑:这类 handler 的上游 URL 必须用剥壳后的路径拼,不能直接抄
url.pathname——多前缀场景下(如/img/v0/…)拿原始 pathname 回源,上游直接 404。批量改代码时每个调用点的变量名(p还是rest)要逐个核对。
排查实录:时光机列表空白
修完上面三件事,客户端又出现新症状:时光机五个 tab 全空白。按链路逐级还原:
- 列表请求是什么? 读客户端
src/stores/user/fetch.ts:GET api.bgmapi.com/user/<uid>/collections/anime?max_results=100(旧版 API)。 - 上游返回什么? 实测返回
{"code":404,"error":"Error: APP ID Parameter is Missing"}——不是数组,客户端解析data[0].collects直接崩,列表全空。 - 为什么报 APP ID Missing? 旧 API 的鉴权模型:带
Authorization: Bearer <token>,或者带?app_id=<已注册应用id>(未登录态访问公开数据用)。客户端源码里两个都带(app_id明文写在src/constants/app.ts),所以只有 token 过期且 app_id 也没带上的请求才会报这个错。 - 复现并确认修复:带上
app_id请求 → 200,响应是[{collects:[{status,list}]}]数组,封面 URL 已被改写成图片域名且可加载。
结论:客户端提示「授权信息过期」是 token 真过期(重新登录即可);而代理侧要保证的是 /api/user/<uid>/collections/... 这条旧 API 链路原样可达 + JSON 里的图片 URL 已改写。两层都对了,列表和封面就都回来了。
排查这类”客户端白屏/空列表”问题的通用路径:先抓客户端源码还原真实请求(URL + 鉴权头 + 期望的响应结构),再逐级对代理做等价请求。czy0729 是开源的,app_id、token 注入、URL 拼接逻辑全都写在源码里,guess 是没有出路的。
登录透传与验证码
网页登录:浏览器打开 <根域>/login,bgm.tv 旧版表单(邮箱+密码+图片验证码),表单提交(POST /FollowTheRabbit)与会话 Cookie 全部原样透传,Cookie 域改写为镜像根域。
官方 App 登录(OAuth):App 里填镜像根域名 → 内置浏览器打开 next.<根域>/oauth/authorize → 未登录自动跳主站登录页 → 登录后回授权页点「授权」→ App 自动拿 token。全链实测每一跳都是上游标准语义。
czy0729 重新授权:客户端提示「授权信息过期」后,点重新登录走 webview,bgm.tv/oauth/authorize 与 bgm.tv/oauth/access_token 都经主站前缀镜像逐跳可达(假凭据返回上游标准 invalid_client/invalid_grant)。前提是先完成登录——token 过期时先重新登录,收藏/列表随之恢复。
验证码出现时机(大坑)
bgm.tv 登录页的验证码只在邮箱和密码都有值之后才出现:页面 JS 监听两个输入框,都有值才把验证码区域滑出并加载 /signup/captcha?时间戳。页面初始看不到验证码不是 bug。浏览器自动填充可能不触发 input 事件——在密码框里随便敲一个字符验证码就出来了。
worker 关键代码
完整源码在 src/_worker.js,核心结构:
// Host 分流:子域角色 + 镜像根域(parent)function resolveRole(hostname) { const m = hostname.toLowerCase().match(/^(lain|web|next|api)\.(.+)$/); if (m) return { role: m[1], parent: m[2] }; return { role: "main", parent: hostname.toLowerCase() };}主域上的前缀路由顺序(多客户端兼容的核心,自上而下先命中先返回):
// 1. /api 前缀(czy0729 API 域名)→ makabaka11 语义:// /p1 → next.bgm.tv,其余 → api.bgm.tv;JSON 里 lain → <根域>/img// 2. /site 剥壳 → bgm.tv 镜像// 3. /img 剥壳 → 图片代理(isImagePath 判定),其余 /img/* 回退主站静态// 4. 图片路径 /r/<n>/、/pic/… → 图片代理(单域名客户端直接拼根域取图)// 5. /v0/subjects/<id>/image → 服务端跟随 302 + 30 天缓存(NipaPlay 封面)// 6. Accept 协商(三合一):旧版 API 路径 + application/json → api.bgm.tv// 7. /v0、/p1 别名 → 对应上游// 8. 其余 → bgm.tv 1:1 镜像四段基础改写是登录透传的关键(详见上一篇的说明,本篇不再重复):
// Cookie 域名改写 / 响应体域名改写 / Location 改写+斜杠折叠 / SPA 登录 302 绕行性能项:图片 200 缓存 30 天 / 404 缓存 1 小时;主站静态资源边缘缓存 1 天;HTML 注入 <link rel="preconnect" href="https://lain.<根域>">;全域名 robots.txt + X-Robots-Tag: noindex 防 SEO 收录。
验证清单(47 项探针,部署后全跑)
官方四域名:主站条目/登录表单/timeline/settings/畸形ID、next p1+p1.me(401)+OAuth(302)、 api v0 改写+收藏 401、图片 x-cache、/calendar 双形态三前缀: /site/subject/<id> 200 标题正确 /img/r/400/封面 200;/img 根 200 自测页;/img/b5/… 200(主站静态不受影响)makabaka11: /api/calendar 200 数组 7 天 /api/collection POST 200 code=401(到达端点) /api/v0 JSON lain→<根域>/img 改写、无 lain.bgm.tv 残留三合一: /collection POST (json) code=401;/calendar (json) 200;/user/<id> json 200、html 200 主站时光机: /api/user/<uid>/collections/anime?app_id=… 200 数组、封面可加载OAuth: /site/oauth/authorize 302 → /login;根域同回归: 官方 lain 域、NipaPlay 主域 /v0 封面、/login 表单、p1 接口全部不受影响# 抽查示例curl -s -o NUL -w "%{http_code}`n" https://bgm-proxy-iv9.pages.dev/site/subject/40339curl -s -X POST https://bgm-proxy-iv9.pages.dev/api/collection/<subject_id> -d "status=want"# → {"code":401,"error":"Unauthorized"}(无 token 时的上游真实语义 = 链路已到达端点)容灾设计:让”域名挂掉 / 上游波动”不再发生
三类故障源,三套对策,全部在 worker 内自愈:
1. 上游故障(bgm.tv 偶发 503/522)→ 多上游池。
bgm.tv / bangumi.tv / chii.in 是同一服务的三个官方域名(czy0729 源码里的 HOST_2/HOST_3 备用域)。worker 回源不再写死单一上游,而是按池顺序尝试:任一上游网络失败 / 5xx / 超时(12s),自动切换下一个;每笔回源都有超时熔断,不会让慢上游拖死请求。API/next 单上游也带一次重试。
2. 上游全挂 → stale 缓存兜底。
全部上游失败时,worker 回退最近一次成功缓存的响应(带 x-stale: 1 头)——条目页、API JSON、图片都可能”略旧但可用”,而不是 5xx 白屏。带凭据的请求不进容灾缓存,鉴权数据零风险。
3. pages.dev 域名级故障 → 独立的 workers.dev 备份。
Pages 项目之外,把同一份 worker 用 wrangler deploy 再部署一个 Cloudflare Worker(workers_dev = true),得到独立的 *.workers.dev 域名。它不经过 Pages 的部署管线,与 pages.dev 是两个故障域:czy0729 的 API/图片两个字段可随时切到备用域名(主站镜像语义在备用域不可用,属已知限制)。真要彻底摆脱 pages.dev,注册一个域名托管到 CF 绑 Custom Domain 即可。
4. 性能项:公开 GET API 边缘缓存。
无凭据的 GET JSON(条目详情、日历等公开数据)写入 caches.default 120 秒——二次访问零回源,省掉 Worker→上游的整段 RTT;缓存键带上游/改写配置维度,换配置不串缓存;带凭据请求绝不缓存。
图片测速失败的真相:colo 冷缓存 × 3 秒硬超时
用 czy0729 客户端实测时有个反复出现的现象:主站/API 测速绿勾(467/620ms),图片测速红叉——但图片实际能正常加载。抓源码 + 实测拆解出的完整因果:
- 测速的实现(
src/utils/fetch/xhr.ts的ping):XHR GET、必须 200、3 秒硬超时、无重试,失败即resolve(0)。 - CF 边缘缓存是按节点(colo)隔离的:本机宽带命中 LAX,反复预热后测速图 0.9s 稳过;手机蜂窝命中另一个 colo,缓存是冷的——测速 MISS → 回源 lain.bgm.tv(实测波动 0.9~2.7s)+ 手机 RTT/TLS ~1.5s ≈ 4s+,必超 3 秒线。
- 图片实际加载不受影响:封面渲染走 Glide + DoH 的另一套网络栈,没有 3 秒限制、失败自动重试——所以”图能显示、测速红叉”并不矛盾。
缓解(已部署):图片回源加 cf: { cacheEverything: true, cacheTtl: 86400 } 子请求级缓存,回源结果进 CF 全球缓存网络,其他 colo 的回源能从 CF 内部网吸收,冷 colo 首测的成功率显著提高。仍会偶发:首测撞上冷 colo + 回源抖动,再测一次必过(colo 已热)。
结论:图片测速红叉 ≠ 图片功能故障;它是”3 秒内没完成 TLS + 回源”的数学结果。以实际刷图为准,测速数字只做两次连续测试间的相对比较(首测预热、二测读数)。彻底解决测速体验需要 App 侧改进(ping 重试一次即可),已在下文给出 issue 建议。
部署核对清单:makabaka11 方案未覆盖的坑
makabaka11/bangumi-proxy-workers 的 README 只声明”仅代理 v0 REST API + 图片双域名”,它没有写、而我们实际踩到的坑:
| 坑 | 现象 | 修复 |
|---|---|---|
czy0729 安卓客户端大量使用旧版 API(/collection/<id> 收藏、/ep/<id>/status 进度、/calendar 数组) | 旧路径被错误路由(转主站或图片上游)→ 收藏转圈、列表空白 | API 通道按 makabaka11 语义分流:/p1→next,其余全部→api.bgm.tv |
lain 的角色/制作人员路径 /pic/crt/ | 枚举式路径白名单漏 crt → 角色缩略图全 404 | 图片判定按顶级目录放行(/pic/、/r/<n>/),不要枚举子目录 |
| czy0729 的三前缀填法(/site /api /img) | 前缀未剥壳 → 全部 404 | 主站/API/图片三前缀剥壳路由 |
| 登录是多步会话流程(sid → formhash → POST) | 多上游池把 POST 切到备用域 → 会话 cookie 互不相认 → 登录必败 | 带 Cookie 的请求与登录/OAuth 路径固定单上游(会话粘性) |
| 容灾缓存存了未改写的 body | 缓存命中返回带 lain.bgm.tv 原链接的内容 → 图片裂 | 缓存写入点必须在域名改写完成之后;改缓存结构时 bump 键版本 |
排查口诀
- 先分清是谁的 401/404:
/p1/me401 = 上游要登录(正常,说明链路通);{"code":404,"error":"APP ID Parameter is Missing"}= 旧 API 缺 token/app_id;nginx HTML 405 = 打错了上游家族。 - 上游对不存在路径返回 200 状态码 + 404 JSON body——探针必须看 body,只看状态码会被骗。
- 客户端报白屏/空列表,先抓客户端源码还原真实请求(URL + 鉴权头 + 期望的响应结构),再对代理做等价请求。开源客户端的 app_id、token 注入、URL 拼接全在源码里。
- Node fetch 默认跟随重定向,测 302 链要
curl或redirect: "manual"。 - 上游 bgm.tv 偶发 503/522:上游波动,几分钟自愈,重试即可,别急着改部署。
*.pages.dev国内 RTT 1~2 秒是物理底噪,测速数字难看不代表坏,以内容能否加载为准。- 部署后等几秒再验证,curl 加
?t=<随机数>绕缓存;改 handler 后每个调用点的路径参数要逐个核对(pvsrest)。
与前一篇方案的关系
前一篇的单域名 worker(/v0 /p1 /img /site 前缀 + 裸路径兜底)依然有效,适合只需要 API+图片、不需要登录的场景。本篇这套是它的超集:官方 App 镜像机制 + OAuth 登录链 + Cookie 域改写 + 主站交互镜像 + 多客户端语义对齐,全部收进一个按 Host 分流的 worker。
参考
- makabaka11/bangumi-proxy-workers — API/图片双域名语义、按路径分流 + JSON 里 lain 改写为图片域名
- czy0729/Bangumi — 三前缀填法、旧版 API 路径表、
app_id鉴权模型、代理策略源码(多客户端适配的依据) - Yuri-NagaSaki/bangumi-proxy — 交互镜像/只读镜像 nginx 模板、robots.txt + noindex
- bangumi/Bangumi-iOS —
BangumiDomains镜像机制、OAuth 流程(本方案逆向的依据) - bangumi/frontend — 新版登录页 Turnstile(确认镜像不可用)
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 0 天
部分内容可能已过时