Bangumi 的 API(api.bgm.tv)、图片 CDN(lain.bgm.tv)和主站网页(bangumi.tv)在国内直连经常超时(实测 000),NipaPlay 这类客户端连不上、头像封面全挂。写一个 Cloudflare Worker 把 API、图片、主站统一反代到 pages.dev 域名下,app 里改一个服务器地址就能满血。
更新(2026-09-16):worker 从旧版「/api 前缀」升级为「根路径三合一」,NipaPlay 的 bangumi 代理机制要求
/v0(主 API)和/p1(评论等 API)路由到同一个域名下,图片反代则是直接替换 lain.bgm.tv 为图片反代域名。新版同时支持主站/site网页反代。
更新(2026-09-21):修复两个线上故障。① API「无法连接」:worker 原来只匹配
/v0 /p1 /img /site四条路由,其余 API 路径(旧版无前缀 API:/subject/xxx、/search/xxx、/calendar等)全部返回 404,客户端表现为「api 连不上、图片和主站正常」。修复:兜底路由把所有未匹配路径透传到 api.bgm.tv(与 makabaka11/bangumi-proxy-workers 语义一致)。② 旧版 API 返回的 JSON 图片 URL 是 PHP json_encode 转义形态http:\/\/lain.bgm.tv\/...,原正则匹配不了(\ / \ /四字符),导致旧版接口的图片地址没被重写、仍是被墙的 lain 域名。修复:正则改为两个斜杠前都允许可选反斜杠,转义与未转义两种形态通吃。
实测基线
curl -s -o NUL -w "%{http_code}" https://api.bgm.tv/v0/subjects/271410 # 000 连接超时curl -s -o NUL -w "%{http_code}" https://lain.bgm.tv/pic/cover/... # 000 连接超时curl -s -o NUL -w "%{http_code}" https://bangumi.tv/subject/271410 # 000 连接超时方案:一个 Worker 五段路由(单域名)
| 镜像路径 | 回源 | 用途 |
|---|---|---|
/v0/* | https://api.bgm.tv | v0 REST 主 API(app / NipaPlay 对接) |
/p1/* | https://next.bgm.tv | 新版接口(评论、收藏条目列表等) |
/img/* | https://lain.bgm.tv | 封面 / 头像 / 人物图,CF 边缘缓存 30 天 |
/site/* | https://bangumi.tv | 主站网页(HTML 静态资源自动重写加 /site 前缀) |
| 其余任意路径 | https://api.bgm.tv | 兜底:旧版无前缀 API(/subject/xxx、/search/xxx、/calendar 等) |
关键点一:API 路径原样透传。NipaPlay 拼的是 <根域名>/v0/... 与 <根域名>/p1/...,这里的 /v0、/p1 就是上游的真实路径前缀,worker 不能剥掉,否则会 404。
关键点一点五(2026-09-21 新增):兜底路由不能省。只有四条显式路由时,旧版无前缀 API(/subject/271410、/search/subject/xxx、/calendar)全部 404,客户端只填一个根域名时会连不上 api。参考项目 makabaka11/bangumi-proxy-workers 的做法是「非 /p1/ 的路径全透传 api.bgm.tv」——根路径直接兜底即可,任何 API 路径都能通。
关键点二:JSON 响应体重写。API 返回的 JSON 里图片是写死的 lain.bgm.tv 绝对 URL(而且常被 JSON 转义成 https:\/\/lain.bgm.tv)。worker 把响应体里这类整 URL 统一替换成本站 /img 前缀,客户端拿到数据后所有图片请求都只碰你的域名,再也碰不到被墙的 bgm.tv。这样只填 API 地址就能反代图片,图片反代设置是可选的加速项。
关键点三:主站 HTML 资源重写。bangumi.tv 的 HTML 里静态资源(css/js/图片/导航)大多是根相对路径 /css/...、/js/...、/img/...,浏览器直接请求反代域名会 404。worker 对 text/html 响应做三层重写:lain 图床绝对/协议相对 URL → /img;主站绝对域名 → /site;根相对路径 → /site/ 前缀。301 跳转的 Location 同样处理(绝对域名和相对路径都加前缀)。
worker 代码(pages/_worker.js,Cloudflare Pages Advanced Mode 格式,部署目录必须含 _worker.js):
// Bangumi 反代(NipaPlay 用),参考 makabaka11/bangumi-proxy-workers。// 路由:// /v0/* → api.bgm.tv(主 API)// /p1/* → next.bgm.tv(评论等 API)// /img/* → lain.bgm.tv(图片 CDN)+ 边缘缓存 30 天// /site/* → bangumi.tv(主站网页)// /api/* → 兼容旧填法(剥掉 /api 后按 /v0 /p1 路由)// 其余任意路径 → api.bgm.tv(旧版无前缀 API:/subject、/search、/calendar 等)const BGM_API = "api.bgm.tv";const BGM_API_NEXT = "next.bgm.tv";const BGM_IMG = "lain.bgm.tv";const BGM_SITE = "bangumi.tv";const IMG_CACHE_TTL = 30 * 24 * 60 * 60;
export default { async fetch(req, env, ctx) { const url = new URL(req.url); if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders() });
let p = url.pathname; // 兼容旧 /api 前缀填法:剥掉后按根路径路由 if (p === "/api" || p.startsWith("/api/")) p = p.slice(4) || "/";
if (p === "/v0" || p.startsWith("/v0/")) return handleApi(req, url, p, BGM_API); if (p === "/p1" || p.startsWith("/p1/")) return handleApi(req, url, p, BGM_API_NEXT); if (p.startsWith("/img/")) return handleImg(req, url, ctx); if (p === "/site" || p.startsWith("/site/")) return handleSite(req, url, p); if (p === "/") return json({ ok: true, api: url.origin, img: url.origin + "/img", site: url.origin + "/site" }); // 兜底:其余路径(旧版无前缀 API:/subject/xxx、/search/xxx、/calendar 等) // 全部透传 api.bgm.tv,与 makabaka11/bangumi-proxy-workers 语义一致 return handleApi(req, url, p, BGM_API); },};
async function handleApi(req, url, p, upstream) { // /v0 /p1 是上游真实路径前缀,原样透传 const resp = await fetch(new Request(`https://${upstream}${p}${url.search}`, { method: req.method, headers: cleanRequestHeaders(req.headers), body: ["GET", "HEAD"].includes(req.method) ? undefined : req.body, redirect: "follow", })); const ct = resp.headers.get("content-type") || ""; const headers = new Headers(resp.headers); setCors(headers); if (ct.includes("application/json") || ct.includes("text/")) { let text = await resp.text(); // 整 URL 重写(含 scheme),兼容 JSON 转义 https:\/\/lain.bgm.tv 与未转义两种形态 text = text.replace(/https?:\\?\/\\?\/?(?:[\w-]+\.)*lain\.bgm\.tv/g, () => url.origin + "/img"); headers.delete("content-length"); headers.delete("content-encoding"); return new Response(text, { status: resp.status, statusText: resp.statusText, headers }); } return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers });}
async function handleImg(req, url, ctx) { // 两种形态:/img<path>(lain 图床,path 透传)或 /img<完整URL>(任意图床) let rest = url.pathname.slice(4); // /img 之后(可能 /pic/... 或 /http(s)://host/...) let upstreamHost = BGM_IMG; const full = rest.match(/^\/(?:https?:)?\/(.+)$/i); if (full) { try { const abs = new URL(/^https?:\/\//i.test(full[1]) ? full[1] : (url.protocol + "//" + full[1])); upstreamHost = abs.host; rest = abs.pathname + abs.search; } catch { upstreamHost = BGM_IMG; } } const upstreamURL = `https://${upstreamHost}${rest}${url.search}`; const cache = caches.default; const key = new Request(upstreamURL, { method: "GET" }); const hit = await cache.match(key); if (hit) { const r = new Response(hit.body, hit); r.headers.set("x-cache", "HIT"); setCors(r.headers); return r; } const resp = await fetch(new Request(upstreamURL, { method: "GET", headers: cleanRequestHeaders(req.headers), redirect: "follow", })); const out = new Response(resp.body, resp); out.headers.set("x-cache", "MISS"); setCors(out.headers); if (resp.status === 200) { out.headers.set("cache-control", `public, max-age=${IMG_CACHE_TTL}`); ctx.waitUntil(cache.put(key, out.clone())); } return out;}
async function handleSite(req, url, p) { const rest = p.slice(5) || "/"; // 去掉 /site const resp = await fetch(new Request(`https://${BGM_SITE}${rest}${url.search}`, { method: req.method, headers: cleanRequestHeaders(req.headers), body: ["GET", "HEAD"].includes(req.method) ? undefined : req.body, redirect: "manual", })); if (resp.status >= 300 && resp.status < 400) { const loc = resp.headers.get("location"); if (loc) { const out = new Response(resp.body, resp); let target = loc; target = target.replace(/^https?:\/\/(?:www\.)?(?:bangumi\.tv|bgm\.tv|next\.bgm\.tv)/, url.origin + "/site"); if (/^\/(?!site\/|img\/|v0|p1)/.test(target)) { target = "/site" + target; } out.headers.set("location", target); return out; } } const headers = new Headers(resp.headers); setCors(headers); const ct = resp.headers.get("content-type") || ""; if (ct.includes("text/html")) { let text = await resp.text(); text = text .replace(/https?:\/\/(?:[\w-]+\.)*lain\.bgm\.tv/g, url.origin + "/img") .replace(/\/\/lain\.bgm\.tv/g, url.origin + "/img") .replace(/https?:\/\/(?:www\.)?(?:bangumi\.tv|(?:www\.)?bgm\.tv|next\.bgm\.tv)/g, url.origin + "/site") .replace(/(href|src|action)="\/(?!site\/)/g, '$1="/site/'); headers.delete("content-length"); headers.delete("content-encoding"); return new Response(text, { status: resp.status, statusText: resp.statusText, headers }); } return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers });}
function cleanRequestHeaders(h) { const out = new Headers(h); out.delete("host"); out.delete("cf-connecting-ip"); out.delete("cf-ipcountry"); out.delete("x-forwarded-host"); return out;}function corsHeaders() { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Authorization,Content-Type", "Access-Control-Max-Age": "86400", };}function setCors(headers) { headers.set("Access-Control-Allow-Origin", "*");}function json(obj) { return new Response(JSON.stringify(obj), { headers: { "content-type": "application/json; charset=utf-8", ...corsHeaders() }, });}部署
# 语法检查(只能保证语法;const/ReferenceError 类运行时坑要靠线上验证)node --check pages/_worker.js
# 建 Pages 项目(项目名不允许大写)npx wrangler pages project create bgm-proxy --production-branch main
# 部署(注意部署目录是 pages/,因为 _worker.js 在 pages/ 下)npx wrangler pages deploy pages --project-name bgm-proxy --branch main --commit-dirty=true部署完得到一个稳定域名,例如 https://bgm-proxy-iv9.pages.dev。
验证
# 根路径自检:必须返回 {"ok":true,...} 才是 worker 生效curl -s https://bgm-proxy-iv9.pages.dev/# API 200,响应体图片链接已重写为本站 /img(无 lain.bgm.tv 残留)curl -s https://bgm-proxy-iv9.pages.dev/v0/subjects/271410 | Select-String lain# 旧版无前缀 API 兜底(2026-09-21 新增,之前 404)curl -s -o NUL -w "%{http_code}`n" https://bgm-proxy-iv9.pages.dev/subject/271410curl -s -o NUL -w "%{http_code}`n" "https://bgm-proxy-iv9.pages.dev/search/subject/%E9%99%8C%E7%94%9F?type=2&responseGroup=small"curl -s -o NUL -w "%{http_code}`n" https://bgm-proxy-iv9.pages.dev/calendar# 旧版接口 JSON 里的图片 URL 也必须重写(PHP json_encode 转义形态 http:\/\/lain.bgm.tv)curl -s "https://bgm-proxy-iv9.pages.dev/search/subject/%E9%99%8C%E7%94%9F?type=2&responseGroup=small" | Select-String 'lain.bgm.tv'# 图片 200:image/jpeg,带 x-cache(首次 MISS,命中后 HIT)curl -s -o NUL -D - https://bgm-proxy-iv9.pages.dev/img/pic/cover/l/e4/79/271410_xxx.jpg# 主站 200,HTML 里静态资源带 /site 前缀curl -s https://bgm-proxy-iv9.pages.dev/site/subject/271410 | Select-String 'css/dist'# 301 跳转 Location 也带 /site 前缀curl -s -o NUL -D - https://bgm-proxy-iv9.pages.dev/site/anime/chart | Select-String '^Location'实测(2026-09-21):/v0、/p1、/img、/site 全 200;旧版 /subject、/search、/calendar 兜底后全 200;旧版 search 响应体 lain 残留为 False(转义形态也重写成功)。
客户端用法(NipaPlay)
NipaPlay 的 bangumi 代理机制是「代理端把 /v0(主 API)和 /p1(评论等 API)路由到同一域名,图片反代直接替换 lain.bgm.tv 为图片反代域名」,所以:
- Bangumi 服务器:填根域名
https://bgm-proxy-iv9.pages.dev(不要带 /api,app 会自己拼 /v0、/p1) - 图片反代(可选,填了会更快):
https://bgm-proxy-iv9.pages.dev/img - 主站(浏览器打开番剧页):
https://bgm-proxy-iv9.pages.dev/site/subject/<id> - 旧
/api前缀填法仍然兼容(worker 自动剥掉再按根路径路由)
图片反代有几种风格:根路径前缀(如 https://imgproxy.example.com/)是「前缀 + 完整 URL」;带子路径前缀(如 .../img)是「替换主机、保留源 path + query」——本 worker 两者都支持(/img 后跟 /pic/... 透传 lain,跟完整 URL 按 host 回源任意图床)。
为什么用 pages.dev 而不是 workers.dev
workers.dev 曾被整域 SNI 封锁;pages.dev 目前家宽可达。同一个 worker 可以再开一个 Pages 项目当备用,域名被封时秒级切(部署命令不变,换 project-name 即可)。
CF 优选(可选提速)
pages.dev 本身由 Cloudflare Anycast 调度,国内家宽大部分节点可用。想进一步提速可以:
- 自定义域名 + CNAME:把反代域名绑到自己的域名(需在 Cloudflare Dashboard → Pages → Custom domains 添加),国内 DNS 解析到 Cloudflare 亚洲节点。
- 优选 IP / 自选节点:在 hosts 或自建 DNS 里把域名指到离你近的 Cloudflare IP(可用 CloudflareSpeedTest 这类工具测出当前最快的边缘 IP)。注意 pages.dev 的 TLS SNI 校验要求域名匹配,hosts 指 IP 通常能通(CF 按 SNI 路由),但大陆部分 IP 段可能不稳,需要实测。
实测(2026-09-16,CloudflareSpeedTest v2.3.5,5955 个 IP 全测,直连无代理):最快 104.25.250.101(43.77ms,pages.dev 实测 200),pages.dev 可用优选 IP 前十:
104.25.250.101 43.77ms ✓ 200104.25.252.217 47.86ms ✓ 200104.25.246.242 51.58ms ✓ 200104.25.249.207 53.94ms ✓ 200104.25.245.225 54.45ms ✓ 200104.18.34.44 60.26ms ✓ 200104.18.44.24 60.64ms ✓ 200172.64.146.166 67.21ms ✓ 200104.21.89.238 67.21ms ✓ 200104.17.99.241 67.46ms ✓ 200注意:测速快的 IP 不一定能访问 pages.dev(如 172.65.230.233 测速 56ms 但实测 403),必须用 curl --resolve 域名:443:IP 实测过 200 再用。Windows hosts 用法:
104.25.250.101 bgm-proxy-iv9.pages.dev104.25.250.101 tsangasuna.pages.dev104.25.250.101 tsangasuna-en.pages.dev(浏览器/系统走 hosts 后需要刷新 DNS 缓存:ipconfig /flushdns)
- 收益看需求:图片缓存命中后约 0.5s,API 约 1-1.6s,大头是国内到 CF 海外边缘的链路。
踩坑记录
- 只有四条显式路由(/v0 /p1 /img /site)时,其余 API 路径全 404(2026-09-21 实测坑:客户端报「api 无法连接」,图片和主站却正常,因为图片走 /img、主站走 /site 都匹配,只有任意其它 API 路径落到 404)。参考项目语义是「非 /p1/ 的路径全透传 api.bgm.tv」——根路径兜底即可,任意 API 路径都能通。
- 区分上游 404 与 worker 404:线上 404 时先看响应体——Hono JSON
{"message":"Route GET:... not found"}= 上游 next.bgm.tv 没有该路由(正常,说明请求穿透成功);纯文本Not Found= worker 路由没匹配(bug)。 - JSON 响应里的图片 URL 有两形态:未转义
https://lain.bgm.tv/...和 PHP json_encode 转义http:\/\/lain.bgm.tv\/...,只匹配前者会漏掉一半。2026-09-21 实测坑:原正则https?:\\?\/\/只能匹配http://或http:\//,匹配不了完整转义形态http:\/\/(四个字符\ / \ /)。新版 v0 API 返回未转义 URL 所以没暴露;旧版 API(/search、/subject、/calendar等)全返回转义形态,图片地址漏重写。修法:每个斜杠前都允许可选反斜杠https?:\\?\/\\?\/?,本地先跑 node 正则用例验证再部署(5 种形态:转义 http/https、未转义 http/https、协议相对)。 - 替换完的 JSON 文本长度变了,要删掉
content-length/content-encoding头再回给客户端。 /v0、/p1是上游真实路径,绝不能剥掉(早期版本剥掉后/v0/users/...变成/users/...→ 收藏接口 404)。- 主站 301 的 Location 可能是相对路径(
/anime/browser?sort=rank),只重写绝对域名会漏,浏览器跟随后 404。 node --check只查语法:手滑写出constheaders=newHeaders(...)这种未声明变量,语法完全合法,部署后运行时报 ReferenceError → 500,只能拿线上请求验证。
手动维护(改 worker / 排查故障)
worker 源码在 src/_worker.js,部署用的副本在 pages/_worker.js(两者内容必须一致)。改完按这个流程:
cd <你的本地目录>/bgm-proxy# 1. 改 src/_worker.js → 同步到 pages/(LF 行尾,别用 Set-Content,会变 CRLF/乱码)Copy-Item src\_worker.js pages\_worker.js -Force# 2. 语法检查node --check pages\_worker.js# 3. 本地验证正则(若动过 lain 重写):写临时 .mjs 跑 5 种形态用例# 4. 部署(注意部署目录是 pages/,不是仓库根)npx wrangler pages deploy pages --project-name bgm-proxy --branch main --commit-dirty=true# 5. 线上全路径验证(见上文「验证」章节)排查口诀(按顺序):
- 根路径
curl <域名>/必须 200 且返回{"ok":true,...}—— 不是这个 JSON 说明 worker 没生效(部署错目录 / 旧版本)。 - /v0、/p1、/img、/site 各测一个 200 —— 四条主路由。
- 旧版 API
/subject/271410、/search/subject/xx、/calendar各测 200 —— 兜底路由(2026-09-21 修复点)。 - lain 残留检查:
/v0/...和/search/...响应体都不含lain.bgm.tv—— 正则重写生效。 - 区分上游 404 与 worker 404:响应体是 Hono JSON
{"message":"Route GET:... not found"}= 上游 next.bgm.tv 没有该路由(正常,说明请求穿透成功);纯文本Not Found= worker 路由没匹配(bug)。 - 域名换过(被封 / 换项目)后,确认 wrangler OAuth 还在:
npx wrangler whoami;部署后先等几秒再用线上请求验证,Pages 边缘缓存可能短暂返回旧 HTML(curl 加?t=<随机数>绕过)。
性能实测
- 图片缓存命中(x-cache: HIT):约 0.5s
- API 单次请求:约 1-1.6s,大头是国内到 CF 海外边缘的链路,回源可忽略
- 想更快只能国内节点(EdgeOne 等),需要新域名 + ICP 备案,收益看需求
Share Article
Generate a share poster or copy the link to share this article.
Continue reading
Take another route
A consistent pick from other articles
Last updated on , 5 days ago
Some content may be outdated