4719 字
24 分钟
Bangumi 镜像全客户端方案

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 封面加速。

实测基线#

Terminal window
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 # 000
curl -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.tvAPI 域名:全部 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 POST next.<根域>/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.devnext.bgm.tv 全量代理next
图片域名(封面/头像,缓存 30 天)lain.bgm-proxy-iv9.pages.devlain.bgm.tvlain
纯 API 域名(makabaka11 语义)api.bgm-proxy-iv9.pages.dev/p1/*→next,其余→api.bgm.tvapi

域名要逐字核对——教程里写错过一次 next.bg m-proxy...(多了空格),复制过去就是连不上。

部署(五个分支一次发完):

Terminal window
cd bgm-proxy
Copy-Item src\_worker.js pages\_worker.js -Force # src 为源,pages/_worker.js 是 Pages 高级模式入口
node --check pages\_worker.js
npx wrangler pages deploy pages --project-name bgm-proxy --branch main --commit-dirty=true
# ……lain / next / web / api 各来一遍(deploy.sh 一键跑完)

czy0729 安卓客户端:单域名三前缀#

czy0729/Bangumi(安卓第三方客户端)的设置页有三个填入位,推荐填法是同一个域名配三个路径前缀:

主站域名 https://<根域>/site
API 域名 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/-/collections
export 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 全覆盖)。验证收藏落地:

Terminal window
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 全空白。按链路逐级还原:

  1. 列表请求是什么? 读客户端 src/stores/user/fetch.ts:GET api.bgmapi.com/user/<uid>/collections/anime?max_results=100(旧版 API)。
  2. 上游返回什么? 实测返回 {"code":404,"error":"Error: APP ID Parameter is Missing"}——不是数组,客户端解析 data[0].collects 直接崩,列表全空。
  3. 为什么报 APP ID Missing? 旧 API 的鉴权模型:带 Authorization: Bearer <token>,或者带 ?app_id=<已注册应用id>(未登录态访问公开数据用)。客户端源码里两个都带(app_id 明文写在 src/constants/app.ts),所以只有 token 过期且 app_id 也没带上的请求才会报这个错。
  4. 复现并确认修复:带上 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 接口全部不受影响
Terminal window
# 抽查示例
curl -s -o NUL -w "%{http_code}`n" https://bgm-proxy-iv9.pages.dev/site/subject/40339
curl -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),图片测速红叉——但图片实际能正常加载。抓源码 + 实测拆解出的完整因果:

  1. 测速的实现(src/utils/fetch/xhr.ts 的 ping):XHR GET、必须 200、3 秒硬超时、无重试,失败即 resolve(0)。
  2. CF 边缘缓存是按节点(colo)隔离的:本机宽带命中 LAX,反复预热后测速图 0.9s 稳过;手机蜂窝命中另一个 colo,缓存是冷的——测速 MISS → 回源 lain.bgm.tv(实测波动 0.9~2.7s)+ 手机 RTT/TLS ~1.5s ≈ 4s+,必超 3 秒线。
  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 键版本

排查口诀#

  1. 先分清是谁的 401/404:/p1/me 401 = 上游要登录(正常,说明链路通);{"code":404,"error":"APP ID Parameter is Missing"} = 旧 API 缺 token/app_id;nginx HTML 405 = 打错了上游家族。
  2. 上游对不存在路径返回 200 状态码 + 404 JSON body——探针必须看 body,只看状态码会被骗。
  3. 客户端报白屏/空列表,先抓客户端源码还原真实请求(URL + 鉴权头 + 期望的响应结构),再对代理做等价请求。开源客户端的 app_id、token 注入、URL 拼接全在源码里。
  4. Node fetch 默认跟随重定向,测 302 链要 curl 或 redirect: "manual"。
  5. 上游 bgm.tv 偶发 503/522:上游波动,几分钟自愈,重试即可,别急着改部署。
  6. *.pages.dev 国内 RTT 1~2 秒是物理底噪,测速数字难看不代表坏,以内容能否加载为准。
  7. 部署后等几秒再验证,curl 加 ?t=<随机数> 绕缓存;改 handler 后每个调用点的路径参数要逐个核对(p vs rest)。

与前一篇方案的关系#

前一篇的单域名 worker(/v0 /p1 /img /site 前缀 + 裸路径兜底)依然有效,适合只需要 API+图片、不需要登录的场景。本篇这套是它的超集:官方 App 镜像机制 + OAuth 登录链 + Cookie 域改写 + 主站交互镜像 + 多客户端语义对齐,全部收进一个按 Host 分流的 worker。

参考#

Bangumi 镜像全客户端方案
https://tsangasuna.pages.dev/posts/bangumi-proxy-mirror-login/
作者
TsangAsuna
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取