视频源脚本
This commit is contained in:
+615
-1
@@ -45,7 +45,7 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { GripVertical } from 'lucide-react';
|
import { GripVertical } from 'lucide-react';
|
||||||
import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||||
@@ -317,6 +317,23 @@ const useLoadingState = () => {
|
|||||||
return { loadingStates, setLoading, isLoading, withLoading };
|
return { loadingStates, setLoading, isLoading, withLoading };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface StandaloneSourceScript {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
version: string;
|
||||||
|
code: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
history: Array<{
|
||||||
|
version: string;
|
||||||
|
code: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
// 新增站点配置类型
|
// 新增站点配置类型
|
||||||
interface SiteConfig {
|
interface SiteConfig {
|
||||||
SiteName: string;
|
SiteName: string;
|
||||||
@@ -5945,6 +5962,591 @@ const CategoryConfig = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 通用弹窗组件 */}
|
{/* 通用弹窗组件 */}
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.isOpen}
|
||||||
|
onClose={hideAlert}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
timer={alertModal.timer}
|
||||||
|
showConfirm={alertModal.showConfirm}
|
||||||
|
onConfirm={alertModal.onConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoSourceScriptLab = () => {
|
||||||
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
|
const [scripts, setScripts] = useState<StandaloneSourceScript[]>([]);
|
||||||
|
const [loadingScripts, setLoadingScripts] = useState(true);
|
||||||
|
const [template, setTemplate] = useState('');
|
||||||
|
const [selectedScriptId, setSelectedScriptId] = useState<string | null>(null);
|
||||||
|
const [editor, setEditor] = useState<{
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
code: string;
|
||||||
|
enabled: boolean;
|
||||||
|
history: StandaloneSourceScript['history'];
|
||||||
|
version?: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}>({
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
code: '',
|
||||||
|
enabled: true,
|
||||||
|
history: [],
|
||||||
|
});
|
||||||
|
const [testHook, setTestHook] = useState<'search' | 'detail' | 'resolvePlayUrl'>('search');
|
||||||
|
const [testPayload, setTestPayload] = useState(
|
||||||
|
JSON.stringify({ keyword: '凡人修仙传', page: 1 }, null, 2)
|
||||||
|
);
|
||||||
|
const [testOutput, setTestOutput] = useState('');
|
||||||
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const applyEditorFromScript = (script: StandaloneSourceScript | null) => {
|
||||||
|
if (!script) {
|
||||||
|
setEditor({
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
code: template,
|
||||||
|
enabled: true,
|
||||||
|
history: [],
|
||||||
|
});
|
||||||
|
setSelectedScriptId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditor({
|
||||||
|
id: script.id,
|
||||||
|
key: script.key,
|
||||||
|
name: script.name,
|
||||||
|
description: script.description || '',
|
||||||
|
code: script.code,
|
||||||
|
enabled: script.enabled,
|
||||||
|
history: script.history || [],
|
||||||
|
version: script.version,
|
||||||
|
updatedAt: script.updatedAt,
|
||||||
|
});
|
||||||
|
setSelectedScriptId(script.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadScripts = async (preferId?: string | null) => {
|
||||||
|
setLoadingScripts(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '加载脚本失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextScripts = (data.items || []) as StandaloneSourceScript[];
|
||||||
|
setScripts(nextScripts);
|
||||||
|
setTemplate(data.template || '');
|
||||||
|
|
||||||
|
const targetId =
|
||||||
|
preferId ||
|
||||||
|
selectedScriptId ||
|
||||||
|
nextScripts[0]?.id ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const selected = nextScripts.find((item) => item.id === targetId) || null;
|
||||||
|
if (selected) {
|
||||||
|
applyEditorFromScript(selected);
|
||||||
|
} else {
|
||||||
|
setEditor({
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
code: data.template || '',
|
||||||
|
enabled: true,
|
||||||
|
history: [],
|
||||||
|
});
|
||||||
|
setSelectedScriptId(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '加载脚本失败', showAlert);
|
||||||
|
} finally {
|
||||||
|
setLoadingScripts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadScripts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setSelectedScriptId(null);
|
||||||
|
setEditor({
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
code: template,
|
||||||
|
enabled: true,
|
||||||
|
history: [],
|
||||||
|
});
|
||||||
|
setTestOutput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCurrent = () => {
|
||||||
|
if (!editor.key || !editor.name || !editor.code) {
|
||||||
|
showError('当前没有可导出的脚本', showAlert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
key: editor.key,
|
||||||
|
name: editor.name,
|
||||||
|
description: editor.description,
|
||||||
|
code: editor.code,
|
||||||
|
enabled: editor.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${editor.key}.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFile = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await file.text();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const items = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
|
||||||
|
await withLoading('importSourceScript', async () => {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'import',
|
||||||
|
items,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '导入失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(`已导入 ${data.items?.length || 0} 个脚本`, showAlert);
|
||||||
|
await loadScripts(data.items?.[0]?.id || null);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '导入失败', showAlert);
|
||||||
|
} finally {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editor.key || !editor.name || !editor.code) {
|
||||||
|
showError('请填写脚本 Key、名称和代码', showAlert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withLoading('saveSourceScript', async () => {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'save',
|
||||||
|
id: editor.id,
|
||||||
|
key: editor.key,
|
||||||
|
name: editor.name,
|
||||||
|
description: editor.description,
|
||||||
|
code: editor.code,
|
||||||
|
enabled: editor.enabled,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '保存失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess('脚本已保存', showAlert);
|
||||||
|
await loadScripts(data.item?.id || editor.id || null);
|
||||||
|
}).catch((error) => {
|
||||||
|
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!editor.id) {
|
||||||
|
handleCreateNew();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert({
|
||||||
|
type: 'warning',
|
||||||
|
title: '删除脚本',
|
||||||
|
message: `确定要删除脚本 "${editor.name}" 吗?`,
|
||||||
|
showConfirm: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
hideAlert();
|
||||||
|
await withLoading('deleteSourceScript', async () => {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'delete',
|
||||||
|
id: editor.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '删除失败');
|
||||||
|
}
|
||||||
|
showSuccess('脚本已删除', showAlert);
|
||||||
|
await loadScripts(null);
|
||||||
|
}).catch((error) => {
|
||||||
|
showError(error instanceof Error ? error.message : '删除失败', showAlert);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (id: string) => {
|
||||||
|
await withLoading(`toggleSourceScript_${id}`, async () => {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'toggle_enabled',
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '更新失败');
|
||||||
|
}
|
||||||
|
await loadScripts(id);
|
||||||
|
}).catch((error) => {
|
||||||
|
showError(error instanceof Error ? error.message : '更新失败', showAlert);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (version: string) => {
|
||||||
|
if (!editor.id) return;
|
||||||
|
|
||||||
|
await withLoading(`restoreSourceScript_${editor.id}`, async () => {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'restore',
|
||||||
|
id: editor.id,
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '回滚失败');
|
||||||
|
}
|
||||||
|
showSuccess('已回滚到历史版本', showAlert);
|
||||||
|
await loadScripts(editor.id);
|
||||||
|
}).catch((error) => {
|
||||||
|
showError(error instanceof Error ? error.message : '回滚失败', showAlert);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = testPayload.trim() ? JSON.parse(testPayload) : {};
|
||||||
|
} catch {
|
||||||
|
showError('测试输入必须是合法 JSON', showAlert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withLoading('testSourceScript', async () => {
|
||||||
|
const response = await fetch('/api/admin/source-script', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'test',
|
||||||
|
key: editor.key || 'test-script',
|
||||||
|
name: editor.name || '测试脚本',
|
||||||
|
code: editor.code,
|
||||||
|
hook: testHook,
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
setTestOutput(JSON.stringify(data, null, 2));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || data.message || '测试失败');
|
||||||
|
}
|
||||||
|
showSuccess('测试执行完成', showAlert);
|
||||||
|
}).catch((error) => {
|
||||||
|
showError(error instanceof Error ? error.message : '测试失败', showAlert);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTestPayload(
|
||||||
|
testHook === 'search'
|
||||||
|
? JSON.stringify({ keyword: '凡人修仙传', page: 1 }, null, 2)
|
||||||
|
: testHook === 'detail'
|
||||||
|
? JSON.stringify({ id: 'demo-id' }, null, 2)
|
||||||
|
: JSON.stringify(
|
||||||
|
{
|
||||||
|
sourceId: 'demo-id',
|
||||||
|
playUrl: 'https://example.com/video.m3u8',
|
||||||
|
episodeIndex: 0,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [testHook]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex flex-col lg:flex-row gap-6'>
|
||||||
|
<div className='lg:w-80 space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
脚本列表
|
||||||
|
</h4>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type='file'
|
||||||
|
accept='application/json,.json'
|
||||||
|
onChange={handleImportFile}
|
||||||
|
className='hidden'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
disabled={isLoading('importSourceScript')}
|
||||||
|
className={isLoading('importSourceScript') ? buttonStyles.disabledSmall : buttonStyles.primarySmall}
|
||||||
|
>
|
||||||
|
导入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => loadScripts(selectedScriptId)}
|
||||||
|
disabled={loadingScripts}
|
||||||
|
className={loadingScripts ? buttonStyles.disabledSmall : buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCreateNew} className={buttonStyles.successSmall}>
|
||||||
|
新建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-3 max-h-[38rem] overflow-y-auto pr-1'>
|
||||||
|
{loadingScripts ? (
|
||||||
|
<div className='text-sm text-gray-500 dark:text-gray-400'>加载中...</div>
|
||||||
|
) : scripts.length === 0 ? (
|
||||||
|
<div className='p-4 rounded-lg border border-dashed border-gray-300 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
还没有脚本,点右上角新建一个。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
scripts.map((script) => (
|
||||||
|
<button
|
||||||
|
key={script.id}
|
||||||
|
onClick={() => {
|
||||||
|
applyEditorFromScript(script);
|
||||||
|
setTestOutput('');
|
||||||
|
}}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border transition-colors ${
|
||||||
|
selectedScriptId === script.id
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||||
|
{script.name}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
|
||||||
|
{script.key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
script.enabled
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{script.enabled ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
<span>{new Date(script.updatedAt).toLocaleString('zh-CN')}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleEnabled(script.id);
|
||||||
|
}}
|
||||||
|
disabled={isLoading(`toggleSourceScript_${script.id}`)}
|
||||||
|
className={script.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall}
|
||||||
|
>
|
||||||
|
{script.enabled ? '停用' : '启用'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex-1 space-y-6'>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='脚本名称'
|
||||||
|
value={editor.name}
|
||||||
|
onChange={(e) => setEditor((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='脚本 Key'
|
||||||
|
value={editor.key}
|
||||||
|
onChange={(e) => setEditor((prev) => ({ ...prev, key: e.target.value }))}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
placeholder='脚本描述(可选)'
|
||||||
|
value={editor.description}
|
||||||
|
onChange={(e) => setEditor((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center justify-between mb-2'>
|
||||||
|
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
脚本代码
|
||||||
|
</label>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{editor.version ? `当前版本: ${editor.version}` : '未保存'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editor.code}
|
||||||
|
onChange={(e) => setEditor((prev) => ({ ...prev, code: e.target.value }))}
|
||||||
|
rows={24}
|
||||||
|
spellCheck={false}
|
||||||
|
className='w-full px-3 py-3 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-950 text-gray-100'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-3'>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading('saveSourceScript')}
|
||||||
|
className={isLoading('saveSourceScript') ? buttonStyles.disabled : buttonStyles.success}
|
||||||
|
>
|
||||||
|
{isLoading('saveSourceScript') ? '保存中...' : '保存脚本'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={isLoading('testSourceScript')}
|
||||||
|
className={isLoading('testSourceScript') ? buttonStyles.disabled : buttonStyles.primary}
|
||||||
|
>
|
||||||
|
{isLoading('testSourceScript') ? '测试中...' : '运行测试'}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleExportCurrent} className={buttonStyles.secondary}>
|
||||||
|
导出当前脚本
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDelete} className={buttonStyles.danger}>
|
||||||
|
{editor.id ? '删除脚本' : '清空编辑器'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 xl:grid-cols-2 gap-6'>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
测试 Hook
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={testHook}
|
||||||
|
onChange={(e) => setTestHook(e.target.value as 'search' | 'detail' | 'resolvePlayUrl')}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
>
|
||||||
|
<option value='search'>search</option>
|
||||||
|
<option value='detail'>detail</option>
|
||||||
|
<option value='resolvePlayUrl'>resolvePlayUrl</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={testPayload}
|
||||||
|
onChange={(e) => setTestPayload(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
spellCheck={false}
|
||||||
|
className='w-full px-3 py-3 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
测试输出
|
||||||
|
</div>
|
||||||
|
<pre className='w-full min-h-[16rem] whitespace-pre-wrap break-all px-3 py-3 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-950 text-gray-100 overflow-auto'>
|
||||||
|
{testOutput || '运行测试后会显示结果、日志和错误信息'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editor.history.length > 0 && (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
历史版本
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{editor.history.map((history) => (
|
||||||
|
<div
|
||||||
|
key={history.version}
|
||||||
|
className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900'
|
||||||
|
>
|
||||||
|
<div className='text-sm text-gray-700 dark:text-gray-300'>
|
||||||
|
<div className='font-medium'>{history.version}</div>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{new Date(history.updatedAt).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestore(history.version)}
|
||||||
|
disabled={isLoading(`restoreSourceScript_${editor.id}`)}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
回滚到此版本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={alertModal.isOpen}
|
isOpen={alertModal.isOpen}
|
||||||
onClose={hideAlert}
|
onClose={hideAlert}
|
||||||
@@ -11931,6 +12533,7 @@ function AdminPageClient() {
|
|||||||
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
||||||
userConfig: false,
|
userConfig: false,
|
||||||
videoSource: false,
|
videoSource: false,
|
||||||
|
sourceScriptLab: false,
|
||||||
mediaLibrary: false,
|
mediaLibrary: false,
|
||||||
openListConfig: false,
|
openListConfig: false,
|
||||||
embyConfig: false,
|
embyConfig: false,
|
||||||
@@ -12306,6 +12909,17 @@ function AdminPageClient() {
|
|||||||
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
|
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
<CollapsibleTab
|
||||||
|
title='视频源脚本'
|
||||||
|
icon={
|
||||||
|
<Bot size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.sourceScriptLab}
|
||||||
|
onToggle={() => toggleTab('sourceScriptLab')}
|
||||||
|
>
|
||||||
|
<VideoSourceScriptLab />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* 电视直播源配置标签 */}
|
{/* 电视直播源配置标签 */}
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
title='电视直播源配置'
|
title='电视直播源配置'
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
deleteSourceScript,
|
||||||
|
getDefaultSourceScriptTemplate,
|
||||||
|
importSourceScripts,
|
||||||
|
listSourceScripts,
|
||||||
|
restoreSourceScriptHistory,
|
||||||
|
saveSourceScript,
|
||||||
|
testSourceScript,
|
||||||
|
toggleSourceScriptEnabled,
|
||||||
|
} from '@/lib/source-script';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function assertAdmin(request: NextRequest) {
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
if (storageType === 'localstorage') {
|
||||||
|
throw new Error('不支持本地存储进行管理员配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo?.username) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authInfo.username === process.env.USERNAME) {
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const username = await assertAdmin(request);
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await listSourceScripts();
|
||||||
|
return NextResponse.json({
|
||||||
|
items,
|
||||||
|
template: getDefaultSourceScriptTemplate(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || '获取脚本列表失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const username = await assertAdmin(request);
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as Record<string, any>;
|
||||||
|
const action = body.action as string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'save': {
|
||||||
|
const saved = await saveSourceScript({
|
||||||
|
id: body.id,
|
||||||
|
key: body.key,
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
code: body.code,
|
||||||
|
enabled: body.enabled,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, item: saved });
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
await deleteSourceScript(body.id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
case 'toggle_enabled': {
|
||||||
|
const item = await toggleSourceScriptEnabled(body.id);
|
||||||
|
return NextResponse.json({ ok: true, item });
|
||||||
|
}
|
||||||
|
case 'restore': {
|
||||||
|
const item = await restoreSourceScriptHistory(body.id, body.version);
|
||||||
|
return NextResponse.json({ ok: true, item });
|
||||||
|
}
|
||||||
|
case 'test': {
|
||||||
|
const result = await testSourceScript({
|
||||||
|
code: body.code,
|
||||||
|
hook: body.hook,
|
||||||
|
payload: body.payload || {},
|
||||||
|
name: body.name,
|
||||||
|
key: body.key,
|
||||||
|
configValues: body.configValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(result, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
case 'import': {
|
||||||
|
const imported = await importSourceScripts(
|
||||||
|
Array.isArray(body.items) ? body.items : []
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true, items: imported });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: (error as Error).message || '脚本操作失败',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
const SOURCE_SCRIPT_REGISTRY_KEY = 'source-script:registry';
|
||||||
|
const MAX_HISTORY_ITEMS = 10;
|
||||||
|
const DEFAULT_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
export interface SourceScriptVersion {
|
||||||
|
version: string;
|
||||||
|
code: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceScriptRecord {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
version: string;
|
||||||
|
code: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
history: SourceScriptVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceScriptImportItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
code: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceScriptRegistry {
|
||||||
|
items: SourceScriptRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceScriptTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
logs: string[];
|
||||||
|
meta?: Record<string, any>;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCRIPT_TEMPLATE = `return {
|
||||||
|
meta: {
|
||||||
|
name: '示例脚本',
|
||||||
|
author: 'admin'
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(ctx, { keyword, page }) {
|
||||||
|
ctx.log.info('search', keyword, page);
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
page,
|
||||||
|
pageCount: 1,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(ctx, { id }) {
|
||||||
|
ctx.log.info('detail', id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: '',
|
||||||
|
poster: '',
|
||||||
|
year: '',
|
||||||
|
desc: '',
|
||||||
|
episodes: [],
|
||||||
|
episodes_titles: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolvePlayUrl(ctx, { playUrl, sourceId, episodeIndex }) {
|
||||||
|
ctx.log.info('resolvePlayUrl', sourceId, episodeIndex, playUrl);
|
||||||
|
return {
|
||||||
|
url: playUrl,
|
||||||
|
type: 'auto',
|
||||||
|
headers: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};`;
|
||||||
|
|
||||||
|
function getNowVersion() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyRegistry(): SourceScriptRegistry {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegistry(): Promise<SourceScriptRegistry> {
|
||||||
|
const raw = await db.getGlobalValue(SOURCE_SCRIPT_REGISTRY_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return buildEmptyRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as SourceScriptRegistry;
|
||||||
|
if (!parsed || !Array.isArray(parsed.items)) {
|
||||||
|
return buildEmptyRegistry();
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return buildEmptyRegistry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRegistry(registry: SourceScriptRegistry) {
|
||||||
|
await db.setGlobalValue(
|
||||||
|
SOURCE_SCRIPT_REGISTRY_KEY,
|
||||||
|
JSON.stringify(registry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScriptKey(key: string) {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
|
||||||
|
throw new Error('脚本 Key 仅支持字母、数字、下划线和中划线');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeCode(code: string) {
|
||||||
|
const blockedPatterns = [
|
||||||
|
/\brequire\s*\(/,
|
||||||
|
/\bprocess\./,
|
||||||
|
/\bchild_process\b/,
|
||||||
|
/\bfs\b/,
|
||||||
|
/\bimport\s*\(/,
|
||||||
|
/\beval\s*\(/,
|
||||||
|
/\bnew\s+Function\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of blockedPatterns) {
|
||||||
|
if (pattern.test(code)) {
|
||||||
|
throw new Error(`脚本包含不允许的用法: ${pattern}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogCollector() {
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
const push = (level: string, args: any[]) => {
|
||||||
|
const rendered = args
|
||||||
|
.map((arg) => {
|
||||||
|
if (typeof arg === 'string') return arg;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
logs.push(`[${level}] ${rendered}`);
|
||||||
|
if (logs.length > 50) {
|
||||||
|
logs.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
log: {
|
||||||
|
info: (...args: any[]) => push('info', args),
|
||||||
|
warn: (...args: any[]) => push('warn', args),
|
||||||
|
error: (...args: any[]) => push('error', args),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`执行超时(${timeoutMs}ms)`)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCacheHelpers(scriptId: string) {
|
||||||
|
const prefix = `source-script-cache:${scriptId}:`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async get(key: string) {
|
||||||
|
const raw = await db.getGlobalValue(`${prefix}${key}`);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { value: string; expiresAt: number };
|
||||||
|
if (parsed.expiresAt && parsed.expiresAt < Date.now()) {
|
||||||
|
await db.deleteGlobalValue(`${prefix}${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.value ?? null;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async set(key: string, value: string, ttlSec = 300) {
|
||||||
|
await db.setGlobalValue(
|
||||||
|
`${prefix}${key}`,
|
||||||
|
JSON.stringify({
|
||||||
|
value,
|
||||||
|
expiresAt: Date.now() + ttlSec * 1000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async del(key: string) {
|
||||||
|
await db.deleteGlobalValue(`${prefix}${key}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUtils() {
|
||||||
|
return {
|
||||||
|
buildUrl(base: string, query?: Record<string, string | number | boolean>) {
|
||||||
|
const url = new URL(base);
|
||||||
|
Object.entries(query || {}).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
joinUrl(base: string, path: string) {
|
||||||
|
return new URL(path, base).toString();
|
||||||
|
},
|
||||||
|
randomUA() {
|
||||||
|
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36';
|
||||||
|
},
|
||||||
|
sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
},
|
||||||
|
base64Encode(value: string) {
|
||||||
|
return Buffer.from(value, 'utf8').toString('base64');
|
||||||
|
},
|
||||||
|
base64Decode(value: string) {
|
||||||
|
return Buffer.from(value, 'base64').toString('utf8');
|
||||||
|
},
|
||||||
|
now() {
|
||||||
|
return Date.now();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScriptFactory(code: string) {
|
||||||
|
assertSafeCode(code);
|
||||||
|
|
||||||
|
return new Function(
|
||||||
|
`"use strict";
|
||||||
|
const process = undefined;
|
||||||
|
const require = undefined;
|
||||||
|
const module = undefined;
|
||||||
|
const exports = undefined;
|
||||||
|
const fetch = undefined;
|
||||||
|
${code}`
|
||||||
|
) as () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createScriptContext(script: SourceScriptRecord, configValues?: Record<string, string>) {
|
||||||
|
const { logs, log } = createLogCollector();
|
||||||
|
const cache = createCacheHelpers(script.id);
|
||||||
|
|
||||||
|
const fetcher = async (input: {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
query?: Record<string, string | number | boolean>;
|
||||||
|
body?: string;
|
||||||
|
json?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}) => {
|
||||||
|
const url = new URL(input.url);
|
||||||
|
Object.entries(input.query || {}).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
throw new Error(`不支持的协议: ${url.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
input.timeoutMs || DEFAULT_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: input.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
...(input.json ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(input.headers || {}),
|
||||||
|
},
|
||||||
|
body: input.json !== undefined ? JSON.stringify(input.json) : input.body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
url: response.url,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
text: () => response.text(),
|
||||||
|
json: <T = any>() => response.json() as Promise<T>,
|
||||||
|
arrayBuffer: () => response.arrayBuffer(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx: Object.freeze({
|
||||||
|
fetch: fetcher,
|
||||||
|
request: {
|
||||||
|
get: (url: string, options?: Omit<Parameters<typeof fetcher>[0], 'url' | 'method'>) =>
|
||||||
|
fetcher({ url, method: 'GET', ...(options || {}) }),
|
||||||
|
post: (url: string, options?: Omit<Parameters<typeof fetcher>[0], 'url' | 'method'>) =>
|
||||||
|
fetcher({ url, method: 'POST', ...(options || {}) }),
|
||||||
|
async getHtml(url: string, options?: Omit<Parameters<typeof fetcher>[0], 'url' | 'method'>) {
|
||||||
|
const response = await fetcher({ url, method: 'GET', ...(options || {}) });
|
||||||
|
const text = await response.text();
|
||||||
|
return cheerio.load(text);
|
||||||
|
},
|
||||||
|
async getJson<T = any>(url: string, options?: Omit<Parameters<typeof fetcher>[0], 'url' | 'method'>) {
|
||||||
|
const response = await fetcher({ url, method: 'GET', ...(options || {}) });
|
||||||
|
return response.json<T>();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
html: {
|
||||||
|
load: (html: string) => cheerio.load(html),
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
parse<T = any>(text: string, fallback?: T) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback as T;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stringify(value: unknown) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utils: createUtils(),
|
||||||
|
cache,
|
||||||
|
log,
|
||||||
|
config: {
|
||||||
|
get: (key: string) => configValues?.[key],
|
||||||
|
require: (key: string) => {
|
||||||
|
const value = configValues?.[key];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`缺少脚本配置: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
all: () => ({ ...(configValues || {}) }),
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
scriptId: script.id,
|
||||||
|
sourceKey: script.key,
|
||||||
|
sourceName: script.name,
|
||||||
|
version: script.version,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
logs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScript(script: any) {
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
throw new Error('脚本必须返回对象');
|
||||||
|
}
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSourceScripts() {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
return registry.items.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSourceScript(id: string) {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
return registry.items.find((item) => item.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSourceScript(input: {
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
code: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) {
|
||||||
|
assertScriptKey(input.key);
|
||||||
|
assertSafeCode(input.code);
|
||||||
|
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = input.id
|
||||||
|
? registry.items.find((item) => item.id === input.id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!existing && registry.items.some((item) => item.key === input.key)) {
|
||||||
|
throw new Error('脚本 Key 已存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.history.unshift({
|
||||||
|
version: existing.version,
|
||||||
|
code: existing.code,
|
||||||
|
updatedAt: existing.updatedAt,
|
||||||
|
});
|
||||||
|
existing.history = existing.history.slice(0, MAX_HISTORY_ITEMS);
|
||||||
|
existing.key = input.key;
|
||||||
|
existing.name = input.name;
|
||||||
|
existing.description = input.description || '';
|
||||||
|
existing.code = input.code;
|
||||||
|
existing.enabled = input.enabled ?? existing.enabled;
|
||||||
|
existing.updatedAt = now;
|
||||||
|
existing.version = getNowVersion();
|
||||||
|
await saveRegistry(registry);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: SourceScriptRecord = {
|
||||||
|
id: nanoid(),
|
||||||
|
key: input.key,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description || '',
|
||||||
|
code: input.code,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
version: getNowVersion(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.items.unshift(created);
|
||||||
|
await saveRegistry(registry);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSourceScripts(items: SourceScriptImportItem[]) {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const now = Date.now();
|
||||||
|
const imported: SourceScriptRecord[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item?.key || !item?.name || !item?.code) {
|
||||||
|
throw new Error('导入脚本缺少必要字段: key/name/code');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertScriptKey(item.key);
|
||||||
|
assertSafeCode(item.code);
|
||||||
|
|
||||||
|
const existing = registry.items.find((record) => record.key === item.key);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.history.unshift({
|
||||||
|
version: existing.version,
|
||||||
|
code: existing.code,
|
||||||
|
updatedAt: existing.updatedAt,
|
||||||
|
});
|
||||||
|
existing.history = existing.history.slice(0, MAX_HISTORY_ITEMS);
|
||||||
|
existing.name = item.name;
|
||||||
|
existing.description = item.description || '';
|
||||||
|
existing.code = item.code;
|
||||||
|
existing.enabled = item.enabled ?? existing.enabled;
|
||||||
|
existing.updatedAt = now;
|
||||||
|
existing.version = getNowVersion();
|
||||||
|
imported.push(existing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: SourceScriptRecord = {
|
||||||
|
id: nanoid(),
|
||||||
|
key: item.key,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description || '',
|
||||||
|
code: item.code,
|
||||||
|
enabled: item.enabled ?? true,
|
||||||
|
version: getNowVersion(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
registry.items.unshift(created);
|
||||||
|
imported.push(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveRegistry(registry);
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSourceScript(id: string) {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const nextItems = registry.items.filter((item) => item.id !== id);
|
||||||
|
if (nextItems.length === registry.items.length) {
|
||||||
|
throw new Error('脚本不存在');
|
||||||
|
}
|
||||||
|
registry.items = nextItems;
|
||||||
|
await saveRegistry(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSourceScriptEnabled(id: string) {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const target = registry.items.find((item) => item.id === id);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('脚本不存在');
|
||||||
|
}
|
||||||
|
target.enabled = !target.enabled;
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
await saveRegistry(registry);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreSourceScriptHistory(id: string, version: string) {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const target = registry.items.find((item) => item.id === id);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('脚本不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = target.history.find((item) => item.version === version);
|
||||||
|
if (!history) {
|
||||||
|
throw new Error('历史版本不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
target.history.unshift({
|
||||||
|
version: target.version,
|
||||||
|
code: target.code,
|
||||||
|
updatedAt: target.updatedAt,
|
||||||
|
});
|
||||||
|
target.history = target.history.slice(0, MAX_HISTORY_ITEMS);
|
||||||
|
target.code = history.code;
|
||||||
|
target.version = getNowVersion();
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
await saveRegistry(registry);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSourceScript(input: {
|
||||||
|
code: string;
|
||||||
|
hook: 'search' | 'detail' | 'resolvePlayUrl';
|
||||||
|
payload: Record<string, any>;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
configValues?: Record<string, string>;
|
||||||
|
}): Promise<SourceScriptTestResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const tempScript: SourceScriptRecord = {
|
||||||
|
id: 'test-script',
|
||||||
|
key: input.key || 'test-script',
|
||||||
|
name: input.name || '测试脚本',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
version: 'test',
|
||||||
|
code: input.code,
|
||||||
|
createdAt: startedAt,
|
||||||
|
updatedAt: startedAt,
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const factory = createScriptFactory(input.code);
|
||||||
|
const compiled = normalizeScript(factory());
|
||||||
|
const hook = compiled[input.hook];
|
||||||
|
if (typeof hook !== 'function') {
|
||||||
|
throw new Error(`脚本未实现 ${input.hook} hook`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ctx, logs } = await createScriptContext(tempScript, input.configValues);
|
||||||
|
const result = await withTimeout(
|
||||||
|
Promise.resolve(hook(ctx, input.payload)),
|
||||||
|
DEFAULT_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
logs,
|
||||||
|
meta: compiled.meta,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
logs: [],
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSourceScriptTemplate() {
|
||||||
|
return DEFAULT_SCRIPT_TEMPLATE;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user