From d342442d5ed39b84f20fe2af064828d831533a47 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Fri, 5 Jun 2026 21:05:11 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8A=A8=E6=BC=AB=E6=BA=90cl?=
=?UTF-8?q?oudflare=20workers=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=E7=A4=BA?=
=?UTF-8?q?=E4=BE=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
public/scripts/bangumi-proxy.worker.js | 234 +++++++++++++++++++++++++
src/app/admin/page.tsx | 62 +++++++
src/components/UserMenu.tsx | 56 ++++++
3 files changed, 352 insertions(+)
create mode 100644 public/scripts/bangumi-proxy.worker.js
diff --git a/public/scripts/bangumi-proxy.worker.js b/public/scripts/bangumi-proxy.worker.js
new file mode 100644
index 0000000..545c66b
--- /dev/null
+++ b/public/scripts/bangumi-proxy.worker.js
@@ -0,0 +1,234 @@
+/* eslint-disable */
+/**
+ * MoonTVPlus Bangumi 代理 - Cloudflare Workers 版
+ *
+ * 项目配置方式:
+ * 1. 后台 -> 动漫数据源配置:
+ * - 默认动漫数据源:自定义 Base URL
+ * - Bangumi Base URL:https://你的-worker.workers.dev
+ *
+ * 2. 如需代理 Bangumi 图片:
+ * - Bangumi 图片 Base URL:https://你的-worker.workers.dev
+ *
+ * 兼容路径:
+ * - /calendar -> https://api.bgm.tv/calendar
+ * - /v0/subjects/123 -> https://api.bgm.tv/v0/subjects/123
+ * - /https://lain.bgm.tv/xxx.jpg -> https://lain.bgm.tv/xxx.jpg
+ * - /?url=https://api.bgm.tv/calendar
+ */
+
+const API_ORIGIN = 'https://api.bgm.tv';
+
+const ALLOWED_HOSTS = new Set([
+ 'api.bgm.tv',
+ 'bgm.tv',
+ 'bangumi.tv',
+ 'chii.in',
+ 'lain.bgm.tv',
+ 'r.bgm.tv',
+]);
+
+const HOP_BY_HOP_HEADERS = new Set([
+ 'connection',
+ 'keep-alive',
+ 'proxy-authenticate',
+ 'proxy-authorization',
+ 'te',
+ 'trailer',
+ 'transfer-encoding',
+ 'upgrade',
+]);
+
+export default {
+ async fetch(request) {
+ return handleRequest(request);
+ },
+};
+
+if (typeof addEventListener === 'function') {
+ addEventListener('fetch', (event) => {
+ event.respondWith(handleRequest(event.request));
+ });
+}
+
+async function handleRequest(request) {
+ const requestUrl = new URL(request.url);
+
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: corsHeaders() });
+ }
+
+ if (requestUrl.pathname === '/' && !requestUrl.searchParams.has('url')) {
+ return jsonResponse({
+ ok: true,
+ name: 'MoonTVPlus Bangumi Proxy',
+ apiBaseUrl: requestUrl.origin,
+ imageBaseUrl: requestUrl.origin,
+ examples: {
+ calendar: `${requestUrl.origin}/calendar`,
+ subject: `${requestUrl.origin}/v0/subjects/1`,
+ image: `${requestUrl.origin}/https://lain.bgm.tv/pic/cover/l/demo.jpg`,
+ queryProxy: `${requestUrl.origin}/?url=${encodeURIComponent('https://api.bgm.tv/calendar')}`,
+ },
+ });
+ }
+
+ try {
+ const targetUrl = buildTargetUrl(requestUrl);
+ assertAllowedTarget(targetUrl);
+
+ const upstreamResponse = await fetch(targetUrl.toString(), {
+ method: request.method,
+ headers: buildUpstreamHeaders(request.headers, targetUrl),
+ body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
+ redirect: 'manual',
+ cf: {
+ cacheEverything: request.method === 'GET',
+ cacheTtl: getCacheTtl(targetUrl),
+ },
+ });
+
+ return buildProxyResponse(upstreamResponse, requestUrl.origin);
+ } catch (error) {
+ return jsonResponse(
+ { error: error?.message || String(error) },
+ 502
+ );
+ }
+}
+
+function buildTargetUrl(requestUrl) {
+ const urlParam = requestUrl.searchParams.get('url');
+ if (urlParam) {
+ return new URL(urlParam);
+ }
+
+ const rawPath = decodeURIComponent(requestUrl.pathname.replace(/^\/+/, ''));
+
+ // 兼容本项目 Bangumi 图片 Base URL 的拼接方式:
+ // `${baseUrl}/${imageUrl}` 会形成 /https://lain.bgm.tv/xxx
+ if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
+ const target = new URL(rawPath);
+ target.search = requestUrl.search;
+ return target;
+ }
+
+ // 某些 URL 解析/拼接场景可能变成 https:/lain.bgm.tv/xxx,这里修正为 https://...
+ if (rawPath.startsWith('http:/') || rawPath.startsWith('https:/')) {
+ const fixed = rawPath.replace(/^http:\//, 'http://').replace(/^https:\//, 'https://');
+ const target = new URL(fixed);
+ target.search = requestUrl.search;
+ return target;
+ }
+
+ const target = new URL(API_ORIGIN);
+ target.pathname = requestUrl.pathname;
+ target.search = requestUrl.search;
+ return target;
+}
+
+function assertAllowedTarget(targetUrl) {
+ if (!['http:', 'https:'].includes(targetUrl.protocol)) {
+ throw new Error('Only http/https target is allowed');
+ }
+
+ const host = targetUrl.hostname.toLowerCase();
+ const allowed = ALLOWED_HOSTS.has(host) || host.endsWith('.bgm.tv') || host.endsWith('.bangumi.tv');
+ if (!allowed) {
+ throw new Error(`Target host is not allowed: ${host}`);
+ }
+}
+
+function buildUpstreamHeaders(inputHeaders, targetUrl) {
+ const headers = new Headers();
+
+ for (const [name, value] of inputHeaders.entries()) {
+ const lower = name.toLowerCase();
+ if (HOP_BY_HOP_HEADERS.has(lower) || lower.startsWith('cf-')) continue;
+ if (lower === 'host' || lower === 'origin' || lower === 'referer') continue;
+ headers.set(name, value);
+ }
+
+ const isImage = isLikelyImageUrl(targetUrl);
+ headers.set('Accept', inputHeaders.get('Accept') || (isImage ? 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' : 'application/json, text/plain, */*'));
+ headers.set('Referer', 'https://bgm.tv/');
+ headers.set('Origin', 'https://bgm.tv');
+ headers.set('User-Agent', inputHeaders.get('User-Agent') || 'MoonTVPlus/1.0 CloudflareWorker (+https://github.com)');
+
+ return headers;
+}
+
+function buildProxyResponse(upstreamResponse, workerOrigin) {
+ const headers = new Headers(upstreamResponse.headers);
+
+ for (const name of Array.from(headers.keys())) {
+ const lower = name.toLowerCase();
+ if (HOP_BY_HOP_HEADERS.has(lower) || lower.startsWith('cf-')) {
+ headers.delete(name);
+ }
+ }
+
+ const location = headers.get('Location');
+ if (location) {
+ try {
+ const locationUrl = new URL(location);
+ if (isAllowedHost(locationUrl.hostname)) {
+ headers.set('Location', `${workerOrigin}/${locationUrl.toString()}`);
+ }
+ } catch {
+ // relative Location 保持原样
+ }
+ }
+
+ setCors(headers);
+ headers.set('X-Proxy-By', 'MoonTVPlus Bangumi Proxy');
+
+ if (!headers.has('Cache-Control')) {
+ headers.set('Cache-Control', 'public, max-age=300');
+ }
+
+ return new Response(upstreamResponse.body, {
+ status: upstreamResponse.status,
+ statusText: upstreamResponse.statusText,
+ headers,
+ });
+}
+
+function getCacheTtl(targetUrl) {
+ if (isLikelyImageUrl(targetUrl)) return 60 * 60 * 24 * 30;
+ if (targetUrl.pathname === '/calendar') return 60 * 30;
+ if (targetUrl.pathname.startsWith('/v0/subjects/')) return 60 * 60 * 6;
+ return 60 * 5;
+}
+
+function isLikelyImageUrl(url) {
+ return /\.(avif|webp|png|jpe?g|gif|svg)(\?.*)?$/i.test(url.pathname);
+}
+
+function isAllowedHost(hostname) {
+ const host = hostname.toLowerCase();
+ return ALLOWED_HOSTS.has(host) || host.endsWith('.bgm.tv') || host.endsWith('.bangumi.tv');
+}
+
+function corsHeaders() {
+ return {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': '*',
+ 'Access-Control-Max-Age': '86400',
+ };
+}
+
+function setCors(headers) {
+ const cors = corsHeaders();
+ for (const key of Object.keys(cors)) {
+ headers.set(key, cors[key]);
+ }
+}
+
+function jsonResponse(data, status = 200) {
+ const headers = new Headers(corsHeaders());
+ headers.set('Content-Type', 'application/json; charset=utf-8');
+ headers.set('Cache-Control', 'no-store');
+ return new Response(JSON.stringify(data, null, 2), { status, headers });
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 72c54f9..a6aea87 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -33,6 +33,7 @@ import {
ChevronDown,
ChevronUp,
Cloud,
+ Copy,
Database,
ExternalLink,
FileText,
@@ -9617,6 +9618,9 @@ const SiteConfigComponent = ({
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showEnableCommentsModal, setShowEnableCommentsModal] = useState(false);
+ const [bangumiProxyScript, setBangumiProxyScript] = useState('');
+ const [bangumiProxyScriptCopied, setBangumiProxyScriptCopied] =
+ useState(false);
const [siteSettings, setSiteSettings] = useState({
SiteName: '',
Announcement: '',
@@ -9723,6 +9727,15 @@ const SiteConfigComponent = ({
}
};
+ useEffect(() => {
+ fetch('/scripts/bangumi-proxy.worker.js')
+ .then((response) => (response.ok ? response.text() : ''))
+ .then(setBangumiProxyScript)
+ .catch((error) => {
+ console.error('加载 Bangumi Workers 脚本失败:', error);
+ });
+ }, []);
+
useEffect(() => {
if (config?.SiteConfig) {
setSiteSettings({
@@ -9841,6 +9854,19 @@ const SiteConfigComponent = ({
setShowEnableCommentsModal(false);
};
+ const handleCopyBangumiProxyScript = async () => {
+ if (!bangumiProxyScript) return;
+ try {
+ await navigator.clipboard.writeText(bangumiProxyScript);
+ setBangumiProxyScriptCopied(true);
+ showSuccess('已复制 Bangumi Workers 脚本', showAlert);
+ setTimeout(() => setBangumiProxyScriptCopied(false), 2000);
+ } catch (error) {
+ console.error('复制 Bangumi Workers 脚本失败:', error);
+ showError('复制失败', showAlert);
+ }
+ };
+
// 保存站点配置
const handleSave = async () => {
await withLoading('saveSiteConfig', async () => {
@@ -10623,6 +10649,42 @@ const SiteConfigComponent = ({
部署环境下不会使用该代理。
+
+
+
+
+
+
+ 复制后粘贴到 Cloudflare Workers,部署后的域名可填入
+ Bangumi Base URL 和 Bangumi 图片 Base URL。
+
+
+
+
+
+
+
+
+
+ {bangumiProxyScript ||
+ '正在加载 /scripts/bangumi-proxy.worker.js ...'}
+
+
+
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index c14128b..bee6746 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -175,6 +175,9 @@ export const UserMenu: React.FC = () => {
useState('server-proxy');
const [animeCustomBaseUrl, setAnimeCustomBaseUrl] = useState('');
const [animeImageBaseUrl, setAnimeImageBaseUrl] = useState('');
+ const [bangumiProxyScript, setBangumiProxyScript] = useState('');
+ const [bangumiProxyScriptCopied, setBangumiProxyScriptCopied] =
+ useState(false);
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
'cmliussss-cdn-tencent'
);
@@ -639,6 +642,13 @@ export const UserMenu: React.FC = () => {
const savedAnimeImageBaseUrl = localStorage.getItem('animeImageBaseUrl');
setAnimeImageBaseUrl(savedAnimeImageBaseUrl || '');
+ fetch('/scripts/bangumi-proxy.worker.js')
+ .then((response) => (response.ok ? response.text() : ''))
+ .then(setBangumiProxyScript)
+ .catch((error) => {
+ console.error('加载 Bangumi Workers 脚本失败:', error);
+ });
+
const savedDoubanImageProxyType = localStorage.getItem(
'doubanImageProxyType'
);
@@ -1788,6 +1798,17 @@ export const UserMenu: React.FC = () => {
}
};
+ const handleCopyBangumiProxyScript = async () => {
+ if (!bangumiProxyScript) return;
+ try {
+ await navigator.clipboard.writeText(bangumiProxyScript);
+ setBangumiProxyScriptCopied(true);
+ setTimeout(() => setBangumiProxyScriptCopied(false), 2000);
+ } catch (error) {
+ console.error('复制 Bangumi Workers 脚本失败:', error);
+ }
+ };
+
const handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
@@ -3030,6 +3051,41 @@ export const UserMenu: React.FC = () => {
图片域名。只需填写基础部分,不需要填写完整图片路径。
+
+
+
+
+
+
+ 复制后粘贴到 Cloudflare Workers,部署地址可填入上方
+ Base URL。
+
+
+
+
+
+
+
+
+
+ {bangumiProxyScript || '正在加载 /scripts/bangumi-proxy.worker.js ...'}
+
+
+
)}