1328 字
7 分钟
对 Bangumi 反向代理,解决大陆地区访问问题
TIP

关于为博客添加bangumi页面的教学见上一篇文章:为博客添加bangumi页面

缘由#

继上篇文章后,我发现这个bangumi在国内访问是一片中国红啊,这就导致了天朝网络的环境下无法加载出图片。为了确保正常访问,就需要搭建一个反向代理,将图片资源从 lain.bgm.tv 替换为我们自己的。

实现原理#

通过cf worker可以很轻松的实现这个想法,只需要构建好反向代理脚本,然后绑定一个自己的域名即可。(cf默认分配的域名大陆地区会有阻断。)

不过cf woker在大陆地区的网络情况并不理想,所以我在这基础上又套了一层eo的cdn。

所以最终流程就是

用户 -> lain.flygeon.top -> eo节点 -> cfwoker -> lain.bgm.tv

为了防止建立反代被cf斩杀,我们还需要移除登录注册相关的敏感路内容。(防止被判钓鱼站点)

完整代码#

/**
* Cloudflare Workers 反向代理脚本
* 支持代理 bgm.tv 和 lain.bgm.tv
*/
// 默认目标域名(主站)
const DEFAULT_HOST = 'bgm.tv';
// 允许代理的目标域名列表
const ALLOWED_HOSTS = [
'bgm.tv',
'lain.bgm.tv',
];
// 允许的请求方法
const ALLOWED_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'];
/** 需要屏蔽的固定路径 */
const BLOCKED_PATHS = [
'/login',
'/signup',
'/register',
'/auth',
'/oauth',
];
/** 需要屏蔽的动态路径规则 */
const BLOCKED_PATTERNS = [
/^\/subject\/\d+\/edit/,
/^\/subject\/\d+\/related/,
/^\/subject\/\d+\/episode_edit/,
/^\/subject\/\d+\/merge/,
/^\/character\/\d+\/edit/,
/^\/person\/\d+\/edit/,
/^\/subject\/\d+\/\(modify\)/,
/^\/subject\/\d+\/delete/,
/^\/subject\/\d+\/recollect/,
/^\/subject\/\d+\/watched/,
/^\/subject\/\d+\/interest/,
/^\/subject\/\d+\/comments/,
];
/** 检查路径是否需要屏蔽 */
function isBlockedPath(path) {
const normalizedPath = path.replace(/\/$/, '') || '/';
for (const blocked of BLOCKED_PATHS) {
if (normalizedPath === blocked || normalizedPath.startsWith(blocked + '/')) {
return true;
}
}
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(normalizedPath)) {
return true;
}
}
return false;
}
/** 替换 HTML 中的原始域名为代理域名 */
function rewriteHtmlUrls(html, proxyOrigin) {
// 替换 lain.bgm.tv 的链接
html = html.replace(
/https?:\/\/lain\.bgm\.tv/gi,
`${proxyOrigin}`
);
// 替换 bgm.tv 的链接
html = html.replace(
/https?:\/\/bgm\.tv/gi,
`${proxyOrigin}`
);
// 处理相对协议 URL (//lain.bgm.tv)
html = html.replace(
/\/\/lain\.bgm\.tv/gi,
`${proxyOrigin}`
);
// 处理相对协议 URL (//bgm.tv)
html = html.replace(
/\/\/bgm\.tv/gi,
`${proxyOrigin}`
);
// 避免重复替换:清理可能出现的双重代理 URL
const escapedOrigin = proxyOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const doubleProxyPattern = new RegExp(
`${escapedOrigin}${escapedOrigin}/`,
'gi'
);
html = html.replace(doubleProxyPattern, `${proxyOrigin}/`);
return html;
}
/** 过滤 HTML 内容,移除登录/注册相关元素 */
async function filterHtmlContent(response, proxyOrigin) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
return response;
}
let html = await response.text();
// 替换 HTML 中的原始域名为代理域名
html = rewriteHtmlUrls(html, proxyOrigin);
// 移除登录/注册相关元素
const BLOCKED_HTML_PATTERNS = [
/<form[^>]*action="[^"]*(?:login|signin)[^"]*"[\s\S]*?<\/form>/gi,
/<form[^>]*id="[^"]*login[^"]*"[\s\S]*?<\/form>/gi,
/<form[^>]*class="[^"]*login[^"]*"[\s\S]*?<\/form>/gi,
/<form[^>]*action="[^"]*(?:signup|register)[^"]*"[\s\S]*?<\/form>/gi,
/<div[^>]*id="headerLogin"[^>]*>[\s\S]*?<\/div>/gi,
/<a[^>]*href="[^"]*(?:login|signin|signup|register)[^"]*"[^>]*>[\s\S]*?<\/a>/gi,
];
for (const pattern of BLOCKED_HTML_PATTERNS) {
html = html.replace(pattern, '');
}
const filteredHeaders = new Headers(response.headers);
filteredHeaders.delete('content-length');
return new Response(html, {
status: response.status,
statusText: response.statusText,
headers: filteredHeaders,
});
}
/**
* 主入口
*/
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
/**
* 处理 OPTIONS 预检请求
*/
function handleOptions(request) {
const headers = new Headers();
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS.join(', '));
headers.set('Access-Control-Allow-Headers', request.headers.get('Access-Control-Request-Headers') || '*');
headers.set('Access-Control-Max-Age', '86400');
return new Response(null, {
status: 204,
headers: headers,
});
}
async function handleRequest(request) {
const url = new URL(request.url);
// 处理 OPTIONS 预检请求
if (request.method === 'OPTIONS') {
return handleOptions(request);
}
// 确定目标域名
let targetHost = DEFAULT_HOST;
let targetPath = url.pathname;
let targetSearch = url.search;
// lain.bgm.tv 特有的资源路径前缀
const LAIN_PATH_PREFIXES = ['/r/', '/pic/', '/cover/', '/crt/', '/sub/', '/user/'];
if (LAIN_PATH_PREFIXES.some(prefix => url.pathname.startsWith(prefix))) {
targetHost = 'lain.bgm.tv';
}
// 检查路径是否需要屏蔽
if (isBlockedPath(targetPath)) {
return new Response(
`<!DOCTYPE html>
<html>
<head><title>访问受限</title></head>
<body style="text-align:center;padding:50px;font-family:sans-serif;">
<h1>访问受限</h1>
<p>该页面已被屏蔽。</p>
<a href="/">返回首页</a>
</body>
</html>`,
{
status: 403,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}
);
}
// 构建目标 URL
const targetUrl = `https://${targetHost}${targetPath}${targetSearch}`;
// 验证目标域名是否在白名单中
if (!ALLOWED_HOSTS.includes(targetHost)) {
return new Response(
JSON.stringify({
error: 'Target host not allowed',
allowed_hosts: ALLOWED_HOSTS,
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 复制并修改请求头
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('cf-connecting-ip');
headers.delete('cf-ray');
headers.delete('cf-visitor');
headers.delete('cf-worker');
headers.set('Host', targetHost);
// 创建代理请求
const proxyRequest = new Request(targetUrl, {
method: request.method,
headers: headers,
body: request.body,
redirect: 'manual',
});
try {
// 发送代理请求
const response = await fetch(proxyRequest);
// 复制响应并修改头部
const proxyHeaders = new Headers(response.headers);
proxyHeaders.set('Access-Control-Allow-Origin', '*');
proxyHeaders.set('Access-Control-Allow-Methods', ALLOWED_METHODS.join(', '));
proxyHeaders.set('Access-Control-Allow-Headers', '*');
proxyHeaders.delete('server');
proxyHeaders.delete('x-powered-by');
proxyHeaders.delete('via');
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = proxyHeaders.get('location');
if (location) {
try {
const redirectUrl = new URL(location, targetUrl);
if (ALLOWED_HOSTS.includes(redirectUrl.hostname)) {
const proxyRedirect = `${url.origin}${redirectUrl.pathname}${redirectUrl.search}`;
proxyHeaders.set('location', proxyRedirect);
}
} catch (e) {
// 保留原始 location
}
}
}
// 处理 Set-Cookie 中的 Domain 属性
const setCookie = proxyHeaders.get('set-cookie');
if (setCookie) {
proxyHeaders.set('set-cookie', setCookie.replace(/domain=[^;]+;?/gi, ''));
}
// 过滤 HTML 内容
const filteredResponse = await filterHtmlContent(
new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: proxyHeaders,
}),
url.origin
);
return filteredResponse;
} catch (error) {
return new Response(
JSON.stringify({
error: 'Proxy request failed',
message: error.message,
}),
{
status: 502,
headers: { 'Content-Type': 'application/json' },
}
);
}
}

博客 Bangumi 页面适配#

搭建好反向代理后,还需要修改博客中 Bangumi 页面的图片加载逻辑,让封面图走代理域名而不是直连 lain.bgm.tv

修改 Card.astro#

找到 src/components/bangumi/Card.astro,添加一个 getImageUrl 函数,将 Bangumi 的图片地址替换为代理地址:

---
import type { UserSubjectCollection } from "@/types/bangumi";
interface Props {
item: UserSubjectCollection;
}
const { item } = Astro.props;
const subject_base_url = "https://bgm.tv/subject/";
function getImageUrl(originalUrl: string | undefined): string | undefined {
if (!originalUrl) return undefined;
return originalUrl.replace(
"https://lain.bgm.tv/",
"https://lain.flygeon.top/",
);
}
---

然后在图片标签中使用这个函数:

<img
data-src={getImageUrl(item.subject.images.medium)}
alt={item.subject.name_cn || item.subject.name}
class="bangumi-lazy-img w-full h-full object-cover pointer-events-none opacity-0 transition-opacity duration-300"
/>

为什么只改 Card 组件#

Bangumi 页面中,图片地址只出现在 Card.astro 这一个组件里。BangumiSection.astro 负责数据分发和筛选,Pagination.astro 负责分页交互,FilterControls.astro 负责状态过滤,它们都不涉及图片 URL 的渲染。因此只需要修改 Card 组件即可覆盖所有图片请求。

enjoy it!#

对 Bangumi 反向代理,解决大陆地区访问问题
https://flygeon.top/posts/4/
作者
Flygeon
发布于
2026-05-30
许可协议
CC BY-NC-SA 4.0