增加动漫源cloudflare workers部署脚本示例
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -9617,6 +9618,9 @@ const SiteConfigComponent = ({
|
|||||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
const [showEnableCommentsModal, setShowEnableCommentsModal] = useState(false);
|
const [showEnableCommentsModal, setShowEnableCommentsModal] = useState(false);
|
||||||
|
const [bangumiProxyScript, setBangumiProxyScript] = useState('');
|
||||||
|
const [bangumiProxyScriptCopied, setBangumiProxyScriptCopied] =
|
||||||
|
useState(false);
|
||||||
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
|
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
|
||||||
SiteName: '',
|
SiteName: '',
|
||||||
Announcement: '',
|
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(() => {
|
useEffect(() => {
|
||||||
if (config?.SiteConfig) {
|
if (config?.SiteConfig) {
|
||||||
setSiteSettings({
|
setSiteSettings({
|
||||||
@@ -9841,6 +9854,19 @@ const SiteConfigComponent = ({
|
|||||||
setShowEnableCommentsModal(false);
|
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 () => {
|
const handleSave = async () => {
|
||||||
await withLoading('saveSiteConfig', async () => {
|
await withLoading('saveSiteConfig', async () => {
|
||||||
@@ -10623,6 +10649,42 @@ const SiteConfigComponent = ({
|
|||||||
部署环境下不会使用该代理。
|
部署环境下不会使用该代理。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ export const UserMenu: React.FC = () => {
|
|||||||
useState('server-proxy');
|
useState('server-proxy');
|
||||||
const [animeCustomBaseUrl, setAnimeCustomBaseUrl] = useState('');
|
const [animeCustomBaseUrl, setAnimeCustomBaseUrl] = useState('');
|
||||||
const [animeImageBaseUrl, setAnimeImageBaseUrl] = useState('');
|
const [animeImageBaseUrl, setAnimeImageBaseUrl] = useState('');
|
||||||
|
const [bangumiProxyScript, setBangumiProxyScript] = useState('');
|
||||||
|
const [bangumiProxyScriptCopied, setBangumiProxyScriptCopied] =
|
||||||
|
useState(false);
|
||||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
|
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
|
||||||
'cmliussss-cdn-tencent'
|
'cmliussss-cdn-tencent'
|
||||||
);
|
);
|
||||||
@@ -639,6 +642,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
const savedAnimeImageBaseUrl = localStorage.getItem('animeImageBaseUrl');
|
const savedAnimeImageBaseUrl = localStorage.getItem('animeImageBaseUrl');
|
||||||
setAnimeImageBaseUrl(savedAnimeImageBaseUrl || '');
|
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(
|
const savedDoubanImageProxyType = localStorage.getItem(
|
||||||
'doubanImageProxyType'
|
'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) => {
|
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||||
setDoubanImageProxyType(value);
|
setDoubanImageProxyType(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -3030,6 +3051,41 @@ export const UserMenu: React.FC = () => {
|
|||||||
图片域名。只需填写基础部分,不需要填写完整图片路径。
|
图片域名。只需填写基础部分,不需要填写完整图片路径。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user