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 ...'}
+                        
+                      
+
)}