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/