增加动漫源cloudflare workers部署脚本示例

This commit is contained in:
mtvpls
2026-06-05 21:05:11 +08:00
parent 63fb51ce99
commit d342442d5e
3 changed files with 352 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
/* eslint-disable */
/**
* MoonTVPlus Bangumi 代理 - Cloudflare Workers 版
*
* 项目配置方式:
* 1. 后台 -> 动漫数据源配置:
* - 默认动漫数据源:自定义 Base URL
* - Bangumi Base URLhttps://你的-worker.workers.dev
*
* 2. 如需代理 Bangumi 图片:
* - Bangumi 图片 Base URLhttps://你的-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 });
}
+62
View File
@@ -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<SiteConfig>({
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 = ({
使
</p>
</div>
<details className='group rounded-lg border border-green-200 bg-green-50/60 p-4 dark:border-green-900/50 dark:bg-green-900/10'>
<summary className='flex cursor-pointer list-none items-start justify-between gap-3'>
<div className='min-w-0'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Bangumi Cloudflare Workers
</label>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Workers
Bangumi Base URL Bangumi Base URL
</p>
</div>
<div className='flex shrink-0 items-center gap-2'>
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyBangumiProxyScript();
}}
disabled={!bangumiProxyScript}
className='inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50'
>
<Copy className='h-3.5 w-3.5' />
{bangumiProxyScriptCopied ? '已复制' : '复制脚本'}
</button>
<ChevronDown className='h-4 w-4 text-green-600 transition-transform group-open:rotate-180 dark:text-green-400' />
</div>
</summary>
<pre className='mt-3 max-h-48 overflow-auto rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300'>
<code>
{bangumiProxyScript ||
'正在加载 /scripts/bangumi-proxy.worker.js ...'}
</code>
</pre>
</details>
</div>
</details>
+56
View File
@@ -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 = () => {
</p>
</div>
<details className='group rounded-lg border border-green-200 bg-green-50/60 p-3 dark:border-green-900/50 dark:bg-green-900/10'>
<summary className='flex cursor-pointer list-none items-center justify-between gap-2'>
<div className='min-w-0'>
<label className='text-xs font-medium text-gray-700 dark:text-gray-300'>
Bangumi Cloudflare Workers
</label>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Workers
Base URL
</p>
</div>
<div className='flex shrink-0 items-center gap-2'>
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyBangumiProxyScript();
}}
disabled={!bangumiProxyScript}
className='inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50'
>
<Copy className='h-3.5 w-3.5' />
{bangumiProxyScriptCopied ? '已复制' : '复制脚本'}
</button>
<ChevronDown className='h-4 w-4 text-green-600 transition-transform group-open:rotate-180 dark:text-green-400' />
</div>
</summary>
<pre className='mt-3 max-h-40 overflow-auto rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300'>
<code>
{bangumiProxyScript || '正在加载 /scripts/bangumi-proxy.worker.js ...'}
</code>
</pre>
</details>
</div>
</div>
)}