3292 字
16 分钟
用 Cloudflare Worker 反代 Bangumi(API / 图片 / 主站三合一,适配 NipaPlay)

Bangumi 的 API(api.bgm.tv)、图片 CDN(lain.bgm.tv)和主站网页(bangumi.tv)在国内直连经常超时(实测 000),NipaPlay 这类客户端连不上、头像封面全挂。写一个 Cloudflare Worker 把 API、图片、主站统一反代到 pages.dev 域名下,app 里改一个服务器地址就能满血。

(2026-10-03)本篇是 API+图片的单域名方案,登录不支持。 官方 App 镜像登录 / OAuth / 主站网页登录透传的完整方案见续篇:Bangumi 镜像四域名方案:Cloudflare Pages 反代 + 登录透传。本文 worker 仍然可用(纯 API 场景),NipaPlay 手动贴 token 不受影响。

更新(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 域名。修复:正则改为两个斜杠前都允许可选反斜杠,转义与未转义两种形态通吃。

实测基线#

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://lain.bgm.tv/pic/cover/... # 000 连接超时
curl -s -o NUL -w "%{http_code}" https://bangumi.tv/subject/271410 # 000 连接超时

方案:一个 Worker 五段路由(单域名)#

镜像路径回源用途
/v0/*https://api.bgm.tvv0 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() },
});
}

部署#

Terminal window
# 语法检查(只能保证语法;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。

验证#

Terminal window
# 根路径自检:必须返回 {"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/271410
curl -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 ✓ 200
104.25.252.217 47.86ms ✓ 200
104.25.246.242 51.58ms ✓ 200
104.25.249.207 53.94ms ✓ 200
104.25.245.225 54.45ms ✓ 200
104.18.34.44 60.26ms ✓ 200
104.18.44.24 60.64ms ✓ 200
172.64.146.166 67.21ms ✓ 200
104.21.89.238 67.21ms ✓ 200
104.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.dev
104.25.250.101 tsangasuna.pages.dev
104.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(两者内容必须一致)。改完按这个流程:

Terminal window
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. 线上全路径验证(见上文「验证」章节)

排查口诀(按顺序):

  1. 根路径 curl <域名>/ 必须 200 且返回 {"ok":true,...} —— 不是这个 JSON 说明 worker 没生效(部署错目录 / 旧版本)。
  2. /v0、/p1、/img、/site 各测一个 200 —— 四条主路由。
  3. 旧版 API /subject/271410、/search/subject/xx、/calendar 各测 200 —— 兜底路由(2026-09-21 修复点)。
  4. lain 残留检查:/v0/... 和 /search/... 响应体都不含 lain.bgm.tv —— 正则重写生效。
  5. 区分上游 404 与 worker 404:响应体是 Hono JSON {"message":"Route GET:... not found"} = 上游 next.bgm.tv 没有该路由(正常,说明请求穿透成功);纯文本 Not Found = worker 路由没匹配(bug)。
  6. 域名换过(被封 / 换项目)后,确认 wrangler OAuth 还在:npx wrangler whoami;部署后先等几秒再用线上请求验证,Pages 边缘缓存可能短暂返回旧 HTML(curl 加 ?t=<随机数> 绕过)。

性能实测#

  • 图片缓存命中(x-cache: HIT):约 0.5s
  • API 单次请求:约 1-1.6s,大头是国内到 CF 海外边缘的链路,回源可忽略
  • 想更快只能国内节点(EdgeOne 等),需要新域名 + ICP 备案,收益看需求
用 Cloudflare Worker 反代 Bangumi(API / 图片 / 主站三合一,适配 NipaPlay)
https://tsangasuna.pages.dev/posts/bangumi-proxy/
作者
TsangAsuna
发布于
2026-09-16
许可协议
CC BY-NC-SA 4.0

分享文章

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

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取