legado初步支持
This commit is contained in:
+236
-16
@@ -12217,6 +12217,8 @@ const OPDSConfigComponent = ({
|
|||||||
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
|
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
|
||||||
const [sources, setSources] = useState<BookSource[]>([]);
|
const [sources, setSources] = useState<BookSource[]>([]);
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
const [legadoImportText, setLegadoImportText] = useState('');
|
||||||
|
const [legadoRuleDrafts, setLegadoRuleDrafts] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.OPDSConfig) {
|
if (config?.OPDSConfig) {
|
||||||
@@ -12226,6 +12228,7 @@ const OPDSConfigComponent = ({
|
|||||||
(config.OPDSConfig.Sources || []).map((item, index) => ({
|
(config.OPDSConfig.Sources || []).map((item, index) => ({
|
||||||
id: item.id || `source_${index + 1}`,
|
id: item.id || `source_${index + 1}`,
|
||||||
name: item.name || `书源 ${index + 1}`,
|
name: item.name || `书源 ${index + 1}`,
|
||||||
|
type: item.type || 'opds',
|
||||||
url: item.url || '',
|
url: item.url || '',
|
||||||
enabled: item.enabled !== false,
|
enabled: item.enabled !== false,
|
||||||
authMode: item.authMode || 'none',
|
authMode: item.authMode || 'none',
|
||||||
@@ -12236,9 +12239,11 @@ const OPDSConfigComponent = ({
|
|||||||
searchTemplate: item.searchTemplate || '',
|
searchTemplate: item.searchTemplate || '',
|
||||||
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
||||||
language: item.language || '',
|
language: item.language || '',
|
||||||
|
legado: item.legado,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
|
setLegadoRuleDrafts({});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
@@ -12264,6 +12269,7 @@ const OPDSConfigComponent = ({
|
|||||||
{
|
{
|
||||||
id: `source_${prev.length + 1}`,
|
id: `source_${prev.length + 1}`,
|
||||||
name: `书源 ${prev.length + 1}`,
|
name: `书源 ${prev.length + 1}`,
|
||||||
|
type: 'opds',
|
||||||
url: '',
|
url: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
authMode: 'none',
|
authMode: 'none',
|
||||||
@@ -12274,13 +12280,76 @@ const OPDSConfigComponent = ({
|
|||||||
searchTemplate: '',
|
searchTemplate: '',
|
||||||
preferFormat: ['epub', 'pdf'],
|
preferFormat: ['epub', 'pdf'],
|
||||||
language: '',
|
language: '',
|
||||||
|
legado: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const makeLegadoSourceId = (name: string, url: string, index: number) => {
|
||||||
|
const raw = `${name}|${url}|${index}`;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return `legado_${Math.abs(hash).toString(36)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importLegadoSources = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(legadoImportText);
|
||||||
|
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
const imported = list
|
||||||
|
.filter((item) => item && typeof item === 'object')
|
||||||
|
.map((rule: any, index) => {
|
||||||
|
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||||
|
const url = rule.bookSourceUrl || '';
|
||||||
|
return {
|
||||||
|
id: makeLegadoSourceId(name, url, index),
|
||||||
|
name,
|
||||||
|
type: 'legado' as const,
|
||||||
|
url,
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
authMode: 'none' as const,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
headerName: '',
|
||||||
|
headerValue: '',
|
||||||
|
searchTemplate: '',
|
||||||
|
preferFormat: ['epub' as const],
|
||||||
|
language: '',
|
||||||
|
legado: rule,
|
||||||
|
} satisfies BookSource;
|
||||||
|
})
|
||||||
|
.filter((source) => !!source.url);
|
||||||
|
|
||||||
|
if (imported.length === 0) {
|
||||||
|
throw new Error('没有识别到有效 Legado 书源,请确认 JSON 内含 bookSourceUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSources((prev) => {
|
||||||
|
const existed = new Set(prev.map((item) => `${item.type || 'opds'}|${item.url}|${item.name}`));
|
||||||
|
const next = imported.filter((item) => !existed.has(`${item.type}|${item.url}|${item.name}`));
|
||||||
|
return [...prev, ...next];
|
||||||
|
});
|
||||||
|
setLegadoImportText('');
|
||||||
|
showSuccess(`已导入 ${imported.length} 个 Legado 书源`, showAlert);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : 'Legado JSON 解析失败', showAlert);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeSource = (index: number) => {
|
const removeSource = (index: number) => {
|
||||||
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
||||||
|
setLegadoRuleDrafts((prev) => {
|
||||||
|
const next: Record<number, string> = {};
|
||||||
|
Object.entries(prev).forEach(([key, value]) => {
|
||||||
|
const numericKey = Number(key);
|
||||||
|
if (numericKey < index) next[numericKey] = value;
|
||||||
|
if (numericKey > index) next[numericKey - 1] = value;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setEditingIndex((prev) => {
|
setEditingIndex((prev) => {
|
||||||
if (prev === null) return prev;
|
if (prev === null) return prev;
|
||||||
if (prev === index) return null;
|
if (prev === index) return null;
|
||||||
@@ -12291,6 +12360,7 @@ const OPDSConfigComponent = ({
|
|||||||
const normalizeSource = (source: BookSource, index: number) => ({
|
const normalizeSource = (source: BookSource, index: number) => ({
|
||||||
id: source.id?.trim() || `source_${index + 1}`,
|
id: source.id?.trim() || `source_${index + 1}`,
|
||||||
name: source.name?.trim() || `书源 ${index + 1}`,
|
name: source.name?.trim() || `书源 ${index + 1}`,
|
||||||
|
type: source.type || 'opds',
|
||||||
url: source.url?.trim() || '',
|
url: source.url?.trim() || '',
|
||||||
enabled: source.enabled !== false,
|
enabled: source.enabled !== false,
|
||||||
authMode: source.authMode || 'none',
|
authMode: source.authMode || 'none',
|
||||||
@@ -12299,13 +12369,28 @@ const OPDSConfigComponent = ({
|
|||||||
headerName:
|
headerName:
|
||||||
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
||||||
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
||||||
searchTemplate: source.searchTemplate?.trim() || '',
|
searchTemplate: source.type === 'legado' ? '' : source.searchTemplate?.trim() || '',
|
||||||
preferFormat: source.preferFormat?.length
|
preferFormat: source.preferFormat?.length
|
||||||
? source.preferFormat
|
? source.preferFormat
|
||||||
: ['epub', 'pdf'],
|
: ['epub', 'pdf'],
|
||||||
language: source.language?.trim() || '',
|
language: source.language?.trim() || '',
|
||||||
|
legado: source.type === 'legado' ? source.legado : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateLegadoRuleJson = (index: number, value: string) => {
|
||||||
|
setLegadoRuleDrafts((prev) => ({ ...prev, [index]: value }));
|
||||||
|
try {
|
||||||
|
const rule = JSON.parse(value);
|
||||||
|
updateSource(index, {
|
||||||
|
legado: rule,
|
||||||
|
name: rule.bookSourceName || sources[index]?.name,
|
||||||
|
url: rule.bookSourceUrl || sources[index]?.url,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 允许用户继续编辑尚未完成的 JSON,保存前需修正为合法 JSON
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildConfig = () => ({
|
const buildConfig = () => ({
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
||||||
@@ -12316,6 +12401,14 @@ const OPDSConfigComponent = ({
|
|||||||
await withLoading('saveOPDSConfig', async () => {
|
await withLoading('saveOPDSConfig', async () => {
|
||||||
try {
|
try {
|
||||||
if (!config) throw new Error('配置未加载');
|
if (!config) throw new Error('配置未加载');
|
||||||
|
for (const [index, draft] of Object.entries(legadoRuleDrafts)) {
|
||||||
|
if (!draft.trim()) continue;
|
||||||
|
try {
|
||||||
|
JSON.parse(draft);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`第 ${Number(index) + 1} 个 Legado 书源 JSON 格式不正确`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const response = await fetch('/api/admin/config', {
|
const response = await fetch('/api/admin/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -12385,10 +12478,10 @@ const OPDSConfigComponent = ({
|
|||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
|
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
|
||||||
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'>
|
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'>
|
||||||
关于电子书馆 / OPDS
|
关于电子书馆 / OPDS / Legado
|
||||||
</h3>
|
</h3>
|
||||||
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
|
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
|
||||||
<p>• 支持多书源,每个源可独立配置认证、搜索模板与默认格式偏好。</p>
|
<p>• 支持多书源,每个源可选择 OPDS 或 Legado,并独立配置规则。</p>
|
||||||
<p>
|
<p>
|
||||||
• 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。
|
• 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。
|
||||||
</p>
|
</p>
|
||||||
@@ -12449,6 +12542,34 @@ const OPDSConfigComponent = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||||
|
<div className='mb-2 flex items-center justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>
|
||||||
|
导入 Legado 书源
|
||||||
|
</h4>
|
||||||
|
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>
|
||||||
|
粘贴阅读/Legado 导出的单个书源 JSON 或书源数组,导入后可逐个测试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={importLegadoSources}
|
||||||
|
disabled={!legadoImportText.trim()}
|
||||||
|
className={buttonStyles.primarySmall}
|
||||||
|
>
|
||||||
|
导入 Legado
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={legadoImportText}
|
||||||
|
onChange={(e) => setLegadoImportText(e.target.value)}
|
||||||
|
placeholder='[{ "bookSourceName": "...", "bookSourceUrl": "...", "searchUrl": "...", "ruleSearch": { ... } }]'
|
||||||
|
rows={5}
|
||||||
|
className='w-full rounded-lg border border-amber-200 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sources.length === 0 && (
|
{sources.length === 0 && (
|
||||||
<div className='rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-500 dark:text-gray-400'>
|
<div className='rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-500 dark:text-gray-400'>
|
||||||
暂无 OPDS 书源,点击“添加书源”开始配置。
|
暂无 OPDS 书源,点击“添加书源”开始配置。
|
||||||
@@ -12499,6 +12620,14 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
|
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
|
||||||
|
类型
|
||||||
|
</span>
|
||||||
|
<span className='min-w-0 text-right'>
|
||||||
|
{source.type === 'legado' ? 'Legado' : 'OPDS'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
|
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
|
||||||
地址
|
地址
|
||||||
@@ -12524,7 +12653,11 @@ const OPDSConfigComponent = ({
|
|||||||
搜索
|
搜索
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{source.searchTemplate?.trim()
|
{source.type === 'legado'
|
||||||
|
? source.legado?.searchUrl
|
||||||
|
? '已配置'
|
||||||
|
: '未配置'
|
||||||
|
: source.searchTemplate?.trim()
|
||||||
? '已配置'
|
? '已配置'
|
||||||
: '未配置'}
|
: '未配置'}
|
||||||
</span>
|
</span>
|
||||||
@@ -12585,6 +12718,27 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-4'>
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
书源类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={source.type || 'opds'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextType = e.target.value as BookSource['type'];
|
||||||
|
updateSource(index, {
|
||||||
|
type: nextType,
|
||||||
|
legado: nextType === 'legado'
|
||||||
|
? source.legado || { bookSourceName: source.name, bookSourceUrl: source.url }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
>
|
||||||
|
<option value='opds'>OPDS</option>
|
||||||
|
<option value='legado'>Legado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
书源 ID
|
书源 ID
|
||||||
@@ -12620,14 +12774,35 @@ const OPDSConfigComponent = ({
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
value={source.url}
|
value={source.url}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateSource(index, { url: e.target.value })
|
const url = e.target.value;
|
||||||
}
|
updateSource(index, {
|
||||||
placeholder='https://example.com/opds'
|
url,
|
||||||
|
legado: source.type === 'legado' ? { ...(source.legado || {}), bookSourceUrl: url } : source.legado,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder={source.type === 'legado' ? 'https://example.com' : 'https://example.com/opds'}
|
||||||
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{source.type === 'legado' ? (
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
Legado 原始 JSON
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={legadoRuleDrafts[index] ?? JSON.stringify(source.legado || {}, null, 2)}
|
||||||
|
onChange={(e) => updateLegadoRuleJson(index, e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
修改后请保持合法 JSON;测试会检查搜索/章节规则是否存在。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className='grid grid-cols-1 gap-4'>
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
@@ -12751,6 +12926,8 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -12823,7 +13000,7 @@ const OPDSConfigComponent = ({
|
|||||||
{source.name || `书源 ${index + 1}`}
|
{source.name || `书源 ${index + 1}`}
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
{source.language || '未设置语言'}
|
{source.type === 'legado' ? 'Legado' : 'OPDS'} · {source.language || '未设置语言'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||||
@@ -12845,7 +13022,11 @@ const OPDSConfigComponent = ({
|
|||||||
: '自定义 Header'}
|
: '自定义 Header'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||||
{source.searchTemplate?.trim()
|
{source.type === 'legado'
|
||||||
|
? source.legado?.searchUrl
|
||||||
|
? '已配置'
|
||||||
|
: '未配置'
|
||||||
|
: source.searchTemplate?.trim()
|
||||||
? '已配置'
|
? '已配置'
|
||||||
: '未配置'}
|
: '未配置'}
|
||||||
</td>
|
</td>
|
||||||
@@ -12933,6 +13114,27 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
书源类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={source.type || 'opds'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextType = e.target.value as BookSource['type'];
|
||||||
|
updateSource(index, {
|
||||||
|
type: nextType,
|
||||||
|
legado: nextType === 'legado'
|
||||||
|
? source.legado || { bookSourceName: source.name, bookSourceUrl: source.url }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
>
|
||||||
|
<option value='opds'>OPDS</option>
|
||||||
|
<option value='legado'>Legado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
书源 ID
|
书源 ID
|
||||||
@@ -12972,16 +13174,32 @@ const OPDSConfigComponent = ({
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
value={source.url}
|
value={source.url}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const url = e.target.value;
|
||||||
updateSource(index, {
|
updateSource(index, {
|
||||||
url: e.target.value,
|
url,
|
||||||
})
|
legado: source.type === 'legado' ? { ...(source.legado || {}), bookSourceUrl: url } : source.legado,
|
||||||
}
|
});
|
||||||
placeholder='https://example.com/opds'
|
}}
|
||||||
|
placeholder={source.type === 'legado' ? 'https://example.com' : 'https://example.com/opds'}
|
||||||
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{source.type === 'legado' ? (
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
Legado 原始 JSON
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={legadoRuleDrafts[index] ?? JSON.stringify(source.legado || {}, null, 2)}
|
||||||
|
onChange={(e) => updateLegadoRuleJson(index, e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
@@ -13109,6 +13327,8 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -13130,7 +13350,7 @@ const OPDSConfigComponent = ({
|
|||||||
disabled={isLoading('saveOPDSConfig')}
|
disabled={isLoading('saveOPDSConfig')}
|
||||||
className={buttonStyles.success}
|
className={buttonStyles.success}
|
||||||
>
|
>
|
||||||
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
|
{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { BookSource, BookSourceCapabilities } from '@/lib/book.types';
|
import { BookSource, BookSourceCapabilities } from '@/lib/book.types';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { legadoClient } from '@/lib/legado.client';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { opdsClient } from '@/lib/opds.client';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -10,6 +11,7 @@ export const runtime = 'nodejs';
|
|||||||
interface TestSourceInput {
|
interface TestSourceInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
type?: 'opds' | 'legado';
|
||||||
url?: string;
|
url?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
authMode?: 'none' | 'basic' | 'header';
|
authMode?: 'none' | 'basic' | 'header';
|
||||||
@@ -20,6 +22,7 @@ interface TestSourceInput {
|
|||||||
searchTemplate?: string;
|
searchTemplate?: string;
|
||||||
preferFormat?: Array<'epub' | 'pdf'>;
|
preferFormat?: Array<'epub' | 'pdf'>;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
legado?: BookSource['legado'];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureAdmin(request: NextRequest) {
|
async function ensureAdmin(request: NextRequest) {
|
||||||
@@ -39,6 +42,7 @@ async function ensureAdmin(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
|
async function detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
|
||||||
|
if (source.type === 'legado') return legadoClient.detectCapabilitiesFromSource(source);
|
||||||
try {
|
try {
|
||||||
const result = await opdsClient.getCatalogFromSource(source);
|
const result = await opdsClient.getCatalogFromSource(source);
|
||||||
return {
|
return {
|
||||||
@@ -70,7 +74,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const inputSources = (body?.Sources || []) as TestSourceInput[];
|
const inputSources = (body?.Sources || []) as TestSourceInput[];
|
||||||
if (!Array.isArray(inputSources) || inputSources.length === 0) {
|
if (!Array.isArray(inputSources) || inputSources.length === 0) {
|
||||||
return NextResponse.json({ success: false, message: '请至少填写一个 OPDS 书源' }, { status: 400 });
|
return NextResponse.json({ success: false, message: '请至少填写一个电子书源' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources: BookSource[] = inputSources
|
const sources: BookSource[] = inputSources
|
||||||
@@ -78,6 +82,7 @@ export async function POST(request: NextRequest) {
|
|||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
id: item.id?.trim() || `source_${index + 1}`,
|
id: item.id?.trim() || `source_${index + 1}`,
|
||||||
name: item.name?.trim() || `书源 ${index + 1}`,
|
name: item.name?.trim() || `书源 ${index + 1}`,
|
||||||
|
type: item.type || 'opds',
|
||||||
url: (item.url || '').trim(),
|
url: (item.url || '').trim(),
|
||||||
enabled: item.enabled !== false,
|
enabled: item.enabled !== false,
|
||||||
authMode: item.authMode || 'none',
|
authMode: item.authMode || 'none',
|
||||||
@@ -88,6 +93,7 @@ export async function POST(request: NextRequest) {
|
|||||||
searchTemplate: item.searchTemplate?.trim() || '',
|
searchTemplate: item.searchTemplate?.trim() || '',
|
||||||
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
||||||
language: item.language?.trim() || '',
|
language: item.language?.trim() || '',
|
||||||
|
legado: item.legado,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const result = await opdsClient.getCatalog(sourceId, href);
|
const result = await bookProvider.getCatalog(sourceId, href);
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { BookAcquisitionLink } from '@/lib/book.types';
|
import { BookAcquisitionLink } from '@/lib/book.types';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ async function resolveDetail(username: string, payload: DetailPayload) {
|
|||||||
}]
|
}]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const detail = await opdsClient.getBookDetail(sourceId, href, {
|
const detail = await bookProvider.getBookDetail(sourceId, href, {
|
||||||
id: bookId,
|
id: bookId,
|
||||||
title: payload.title || shelfItem?.title || readRecord?.title || undefined,
|
title: payload.title || shelfItem?.title || readRecord?.title || undefined,
|
||||||
author: payload.author || shelfItem?.author || readRecord?.author || undefined,
|
author: payload.author || shelfItem?.author || readRecord?.author || undefined,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
@@ -11,12 +11,13 @@ type FilePayload = {
|
|||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
format?: 'epub' | 'pdf' | null;
|
format?: 'epub' | 'pdf' | 'chapters' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> {
|
async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> {
|
||||||
const sourceId = payload.sourceId?.trim();
|
const sourceId = payload.sourceId?.trim();
|
||||||
if (!sourceId) throw new Error('缺少 sourceId');
|
if (!sourceId) throw new Error('缺少 sourceId');
|
||||||
|
if (payload.format === 'chapters') throw new Error('章节型书源不提供文件下载');
|
||||||
|
|
||||||
if (payload.href?.trim()) {
|
if (payload.href?.trim()) {
|
||||||
return { sourceId, href: payload.href.trim() };
|
return { sourceId, href: payload.href.trim() };
|
||||||
@@ -35,9 +36,10 @@ async function resolveFileHref(username: string, payload: FilePayload): Promise<
|
|||||||
const detailHref = shelfItem?.detailHref || readRecord?.detailHref;
|
const detailHref = shelfItem?.detailHref || readRecord?.detailHref;
|
||||||
if (!detailHref) throw new Error('找不到可下载文件');
|
if (!detailHref) throw new Error('找不到可下载文件');
|
||||||
|
|
||||||
const preferred = await opdsClient.getPreferredAcquisition(sourceId, detailHref);
|
const preferred = await bookProvider.getPreferredAcquisition(sourceId, detailHref);
|
||||||
|
if (preferred.format === 'chapters') throw new Error('章节型书源不提供文件下载');
|
||||||
if (payload.format && preferred.format !== payload.format) {
|
if (payload.format && preferred.format !== payload.format) {
|
||||||
const detail = await opdsClient.getBookDetail(sourceId, detailHref);
|
const detail = await bookProvider.getBookDetail(sourceId, detailHref);
|
||||||
const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub')));
|
const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub')));
|
||||||
if (!matched?.href) throw new Error('找不到对应格式文件');
|
if (!matched?.href) throw new Error('找不到对应格式文件');
|
||||||
return { sourceId, href: matched.href };
|
return { sourceId, href: matched.href };
|
||||||
@@ -47,7 +49,7 @@ async function resolveFileHref(username: string, payload: FilePayload): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function proxyFile(request: NextRequest, sourceId: string, href: string) {
|
async function proxyFile(request: NextRequest, sourceId: string, href: string) {
|
||||||
const source = await opdsClient.getSourceById(sourceId);
|
const source = await bookProvider.getSourceById(sourceId);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
if (source.authMode === 'basic' && source.username) {
|
if (source.authMode === 'basic' && source.username) {
|
||||||
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
|
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
|
||||||
@@ -98,7 +100,7 @@ export async function GET(request: NextRequest) {
|
|||||||
sourceId: searchParams.get('sourceId') || undefined,
|
sourceId: searchParams.get('sourceId') || undefined,
|
||||||
bookId: searchParams.get('bookId') || undefined,
|
bookId: searchParams.get('bookId') || undefined,
|
||||||
href: searchParams.get('href') || undefined,
|
href: searchParams.get('href') || undefined,
|
||||||
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null,
|
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | 'chapters' | null) || null,
|
||||||
});
|
});
|
||||||
return await proxyFile(request, resolved.sourceId, resolved.href);
|
return await proxyFile(request, resolved.sourceId, resolved.href);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { legadoClient } from '@/lib/legado.client';
|
||||||
|
|
||||||
|
import { getAuthorizedBooksUsername } from '../../_utils';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const username = await getAuthorizedBooksUsername(request);
|
||||||
|
if (username instanceof NextResponse) return username;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const sourceId = searchParams.get('sourceId')?.trim();
|
||||||
|
const href = searchParams.get('href')?.trim();
|
||||||
|
const tocHref = searchParams.get('tocHref')?.trim() || undefined;
|
||||||
|
if (!sourceId || !href) return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
|
||||||
|
const chapter = await legadoClient.getChapterContent(sourceId, href, tocHref);
|
||||||
|
return NextResponse.json(chapter);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { legadoClient } from '@/lib/legado.client';
|
||||||
|
|
||||||
|
import { getAuthorizedBooksUsername } from '../../_utils';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const username = await getAuthorizedBooksUsername(request);
|
||||||
|
if (username instanceof NextResponse) return username;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const sourceId = searchParams.get('sourceId')?.trim();
|
||||||
|
const bookId = searchParams.get('bookId')?.trim();
|
||||||
|
const href = searchParams.get('href')?.trim() || '';
|
||||||
|
|
||||||
|
if (!sourceId) return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
||||||
|
|
||||||
|
const chapters = bookId
|
||||||
|
? await legadoClient.getChaptersByBookId(sourceId, bookId)
|
||||||
|
: href
|
||||||
|
? await legadoClient.getChapters(sourceId, href)
|
||||||
|
: null;
|
||||||
|
if (!chapters) return NextResponse.json({ error: '缺少 bookId 或 href,无法定位章节目录' }, { status: 400 });
|
||||||
|
return NextResponse.json({ chapters }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { BookAcquisitionLink } from '@/lib/book.types';
|
import { BookAcquisitionLink } from '@/lib/book.types';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../../_utils';
|
import { getAuthorizedBooksUsername } from '../../_utils';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ type ManifestPayload = {
|
|||||||
bookId?: string;
|
bookId?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
acquisitionHref?: string;
|
acquisitionHref?: string;
|
||||||
format?: 'epub' | 'pdf' | null;
|
format?: 'epub' | 'pdf' | 'chapters' | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
cover?: string;
|
cover?: string;
|
||||||
@@ -45,12 +45,12 @@ async function resolveManifest(username: string, payload: ManifestPayload) {
|
|||||||
const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref
|
const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref
|
||||||
? [{
|
? [{
|
||||||
rel: 'http://opds-spec.org/acquisition',
|
rel: 'http://opds-spec.org/acquisition',
|
||||||
type: resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
|
type: resolvedFormat === 'chapters' ? 'application/x-legado-chapters+json' : resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
|
||||||
href: resolvedAcquisitionHref,
|
href: resolvedAcquisitionHref,
|
||||||
}]
|
}]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
|
const detail = await bookProvider.getBookDetail(sourceId, resolvedHref || '', {
|
||||||
id: bookId || resolvedAcquisitionHref || undefined,
|
id: bookId || resolvedAcquisitionHref || undefined,
|
||||||
title: payload.title || existingRecord?.title || shelfItem?.title || undefined,
|
title: payload.title || existingRecord?.title || shelfItem?.title || undefined,
|
||||||
author: payload.author || existingRecord?.author || shelfItem?.author || undefined,
|
author: payload.author || existingRecord?.author || shelfItem?.author || undefined,
|
||||||
@@ -60,9 +60,9 @@ async function resolveManifest(username: string, payload: ManifestPayload) {
|
|||||||
acquisitionLinks: fallbackAcquisitionLinks,
|
acquisitionLinks: fallbackAcquisitionLinks,
|
||||||
});
|
});
|
||||||
const preferred = resolvedHref
|
const preferred = resolvedHref
|
||||||
? await opdsClient.getPreferredAcquisition(sourceId, resolvedHref)
|
? await bookProvider.getPreferredAcquisition(sourceId, resolvedHref)
|
||||||
: {
|
: {
|
||||||
format: resolvedFormat === 'pdf' ? 'pdf' : 'epub',
|
format: resolvedFormat === 'chapters' ? 'chapters' : resolvedFormat === 'pdf' ? 'pdf' : 'epub',
|
||||||
href: resolvedAcquisitionHref || '',
|
href: resolvedAcquisitionHref || '',
|
||||||
};
|
};
|
||||||
const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id);
|
const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id);
|
||||||
@@ -70,7 +70,8 @@ async function resolveManifest(username: string, payload: ManifestPayload) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
book: detail,
|
book: detail,
|
||||||
format: preferred.format,
|
format: preferred.format,
|
||||||
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`,
|
fileUrl: preferred.format === 'chapters' ? undefined : `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`,
|
||||||
|
chaptersUrl: preferred.format === 'chapters' ? `/api/books/read/chapters?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}` : undefined,
|
||||||
acquisitionHref: preferred.href,
|
acquisitionHref: preferred.href,
|
||||||
cacheKey: `${sourceId}::${detail.id}::${preferred.format}`,
|
cacheKey: `${sourceId}::${detail.id}::${preferred.format}`,
|
||||||
coverUrl: detail.cover,
|
coverUrl: detail.cover,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!q) {
|
if (!q) {
|
||||||
return NextResponse.json({ results: [], failedSources: [] });
|
return NextResponse.json({ results: [], failedSources: [] });
|
||||||
}
|
}
|
||||||
const result = await opdsClient.searchBooks(q, sourceId);
|
const result = await bookProvider.searchBooks(q, sourceId);
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../../_utils';
|
import { getAuthorizedBooksUsername } from '../../_utils';
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sources = await opdsClient.getSearchSources(sourceId);
|
const sources = await bookProvider.getSearchSources(sourceId);
|
||||||
let completedSources = 0;
|
let completedSources = 0;
|
||||||
let totalResults = 0;
|
let totalResults = 0;
|
||||||
const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = [];
|
const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = [];
|
||||||
@@ -47,7 +47,7 @@ export async function GET(request: NextRequest) {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
sources.map(async (source) => {
|
sources.map(async (source) => {
|
||||||
try {
|
try {
|
||||||
const result = await opdsClient.searchBooksSource(q, source);
|
const result = await bookProvider.searchBooksSource(q, source);
|
||||||
completedSources += 1;
|
completedSources += 1;
|
||||||
totalResults += result.results.length;
|
totalResults += result.results.length;
|
||||||
send({
|
send({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { bookProvider } from '@/lib/book-provider';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (username instanceof NextResponse) return username;
|
if (username instanceof NextResponse) return username;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sources = await opdsClient.getSources();
|
const sources = await bookProvider.getSources();
|
||||||
return NextResponse.json({ sources });
|
return NextResponse.json({ sources });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
||||||
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
|
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
|
||||||
import { BookDetail, BookShelfItem } from '@/lib/book.types';
|
import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types';
|
||||||
|
|
||||||
function DetailSkeleton() {
|
function DetailSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +47,7 @@ function sanitizeFilename(name: string) {
|
|||||||
return name.replace(/[\/:*?"<>|]/g, '_').trim();
|
return name.replace(/[\/:*?"<>|]/g, '_').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string, title?: string) {
|
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) {
|
||||||
const response = await fetch('/api/books/file', {
|
const response = await fetch('/api/books/file', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -86,6 +86,9 @@ export default function BookDetailPage() {
|
|||||||
const bookId = searchParams.get('bookId') || '';
|
const bookId = searchParams.get('bookId') || '';
|
||||||
const [detail, setDetail] = useState<BookDetail | null>(null);
|
const [detail, setDetail] = useState<BookDetail | null>(null);
|
||||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||||
|
const [chapters, setChapters] = useState<BookChapter[]>([]);
|
||||||
|
const [chaptersLoading, setChaptersLoading] = useState(false);
|
||||||
|
const [chaptersError, setChaptersError] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
|
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
|
||||||
|
|
||||||
@@ -122,8 +125,50 @@ export default function BookDetailPage() {
|
|||||||
.catch((err) => setError(err.message || '获取详情失败'));
|
.catch((err) => setError(err.message || '获取详情失败'));
|
||||||
}, [sourceId, bookId, cached]);
|
}, [sourceId, bookId, cached]);
|
||||||
|
|
||||||
const readable = detail?.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
|
const readable = detail?.acquisitionLinks.find((item) => {
|
||||||
const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub';
|
const type = item.type.toLowerCase();
|
||||||
|
return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters';
|
||||||
|
});
|
||||||
|
const readableFormat = readable?.type.toLowerCase().includes('pdf')
|
||||||
|
? 'pdf'
|
||||||
|
: readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters'
|
||||||
|
? 'chapters'
|
||||||
|
: 'epub';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detail || !readable || readableFormat !== 'chapters') {
|
||||||
|
setChapters([]);
|
||||||
|
setChaptersError('');
|
||||||
|
setChaptersLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setChapters([]);
|
||||||
|
setChaptersLoading(true);
|
||||||
|
setChaptersError('');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sourceId: detail.sourceId,
|
||||||
|
bookId: detail.id,
|
||||||
|
});
|
||||||
|
fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || '获取章节失败');
|
||||||
|
if (cancelled) return;
|
||||||
|
setChapters((json.chapters || []) as BookChapter[]);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setChapters([]);
|
||||||
|
setChaptersError(err.message || '获取章节失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setChaptersLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [detail, readable, readableFormat]);
|
||||||
|
|
||||||
const toggleShelf = async () => {
|
const toggleShelf = async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -176,7 +221,7 @@ export default function BookDetailPage() {
|
|||||||
<div className='flex flex-wrap gap-3'>
|
<div className='flex flex-wrap gap-3'>
|
||||||
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
||||||
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
|
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
|
||||||
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
{readable && readableFormat !== 'chapters' ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -184,7 +229,8 @@ export default function BookDetailPage() {
|
|||||||
<h2 className='text-lg font-semibold'>可用格式</h2>
|
<h2 className='text-lg font-semibold'>可用格式</h2>
|
||||||
<div className='mt-4 space-y-3'>
|
<div className='mt-4 space-y-3'>
|
||||||
{detail.acquisitionLinks.map((item) => {
|
{detail.acquisitionLinks.map((item) => {
|
||||||
const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined;
|
const type = item.type.toLowerCase();
|
||||||
|
const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined;
|
||||||
return (
|
return (
|
||||||
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
|
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
|
||||||
<div>
|
<div>
|
||||||
@@ -193,7 +239,7 @@ export default function BookDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button disabled={!format || fileBusy !== ''} onClick={async () => {
|
<button disabled={!format || fileBusy !== ''} onClick={async () => {
|
||||||
if (!format) return;
|
if (!format) return;
|
||||||
if (format === 'epub') {
|
if (format === 'epub' || format === 'chapters') {
|
||||||
cacheBookDetail(detail);
|
cacheBookDetail(detail);
|
||||||
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
|
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
|
||||||
return;
|
return;
|
||||||
@@ -212,6 +258,36 @@ export default function BookDetailPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{readableFormat === 'chapters' ? (
|
||||||
|
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<h2 className='text-lg font-semibold'>章节目录</h2>
|
||||||
|
<div className='text-sm text-gray-500'>{chaptersLoading ? '加载中...' : `${chapters.length} 章`}</div>
|
||||||
|
</div>
|
||||||
|
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null}
|
||||||
|
{!chaptersLoading && !chaptersError && chapters.length === 0 ? (
|
||||||
|
<div className='mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||||
|
源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{chapters.length > 0 ? (
|
||||||
|
<div className='mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3'>
|
||||||
|
{chapters.slice(0, 60).map((chapter) => (
|
||||||
|
<Link
|
||||||
|
key={`${chapter.href}-${chapter.order}`}
|
||||||
|
href={buildBookReadPath(detail.sourceId, detail.id, chapter.href)}
|
||||||
|
onClick={() => cacheBookDetail(detail)}
|
||||||
|
className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40'
|
||||||
|
title={chapter.title}
|
||||||
|
>
|
||||||
|
{chapter.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{chapters.length > 60 ? <div className='mt-3 text-xs text-gray-500'>仅预览前 60 章,完整目录请进入阅读页侧边栏查看。</div> : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export default function BooksHomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||||
<h1 className='text-lg font-semibold'>OPDS 电子书源</h1>
|
<h1 className='text-lg font-semibold'>电子书源</h1>
|
||||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>支持分类浏览、搜索、书架与 EPUB 在线阅读。</p>
|
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>支持 OPDS 与 Legado 书源,提供搜索、书架与在线阅读。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{loading ? <BooksHomeSkeleton /> : null}
|
{loading ? <BooksHomeSkeleton /> : null}
|
||||||
@@ -56,6 +56,7 @@ export default function BooksHomePage() {
|
|||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||||
<div className='text-base font-semibold'>{source.name}</div>
|
<div className='text-base font-semibold'>{source.name}</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</div>
|
||||||
<div className='mt-2 flex flex-wrap gap-2 text-xs'>
|
<div className='mt-2 flex flex-wrap gap-2 text-xs'>
|
||||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>分类{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span>
|
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>分类{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span>
|
||||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>搜索{source.capabilities?.searchSupported ? '可用' : '不可用'}</span>
|
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>搜索{source.capabilities?.searchSupported ? '可用' : '不可用'}</span>
|
||||||
|
|||||||
+153
-2
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||||
import { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
|
import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
|
||||||
import {
|
import {
|
||||||
buildBookCacheKey,
|
buildBookCacheKey,
|
||||||
enforceBookCacheLimit,
|
enforceBookCacheLimit,
|
||||||
@@ -370,6 +370,153 @@ async function downloadBookWithProgress(
|
|||||||
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialChapterHref = searchParams.get('chapterHref') || '';
|
||||||
|
const [chapters, setChapters] = useState<BookChapter[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [chapter, setChapter] = useState<BookChapterContent | null>(null);
|
||||||
|
const [tocOpen, setTocOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleToggleChapters = () => setTocOpen((prev) => !prev);
|
||||||
|
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
|
||||||
|
return () => window.removeEventListener('books-read-toggle-chapters', handleToggleChapters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manifest.chaptersUrl && !manifest.acquisitionHref) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setChapters([]);
|
||||||
|
setChapter(null);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`;
|
||||||
|
fetch(url, { cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || '获取目录失败');
|
||||||
|
if (cancelled) return;
|
||||||
|
const list = (json.chapters || []) as BookChapter[];
|
||||||
|
setChapters(list);
|
||||||
|
const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value || '';
|
||||||
|
const savedIndex = list.findIndex((item) => item.href === savedHref);
|
||||||
|
setCurrentIndex(savedIndex >= 0 ? savedIndex : 0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message || '获取目录失败');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [initialChapterHref, manifest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const item = chapters[currentIndex];
|
||||||
|
if (!item) {
|
||||||
|
if (chapters.length === 0) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sourceId: manifest.book.sourceId,
|
||||||
|
href: item.href,
|
||||||
|
});
|
||||||
|
if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref);
|
||||||
|
fetch(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || '获取章节失败');
|
||||||
|
setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title });
|
||||||
|
const progressPercent = chapters.length > 0 ? Math.round(((currentIndex + 1) / chapters.length) * 100) : 0;
|
||||||
|
const record: BookReadRecord = {
|
||||||
|
sourceId: manifest.book.sourceId,
|
||||||
|
sourceName: manifest.book.sourceName,
|
||||||
|
bookId: manifest.book.id,
|
||||||
|
title: manifest.book.title,
|
||||||
|
author: manifest.book.author,
|
||||||
|
cover: manifest.book.cover,
|
||||||
|
detailHref: manifest.book.detailHref,
|
||||||
|
acquisitionHref: manifest.acquisitionHref,
|
||||||
|
format: 'chapters',
|
||||||
|
locator: { type: 'chapter', value: item.href, href: item.href, chapterTitle: item.title },
|
||||||
|
chapterTitle: item.title,
|
||||||
|
chapterHref: item.href,
|
||||||
|
progressPercent,
|
||||||
|
saveTime: Date.now(),
|
||||||
|
};
|
||||||
|
void saveBookReadRecord(record.sourceId, record.bookId, record);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message || '获取章节失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [chapters, currentIndex, manifest]);
|
||||||
|
|
||||||
|
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||||
|
if (loading && !chapter) return <div className='p-4 text-sm text-gray-500'>章节加载中...</div>;
|
||||||
|
if (!chapters.length) {
|
||||||
|
return (
|
||||||
|
<div className='mx-auto max-w-2xl p-4'>
|
||||||
|
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||||
|
暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB 文件;如果详情接口显示章节数为 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-[calc(100vh-3.5rem)] bg-gray-50 dark:bg-black'>
|
||||||
|
{tocOpen && typeof document !== 'undefined' ? createPortal(
|
||||||
|
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
||||||
|
<div
|
||||||
|
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='sticky top-0 border-b border-gray-200 bg-white/95 p-4 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
|
||||||
|
<div className='text-base font-semibold'>章节目录</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500'>{manifest.book.title} · {chapters.length} 章</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2 p-4'>
|
||||||
|
{chapters.map((item, index) => {
|
||||||
|
const active = index === currentIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${item.href}-${item.order}-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentIndex(index);
|
||||||
|
setTocOpen(false);
|
||||||
|
}}
|
||||||
|
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
<span className='block truncate'>{item.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className='mx-auto max-w-3xl px-4 py-6'>
|
||||||
|
<article className='text-lg leading-9 text-gray-800 dark:text-gray-100'>
|
||||||
|
{loading ? '加载中...' : chapter?.content?.includes('<img')
|
||||||
|
? <div className='space-y-2' dangerouslySetInnerHTML={{ __html: chapter.content }} />
|
||||||
|
: <div className='whitespace-pre-wrap'>{chapter?.content || '本章暂无内容'}</div>}
|
||||||
|
</article>
|
||||||
|
<div className='mt-5 flex justify-between gap-3'>
|
||||||
|
<button disabled={currentIndex <= 0} onClick={() => setCurrentIndex((prev) => Math.max(0, prev - 1))} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'>上一章</button>
|
||||||
|
<button disabled={currentIndex >= chapters.length - 1} onClick={() => setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1))} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'>下一章</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function normalizeHrefForMatch(href?: string) {
|
function normalizeHrefForMatch(href?: string) {
|
||||||
if (!href) return '';
|
if (!href) return '';
|
||||||
@@ -1174,7 +1321,7 @@ export default function BookReadPage() {
|
|||||||
sourceId: manifest.book.sourceId,
|
sourceId: manifest.book.sourceId,
|
||||||
bookId: manifest.book.id,
|
bookId: manifest.book.id,
|
||||||
title: manifest.book.title,
|
title: manifest.book.title,
|
||||||
format: manifest.format,
|
format: 'epub',
|
||||||
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
|
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
|
||||||
blob,
|
blob,
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
@@ -1655,6 +1802,10 @@ export default function BookReadPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (manifest.format === 'chapters') {
|
||||||
|
return <ChapterReader manifest={manifest} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (manifest.format === 'pdf') {
|
if (manifest.format === 'pdf') {
|
||||||
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF 加载中... {progressLabel}</div>;
|
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF 加载中... {progressLabel}</div>;
|
||||||
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
|
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function getStaticMeta(pathname: string) {
|
|||||||
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
|
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
|
||||||
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
|
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
|
||||||
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
|
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
|
||||||
return { title: '电子书馆', subtitle: 'OPDS 目录、搜索、阅读与书架' };
|
return { title: '电子书馆', subtitle: 'OPDS / Legado 目录、搜索、阅读与书架' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BooksLayout({ children }: { children: React.ReactNode }) {
|
export default function BooksLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ export interface AdminConfig {
|
|||||||
Sources?: Array<{
|
Sources?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
type?: 'opds' | 'legado';
|
||||||
url: string;
|
url: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
authMode?: 'none' | 'basic' | 'header';
|
authMode?: 'none' | 'basic' | 'header';
|
||||||
@@ -297,6 +298,7 @@ export interface AdminConfig {
|
|||||||
searchTemplate?: string;
|
searchTemplate?: string;
|
||||||
preferFormat?: Array<'epub' | 'pdf'>;
|
preferFormat?: Array<'epub' | 'pdf'>;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
legado?: import('./book.types').LegadoBookSourceRule;
|
||||||
}>;
|
}>;
|
||||||
CacheTTL?: number;
|
CacheTTL?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
BookCatalogResult,
|
||||||
|
BookDetail,
|
||||||
|
BookListItem,
|
||||||
|
BookSearchFailure,
|
||||||
|
BookSearchResult,
|
||||||
|
BookSource,
|
||||||
|
} from './book.types';
|
||||||
|
import { legadoClient } from './legado.client';
|
||||||
|
import { opdsClient } from './opds.client';
|
||||||
|
|
||||||
|
function sourceKind(source?: Pick<BookSource, 'type'>) {
|
||||||
|
return source?.type === 'legado' || (source as BookSource | undefined)?.legado ? 'legado' : 'opds';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookProvider {
|
||||||
|
async getSources(): Promise<BookSource[]> {
|
||||||
|
const [opdsSources, legadoSources] = await Promise.all([
|
||||||
|
opdsClient.getSources().catch(() => []),
|
||||||
|
legadoClient.getSources().catch(() => []),
|
||||||
|
]);
|
||||||
|
return [...opdsSources.map((source) => ({ ...source, type: source.type || 'opds' as const })), ...legadoSources];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceById(sourceId: string): Promise<BookSource> {
|
||||||
|
const sources = await this.getSources();
|
||||||
|
const source = sources.find((item) => item.id === sourceId);
|
||||||
|
if (!source) throw new Error('未找到对应的电子书源');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCatalog(sourceId: string, href?: string): Promise<BookCatalogResult> {
|
||||||
|
const source = await this.getSourceById(sourceId);
|
||||||
|
return sourceKind(source) === 'legado'
|
||||||
|
? legadoClient.getCatalog(sourceId, href)
|
||||||
|
: opdsClient.getCatalog(sourceId, href);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearchSources(sourceId?: string): Promise<BookSource[]> {
|
||||||
|
if (sourceId) return [await this.getSourceById(sourceId)];
|
||||||
|
return this.getSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchBooksSource(q: string, source: BookSource): Promise<{ source: BookSource; results: BookListItem[] }> {
|
||||||
|
return sourceKind(source) === 'legado'
|
||||||
|
? legadoClient.searchBooksSource(q, source)
|
||||||
|
: opdsClient.searchBooksSource(q, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||||
|
const sources = await this.getSearchSources(sourceId);
|
||||||
|
const results: BookListItem[] = [];
|
||||||
|
const failedSources: BookSearchFailure[] = [];
|
||||||
|
await Promise.all(sources.map(async (source) => {
|
||||||
|
try {
|
||||||
|
const sourceResult = await this.searchBooksSource(q, source);
|
||||||
|
results.push(...sourceResult.results);
|
||||||
|
} catch (error) {
|
||||||
|
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return { results, failedSources };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBookDetail(sourceId: string, href: string, fallback?: Partial<BookDetail>): Promise<BookDetail> {
|
||||||
|
const source = await this.getSourceById(sourceId);
|
||||||
|
return sourceKind(source) === 'legado'
|
||||||
|
? legadoClient.getBookDetail(sourceId, href, fallback)
|
||||||
|
: opdsClient.getBookDetail(sourceId, href, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPreferredAcquisition(sourceId: string, href: string): Promise<{ format: 'epub' | 'pdf' | 'chapters'; href: string }> {
|
||||||
|
const source = await this.getSourceById(sourceId);
|
||||||
|
if (sourceKind(source) === 'legado') {
|
||||||
|
const detail = await legadoClient.getBookDetail(sourceId, href);
|
||||||
|
const chapters = detail.acquisitionLinks.find((item) => item.type === 'application/x-legado-chapters+json') || detail.acquisitionLinks[0];
|
||||||
|
if (!chapters?.href) throw new Error('当前书籍没有可用章节目录');
|
||||||
|
return { format: 'chapters', href: chapters.href };
|
||||||
|
}
|
||||||
|
return opdsClient.getPreferredAcquisition(sourceId, href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookProvider = new BookProvider();
|
||||||
@@ -16,7 +16,7 @@ export interface BookRouteCacheItem {
|
|||||||
detailHref?: string;
|
detailHref?: string;
|
||||||
acquisitionHref?: string;
|
acquisitionHref?: string;
|
||||||
acquisitionLinks?: BookAcquisitionLink[];
|
acquisitionLinks?: BookAcquisitionLink[];
|
||||||
format?: 'epub' | 'pdf';
|
format?: 'epub' | 'pdf' | 'chapters';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,10 @@ export function cacheBookListItem(item: BookListItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cacheBookDetail(detail: BookDetail) {
|
export function cacheBookDetail(detail: BookDetail) {
|
||||||
const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
|
const readable = detail.acquisitionLinks.find((item) => {
|
||||||
|
const type = item.type.toLowerCase();
|
||||||
|
return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters';
|
||||||
|
});
|
||||||
saveBookRouteCache({
|
saveBookRouteCache({
|
||||||
sourceId: detail.sourceId,
|
sourceId: detail.sourceId,
|
||||||
bookId: detail.id,
|
bookId: detail.id,
|
||||||
@@ -87,7 +90,7 @@ export function cacheBookDetail(detail: BookDetail) {
|
|||||||
summary: detail.summary,
|
summary: detail.summary,
|
||||||
detailHref: detail.detailHref,
|
detailHref: detail.detailHref,
|
||||||
acquisitionHref: readable?.href,
|
acquisitionHref: readable?.href,
|
||||||
format: readable?.type.toLowerCase().includes('pdf') ? 'pdf' : readable ? 'epub' : undefined,
|
format: readable?.type.toLowerCase().includes('pdf') ? 'pdf' : readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters' ? 'chapters' : readable ? 'epub' : undefined,
|
||||||
acquisitionLinks: detail.acquisitionLinks,
|
acquisitionLinks: detail.acquisitionLinks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,11 @@ export function buildBookDetailPath(sourceId: string, bookId: string) {
|
|||||||
return `/books/detail?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
return `/books/detail?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBookReadPath(sourceId: string, bookId: string) {
|
export function buildBookReadPath(sourceId: string, bookId: string, chapterHref?: string) {
|
||||||
return `/books/read?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
const params = new URLSearchParams({
|
||||||
|
sourceId,
|
||||||
|
bookId,
|
||||||
|
});
|
||||||
|
if (chapterHref) params.set('chapterHref', chapterHref);
|
||||||
|
return `/books/read?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-7
@@ -1,16 +1,71 @@
|
|||||||
export interface BookSourceCapabilities {
|
export interface BookSourceCapabilities {
|
||||||
searchSupported: boolean;
|
searchSupported: boolean;
|
||||||
catalogSupported: boolean;
|
catalogSupported: boolean;
|
||||||
searchMode: 'opds' | 'template' | 'disabled';
|
searchMode: 'opds' | 'template' | 'legado' | 'disabled';
|
||||||
catalogMode: 'navigation' | 'acquisition' | 'flat' | 'disabled';
|
catalogMode: 'navigation' | 'acquisition' | 'flat' | 'legado' | 'disabled';
|
||||||
acquisitionTypes: string[];
|
acquisitionTypes: string[];
|
||||||
lastCheckedAt?: number;
|
lastCheckedAt?: number;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LegadoRuleSearch {
|
||||||
|
bookList?: string;
|
||||||
|
name?: string;
|
||||||
|
author?: string;
|
||||||
|
intro?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
bookUrl?: string;
|
||||||
|
kind?: string;
|
||||||
|
lastChapter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegadoRuleBookInfo {
|
||||||
|
name?: string;
|
||||||
|
author?: string;
|
||||||
|
intro?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
tocUrl?: string;
|
||||||
|
kind?: string;
|
||||||
|
lastChapter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegadoRuleToc {
|
||||||
|
chapterList?: string;
|
||||||
|
chapterName?: string;
|
||||||
|
chapterUrl?: string;
|
||||||
|
isVip?: string;
|
||||||
|
isPay?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegadoRuleContent {
|
||||||
|
content?: string;
|
||||||
|
nextContentUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegadoBookSourceRule {
|
||||||
|
bookSourceName?: string;
|
||||||
|
bookSourceUrl?: string;
|
||||||
|
bookSourceGroup?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
enabledExplore?: boolean;
|
||||||
|
header?: string | Record<string, string>;
|
||||||
|
loginUrl?: string;
|
||||||
|
searchUrl?: string;
|
||||||
|
bookInfoUrl?: string;
|
||||||
|
tocUrl?: string;
|
||||||
|
chapterUrl?: string;
|
||||||
|
ruleSearch?: LegadoRuleSearch;
|
||||||
|
ruleBookInfo?: LegadoRuleBookInfo;
|
||||||
|
ruleToc?: LegadoRuleToc;
|
||||||
|
ruleContent?: LegadoRuleContent;
|
||||||
|
customOrder?: number;
|
||||||
|
weight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookSource {
|
export interface BookSource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
type?: 'opds' | 'legado';
|
||||||
url: string;
|
url: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
authMode?: 'none' | 'basic' | 'header';
|
authMode?: 'none' | 'basic' | 'header';
|
||||||
@@ -21,6 +76,7 @@ export interface BookSource {
|
|||||||
searchTemplate?: string;
|
searchTemplate?: string;
|
||||||
preferFormat?: Array<'epub' | 'pdf'>;
|
preferFormat?: Array<'epub' | 'pdf'>;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
legado?: LegadoBookSourceRule;
|
||||||
capabilities?: BookSourceCapabilities;
|
capabilities?: BookSourceCapabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +143,7 @@ export interface BookSearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookLocator {
|
export interface BookLocator {
|
||||||
type: 'epub-cfi' | 'pdf-page' | 'href';
|
type: 'epub-cfi' | 'pdf-page' | 'href' | 'chapter';
|
||||||
value: string;
|
value: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
chapterTitle?: string;
|
chapterTitle?: string;
|
||||||
@@ -100,7 +156,7 @@ export interface BookShelfItem {
|
|||||||
title: string;
|
title: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
cover?: string;
|
cover?: string;
|
||||||
format?: 'epub' | 'pdf';
|
format?: 'epub' | 'pdf' | 'chapters';
|
||||||
detailHref?: string;
|
detailHref?: string;
|
||||||
acquisitionHref?: string;
|
acquisitionHref?: string;
|
||||||
progressPercent?: number;
|
progressPercent?: number;
|
||||||
@@ -118,7 +174,7 @@ export interface BookReadRecord {
|
|||||||
title: string;
|
title: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
cover?: string;
|
cover?: string;
|
||||||
format: 'epub' | 'pdf';
|
format: 'epub' | 'pdf' | 'chapters';
|
||||||
detailHref?: string;
|
detailHref?: string;
|
||||||
acquisitionHref?: string;
|
acquisitionHref?: string;
|
||||||
locator: BookLocator;
|
locator: BookLocator;
|
||||||
@@ -159,10 +215,27 @@ export interface BookTtsProgress {
|
|||||||
|
|
||||||
export interface BookReadManifest {
|
export interface BookReadManifest {
|
||||||
book: BookDetail;
|
book: BookDetail;
|
||||||
format: 'epub' | 'pdf';
|
format: 'epub' | 'pdf' | 'chapters';
|
||||||
fileUrl: string;
|
fileUrl?: string;
|
||||||
|
chaptersUrl?: string;
|
||||||
acquisitionHref?: string;
|
acquisitionHref?: string;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
lastRecord?: BookReadRecord | null;
|
lastRecord?: BookReadRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookChapter {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookChapterContent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
content: string;
|
||||||
|
nextHref?: string;
|
||||||
|
previousHref?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -695,6 +695,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
return [{
|
return [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: process.env.OPDS_NAME || '默认书源',
|
name: process.env.OPDS_NAME || '默认书源',
|
||||||
|
type: 'opds',
|
||||||
url: envUrl,
|
url: envUrl,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
|
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
|
||||||
|
|||||||
@@ -0,0 +1,687 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import * as cheerio from 'cheerio/slim';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import he from 'he';
|
||||||
|
|
||||||
|
import { getConfig } from './config';
|
||||||
|
import {
|
||||||
|
BookAcquisitionLink,
|
||||||
|
BookCatalogResult,
|
||||||
|
BookChapter,
|
||||||
|
BookChapterContent,
|
||||||
|
BookDetail,
|
||||||
|
BookListItem,
|
||||||
|
BookSearchFailure,
|
||||||
|
BookSearchResult,
|
||||||
|
BookSource,
|
||||||
|
BookSourceCapabilities,
|
||||||
|
LegadoBookSourceRule,
|
||||||
|
} from './book.types';
|
||||||
|
import { validateProxyUrlServerSide } from './server/ssrf';
|
||||||
|
|
||||||
|
interface ResolvedLegadoConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
sources: BookSource[];
|
||||||
|
cacheTTL: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = Number(process.env.LEGADO_TIMEOUT_MS || process.env.OPDS_TIMEOUT_MS || 20000);
|
||||||
|
const MAX_TEXT_BYTES = Number(process.env.LEGADO_MAX_TEXT_BYTES || 3 * 1024 * 1024);
|
||||||
|
const LEGADO_CACHE_VERSION = 'v5';
|
||||||
|
const textCache = new Map<string, { expiresAt: number; data: string }>();
|
||||||
|
const searchCache = new Map<string, { expiresAt: number; data: BookListItem[] }>();
|
||||||
|
const detailCache = new Map<string, { expiresAt: number; data: BookDetail }>();
|
||||||
|
const tocCache = new Map<string, { expiresAt: number; data: BookChapter[] }>();
|
||||||
|
const chapterCache = new Map<string, { expiresAt: number; data: BookChapterContent }>();
|
||||||
|
|
||||||
|
function stableId(input: string) {
|
||||||
|
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObjectHeader(value?: string | Record<string, string>): Record<string, string> {
|
||||||
|
if (!value) return {};
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return value.split('\n').reduce<Record<string, string>>((headers, line) => {
|
||||||
|
const index = line.indexOf(':');
|
||||||
|
if (index > 0) headers[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
||||||
|
return headers;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(source: BookSource): HeadersInit {
|
||||||
|
const rule = source.legado;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
...asObjectHeader(rule?.header),
|
||||||
|
};
|
||||||
|
if (source.authMode === 'header' && source.headerName && source.headerValue) headers[source.headerName] = source.headerValue;
|
||||||
|
if (source.authMode === 'basic' && source.username) headers.Authorization = `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`;
|
||||||
|
delete headers.Host;
|
||||||
|
delete headers.host;
|
||||||
|
delete headers['Content-Length'];
|
||||||
|
delete headers['content-length'];
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceBase(source: BookSource) {
|
||||||
|
return source.legado?.bookSourceUrl || source.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(base: string, href?: string): string {
|
||||||
|
if (!href) return base;
|
||||||
|
const trimmed = href.trim();
|
||||||
|
if (!trimmed) return base;
|
||||||
|
if (/^javascript:/i.test(trimmed)) return '';
|
||||||
|
return new URL(trimmed, base).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeRuleParam(value: string) {
|
||||||
|
return encodeURIComponent(value).replace(/%20/g, '+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrlFromTemplate(template: string, source: BookSource, keyword?: string, page = 1, baseOverride?: string) {
|
||||||
|
const base = baseOverride || sourceBase(source);
|
||||||
|
let raw = template || base;
|
||||||
|
raw = raw.replace(/\{\{(?:key|keyword|searchTerms)\}\}/g, encodeRuleParam(keyword || ''));
|
||||||
|
raw = raw.replace(/\{\{(?:page|pageIndex)\}\}/g, String(page));
|
||||||
|
raw = raw
|
||||||
|
.replace(/\{searchTerms\}/g, encodeRuleParam(keyword || ''))
|
||||||
|
.replace(/\{key\}/g, encodeRuleParam(keyword || ''))
|
||||||
|
.replace(/\{keyword\}/g, encodeRuleParam(keyword || ''))
|
||||||
|
.replace(/\{page\}/g, String(page))
|
||||||
|
.replace(/\{pageIndex\}/g, String(page));
|
||||||
|
if (raw.includes('{{') && keyword) raw = raw.replace(/\{\{.*?\}\}/g, encodeRuleParam(keyword));
|
||||||
|
return normalizeUrl(base, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonMaybe(value: string): any | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPrimitiveToString(value: any): string {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
if (Array.isArray(value)) return value.map(jsonPrimitiveToString).filter(Boolean).join(', ');
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonPath(input: any, path?: string): any {
|
||||||
|
if (!path) return input;
|
||||||
|
let normalized = path.trim();
|
||||||
|
if (normalized.startsWith('@json:')) normalized = normalized.slice(6);
|
||||||
|
if (normalized.startsWith('-@json:')) normalized = normalized.slice(7);
|
||||||
|
if (!normalized || normalized === '$') return input;
|
||||||
|
|
||||||
|
const recursive = normalized.match(/^\$\.\.([A-Za-z0-9_$-]+)\[\*\]$/);
|
||||||
|
if (recursive) {
|
||||||
|
const key = recursive[1];
|
||||||
|
const out: any[] = [];
|
||||||
|
const walk = (node: any) => {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node[key])) out.push(...node[key]);
|
||||||
|
Object.values(node).forEach(walk);
|
||||||
|
};
|
||||||
|
walk(input);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized.replace(/^\$\.?/, '');
|
||||||
|
const tokens = normalized.match(/[^.[\]]+|\[\*\]|\[\d+\]/g) || [];
|
||||||
|
let current = input;
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (current === undefined || current === null) return undefined;
|
||||||
|
if (token === '[*]') {
|
||||||
|
if (!Array.isArray(current)) return [];
|
||||||
|
current = current.flat();
|
||||||
|
} else if (/^\[\d+\]$/.test(token)) {
|
||||||
|
current = Array.isArray(current) ? current[Number(token.slice(1, -1))] : undefined;
|
||||||
|
} else if (Array.isArray(current)) {
|
||||||
|
current = current.map((item) => item?.[token]).filter((item) => item !== undefined);
|
||||||
|
} else {
|
||||||
|
current = current[token];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleIsJson(rule?: string) {
|
||||||
|
return !!rule && /@json:|-@json:|^\$\./.test(rule.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectJsonItems(json: any, rule?: string): any[] {
|
||||||
|
const reverse = !!rule?.trim().startsWith('-');
|
||||||
|
const value = readJsonPath(json, rule);
|
||||||
|
const list = Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
return reverse ? [...list].reverse() : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplateWithJson(template: string, json: any, source: BookSource, baseUrl: string) {
|
||||||
|
const rendered = template.replace(/\{\{(.*?)\}\}/g, (_, expr) => {
|
||||||
|
const normalizedExpr = String(expr).trim().replace(/^@json:/, '');
|
||||||
|
const value = readJsonPath(json, normalizedExpr);
|
||||||
|
return encodeRuleParam(jsonPrimitiveToString(value));
|
||||||
|
});
|
||||||
|
return normalizeUrl(baseUrl || sourceBase(source), rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonRule(json: any, rule?: string, source?: BookSource, baseUrl?: string): string {
|
||||||
|
if (!rule) return '';
|
||||||
|
const trimmed = rule.trim();
|
||||||
|
if (trimmed.includes('{{')) return renderTemplateWithJson(trimmed, json, source as BookSource, baseUrl || sourceBase(source as BookSource));
|
||||||
|
if (trimmed.startsWith('@js:')) {
|
||||||
|
if (/result\s*=\s*['"]([^'"]+)['"]\s*\+\s*result\.([A-Za-z0-9_$-]+)/.test(trimmed)) {
|
||||||
|
const match = trimmed.match(/result\s*=\s*['"]([^'"]+)['"]\s*\+\s*result\.([A-Za-z0-9_$-]+)/);
|
||||||
|
return normalizeUrl(baseUrl || sourceBase(source as BookSource), `${match?.[1] || ''}${jsonPrimitiveToString(json?.[match?.[2] || ''])}`);
|
||||||
|
}
|
||||||
|
if (/item\.img|\.reverse\(\)/.test(trimmed)) {
|
||||||
|
const data = Array.isArray(json?.data) ? [...json.data].reverse() : Array.isArray(json) ? [...json].reverse() : [];
|
||||||
|
return data
|
||||||
|
.map((item) => item?.img ? `<img src="${String(item.img)}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">` : '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const value = readJsonPath(json, trimmed);
|
||||||
|
const text = jsonPrimitiveToString(value);
|
||||||
|
if ((/url|href|pic|cover/i.test(trimmed) || /^https?:\/\//i.test(text)) && text && baseUrl) return normalizeUrl(baseUrl, text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackChapterHrefFromItem(item: any, rule?: string, baseUrl?: string): string {
|
||||||
|
const id = jsonPrimitiveToString(item?.id || item?.cid || item?.chapter_id || item?.chapterId);
|
||||||
|
if (!id) return '';
|
||||||
|
const match = (rule || '').match(/['"]([^'"]*(?:pic|chapter)[^'"]*(?:cid|id)=)['"]/i);
|
||||||
|
if (match?.[1]) return normalizeUrl(baseUrl || '', `${match[1]}${id}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitAlternatives(rule?: string): string[] {
|
||||||
|
return (rule || '').split('||').map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStep(step: string): { selector: string; attr: string } {
|
||||||
|
const trimmed = step.trim();
|
||||||
|
const attrMatch = trimmed.match(/(?:@|::)(text|textNodes|html|href|src|content|value|data-[\w-]+|[\w-]+)$/i);
|
||||||
|
if (attrMatch) {
|
||||||
|
return { selector: trimmed.slice(0, attrMatch.index).trim(), attr: attrMatch[1] };
|
||||||
|
}
|
||||||
|
const dotAttr = trimmed.match(/\.(text|html|href|src)$/i);
|
||||||
|
if (dotAttr) return { selector: trimmed.slice(0, dotAttr.index).trim(), attr: dotAttr[1] };
|
||||||
|
return { selector: trimmed, attr: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFilters(rule: string) {
|
||||||
|
return rule.split('##')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectElements($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string): cheerio.Cheerio<any> {
|
||||||
|
const normalized = stripFilters(rule || '');
|
||||||
|
if (!normalized) return root;
|
||||||
|
const steps = normalized.split(/&&|@css:/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
for (const rawStep of steps) {
|
||||||
|
const { selector, attr } = parseStep(rawStep);
|
||||||
|
if (!selector || attr) break;
|
||||||
|
current = current.find(selector);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string): string {
|
||||||
|
for (const alternative of splitAlternatives(rule)) {
|
||||||
|
const normalized = stripFilters(alternative);
|
||||||
|
const steps = normalized.split(/&&|@css:/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
let attr = '';
|
||||||
|
for (const rawStep of steps) {
|
||||||
|
const parsed = parseStep(rawStep);
|
||||||
|
if (parsed.selector) current = current.find(parsed.selector);
|
||||||
|
if (parsed.attr) attr = parsed.attr;
|
||||||
|
}
|
||||||
|
if (current.length === 0 && steps.length === 1) {
|
||||||
|
const parsed = parseStep(steps[0]);
|
||||||
|
if (!parsed.selector && parsed.attr) current = root;
|
||||||
|
}
|
||||||
|
const node = current.first();
|
||||||
|
let value = '';
|
||||||
|
const normalizedAttr = attr.toLowerCase();
|
||||||
|
if (!attr || normalizedAttr === 'text' || normalizedAttr === 'textnodes') value = node.text();
|
||||||
|
else if (normalizedAttr === 'html') value = node.html() || '';
|
||||||
|
else value = node.attr(attr) || '';
|
||||||
|
value = he.decode(value || '').replace(/\u00a0/g, ' ').trim();
|
||||||
|
if ((normalizedAttr === 'href' || normalizedAttr === 'src') && value && baseUrl) value = normalizeUrl(baseUrl, value);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentFromRule(raw: string, rule?: string, baseUrl?: string): string {
|
||||||
|
const json = parseJsonMaybe(raw);
|
||||||
|
if (json && (ruleIsJson(rule) || rule?.trim().startsWith('@js:'))) {
|
||||||
|
return readJsonRule(json, rule, undefined, baseUrl);
|
||||||
|
}
|
||||||
|
const $ = cheerio.load(raw);
|
||||||
|
return readValue($, $.root(), rule, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanContent(value: string) {
|
||||||
|
const decoded = he.decode(value || '').trim();
|
||||||
|
if (/<img\b/i.test(decoded)) return decoded;
|
||||||
|
return decoded
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/p>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLegadoConfig(): Promise<ResolvedLegadoConfig> {
|
||||||
|
let enabled = process.env.OPDS_ENABLED === 'true' || process.env.LEGADO_ENABLED === 'true';
|
||||||
|
let sources: BookSource[] = [];
|
||||||
|
const cacheTTL = Number(process.env.LEGADO_CACHE_TTL_MS || process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
||||||
|
|
||||||
|
const envJson = process.env.LEGADO_SOURCES_JSON;
|
||||||
|
if (envJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(envJson);
|
||||||
|
sources = normalizeImportedSources(parsed);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
if (config.OPDSConfig) {
|
||||||
|
enabled = config.OPDSConfig.Enabled ?? enabled;
|
||||||
|
if (Array.isArray(config.OPDSConfig.Sources)) {
|
||||||
|
sources = (config.OPDSConfig.Sources as BookSource[])
|
||||||
|
.map((source, index) => normalizeConfiguredLegadoSource(source, index))
|
||||||
|
.filter((source): source is BookSource => !!source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return { enabled, cacheTTL, sources: sources.filter((source) => !!source.url && source.enabled !== false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeImportedSources(input: unknown): BookSource[] {
|
||||||
|
const list = Array.isArray(input) ? input : [input];
|
||||||
|
return list
|
||||||
|
.filter((item): item is LegadoBookSourceRule => !!item && typeof item === 'object')
|
||||||
|
.map((rule, index) => {
|
||||||
|
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||||
|
const url = rule.bookSourceUrl || '';
|
||||||
|
return {
|
||||||
|
id: `legado_${stableId(`${name}|${url}|${index}`)}`,
|
||||||
|
name,
|
||||||
|
type: 'legado' as const,
|
||||||
|
url,
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
authMode: 'none' as const,
|
||||||
|
preferFormat: ['epub' as const],
|
||||||
|
language: '',
|
||||||
|
legado: rule,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((source) => !!source.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfiguredLegadoSource(item: any, index: number): BookSource | null {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
if (item.type === 'legado' || item.legado) {
|
||||||
|
const rule = item.legado || item;
|
||||||
|
const name = item.name || rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||||
|
const url = item.url || rule.bookSourceUrl || '';
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: item.id || `legado_${stableId(`${name}|${url}|${index}`)}`,
|
||||||
|
name,
|
||||||
|
type: 'legado',
|
||||||
|
url,
|
||||||
|
enabled: item.enabled !== false && rule.enabled !== false,
|
||||||
|
authMode: item.authMode || 'none',
|
||||||
|
legado: { ...rule, bookSourceName: rule.bookSourceName || name, bookSourceUrl: rule.bookSourceUrl || url },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.bookSourceUrl || item.searchUrl || item.ruleSearch) {
|
||||||
|
return normalizeImportedSources([item])[0] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(source: BookSource, url: string): Promise<string> {
|
||||||
|
const safe = await validateProxyUrlServerSide(url);
|
||||||
|
if (!safe) throw new Error('书源地址未通过安全校验');
|
||||||
|
const cacheKey = `${LEGADO_CACHE_VERSION}|text|${source.id}|${url}`;
|
||||||
|
const cached = textCache.get(cacheKey);
|
||||||
|
const { cacheTTL } = await resolveLegadoConfig();
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { headers: buildHeaders(source), signal: controller.signal, cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error(`请求失败: ${response.status}`);
|
||||||
|
const contentLength = Number(response.headers.get('content-length') || '0');
|
||||||
|
if (contentLength > MAX_TEXT_BYTES) throw new Error('响应内容过大');
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length > MAX_TEXT_BYTES) throw new Error('响应内容过大');
|
||||||
|
textCache.set(cacheKey, { data: text, expiresAt: Date.now() + cacheTTL });
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSourceById(sourceId: string): Promise<BookSource> {
|
||||||
|
const config = await resolveLegadoConfig();
|
||||||
|
const source = config.sources.find((item) => item.id === sourceId);
|
||||||
|
if (!source) throw new Error('未找到对应的 Legado 书源');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRule(source: BookSource): LegadoBookSourceRule {
|
||||||
|
if (!source.legado) throw new Error('Legado 书源缺少规则');
|
||||||
|
return source.legado;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItem(source: BookSource, partial: Partial<BookListItem> & { detailHref?: string; title?: string }): BookListItem {
|
||||||
|
const detailHref = partial.detailHref || '';
|
||||||
|
return {
|
||||||
|
id: partial.id || stableId(`${source.id}|${detailHref || partial.title || Date.now()}`),
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
title: partial.title || '未命名电子书',
|
||||||
|
author: partial.author,
|
||||||
|
cover: partial.cover,
|
||||||
|
summary: partial.summary,
|
||||||
|
tags: partial.tags,
|
||||||
|
detailHref,
|
||||||
|
acquisitionLinks: partial.acquisitionLinks || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LegadoClient {
|
||||||
|
async getSources(): Promise<BookSource[]> {
|
||||||
|
const config = await resolveLegadoConfig();
|
||||||
|
if (!config.enabled) return [];
|
||||||
|
return config.sources.map((source) => ({
|
||||||
|
...source,
|
||||||
|
capabilities: {
|
||||||
|
searchSupported: !!source.legado?.searchUrl,
|
||||||
|
catalogSupported: false,
|
||||||
|
searchMode: source.legado?.searchUrl ? 'legado' : 'disabled',
|
||||||
|
catalogMode: 'legado',
|
||||||
|
acquisitionTypes: ['application/x-legado-chapters+json'],
|
||||||
|
lastCheckedAt: Date.now(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearchSources(sourceId?: string): Promise<BookSource[]> {
|
||||||
|
return sourceId ? [await getSourceById(sourceId)] : (await resolveLegadoConfig()).sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchBooksSource(q: string, source: BookSource): Promise<{ source: BookSource; results: BookListItem[] }> {
|
||||||
|
const rule = getRule(source);
|
||||||
|
if (!rule.searchUrl || !rule.ruleSearch?.bookList) throw new Error('该 Legado 书源不支持搜索');
|
||||||
|
const cacheKey = `${LEGADO_CACHE_VERSION}|search|${source.id}|${q}`;
|
||||||
|
const { cacheTTL } = await resolveLegadoConfig();
|
||||||
|
const cached = searchCache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return { source, results: cached.data };
|
||||||
|
|
||||||
|
const targetUrl = buildUrlFromTemplate(rule.searchUrl, source, q, 1);
|
||||||
|
const html = await fetchText(source, targetUrl);
|
||||||
|
const results: BookListItem[] = [];
|
||||||
|
const json = parseJsonMaybe(html);
|
||||||
|
if (json && ruleIsJson(rule.ruleSearch.bookList)) {
|
||||||
|
const items = selectJsonItems(json, rule.ruleSearch.bookList);
|
||||||
|
items.forEach((item) => {
|
||||||
|
const detailHref = readJsonRule(item, rule.ruleSearch?.bookUrl, source, targetUrl);
|
||||||
|
const title = readJsonRule(item, rule.ruleSearch?.name, source, targetUrl);
|
||||||
|
if (!title && !detailHref) return;
|
||||||
|
const cover = readJsonRule(item, rule.ruleSearch?.coverUrl, source, targetUrl);
|
||||||
|
results.push(makeItem(source, {
|
||||||
|
id: jsonPrimitiveToString(item?.id) || undefined,
|
||||||
|
title,
|
||||||
|
author: readJsonRule(item, rule.ruleSearch?.author, source, targetUrl),
|
||||||
|
summary: readJsonRule(item, rule.ruleSearch?.intro, source, targetUrl),
|
||||||
|
cover: cover || undefined,
|
||||||
|
detailHref,
|
||||||
|
tags: readJsonRule(item, rule.ruleSearch?.kind, source, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const items = selectElements($, $.root(), rule.ruleSearch.bookList);
|
||||||
|
items.each((_, element) => {
|
||||||
|
const root = $(element);
|
||||||
|
const detailHref = readValue($, root, rule.ruleSearch?.bookUrl, targetUrl);
|
||||||
|
const title = readValue($, root, rule.ruleSearch?.name, targetUrl);
|
||||||
|
if (!title && !detailHref) return;
|
||||||
|
const cover = readValue($, root, rule.ruleSearch?.coverUrl, targetUrl);
|
||||||
|
results.push(makeItem(source, {
|
||||||
|
title,
|
||||||
|
author: readValue($, root, rule.ruleSearch?.author, targetUrl),
|
||||||
|
summary: readValue($, root, rule.ruleSearch?.intro, targetUrl),
|
||||||
|
cover: cover || undefined,
|
||||||
|
detailHref,
|
||||||
|
tags: readValue($, root, rule.ruleSearch?.kind, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
searchCache.set(cacheKey, { data: results, expiresAt: Date.now() + cacheTTL });
|
||||||
|
return { source, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||||
|
const sources = await this.getSearchSources(sourceId);
|
||||||
|
const results: BookListItem[] = [];
|
||||||
|
const failedSources: BookSearchFailure[] = [];
|
||||||
|
await Promise.all(sources.map(async (source) => {
|
||||||
|
try {
|
||||||
|
const sourceResult = await this.searchBooksSource(q, source);
|
||||||
|
results.push(...sourceResult.results);
|
||||||
|
} catch (error) {
|
||||||
|
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return { results, failedSources };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCatalog(sourceId: string, href?: string): Promise<BookCatalogResult> {
|
||||||
|
const source = await getSourceById(sourceId);
|
||||||
|
return {
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
title: source.name,
|
||||||
|
href: href || source.url,
|
||||||
|
entries: [],
|
||||||
|
navigation: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChaptersByBookId(sourceId: string, bookId: string): Promise<BookChapter[]> {
|
||||||
|
const source = await getSourceById(sourceId);
|
||||||
|
const rule = getRule(source);
|
||||||
|
const base = sourceBase(source);
|
||||||
|
const detailHref = rule.ruleSearch?.bookUrl
|
||||||
|
? normalizeUrl(base, rule.ruleSearch.bookUrl
|
||||||
|
.replace(/\{\{\s*\$\.id\s*\}\}/g, encodeURIComponent(bookId))
|
||||||
|
.replace(/\{\{\s*id\s*\}\}/g, encodeURIComponent(bookId))
|
||||||
|
.replace(/\{id\}/g, encodeURIComponent(bookId)))
|
||||||
|
: '';
|
||||||
|
if (!detailHref) throw new Error('该 Legado 书源无法通过 bookId 定位详情');
|
||||||
|
const detail = await this.getBookDetail(sourceId, detailHref, { id: bookId, detailHref });
|
||||||
|
const tocHref = detail.acquisitionLinks.find((item) => item.rel === 'legado:chapters' || item.type.toLowerCase().includes('legado-chapters'))?.href;
|
||||||
|
if (!tocHref) return [];
|
||||||
|
return this.getChapters(sourceId, tocHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBookDetail(sourceId: string, href: string, fallback?: Partial<BookDetail>): Promise<BookDetail> {
|
||||||
|
const source = await getSourceById(sourceId);
|
||||||
|
const rule = getRule(source);
|
||||||
|
const detailHref = href || fallback?.detailHref || '';
|
||||||
|
const cacheKey = `${LEGADO_CACHE_VERSION}|detail|${source.id}|${detailHref}`;
|
||||||
|
const { cacheTTL } = await resolveLegadoConfig();
|
||||||
|
const cached = detailCache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return { ...cached.data, ...(!href && fallback ? fallback : {}) };
|
||||||
|
|
||||||
|
let detail: BookDetail | null = null;
|
||||||
|
if (detailHref && rule.ruleBookInfo) {
|
||||||
|
const targetUrl = rule.bookInfoUrl ? buildUrlFromTemplate(rule.bookInfoUrl, source, undefined, 1, detailHref).replace(/\{bookUrl\}/g, encodeURIComponent(detailHref)) : detailHref;
|
||||||
|
const html = await fetchText(source, targetUrl);
|
||||||
|
const json = parseJsonMaybe(html);
|
||||||
|
const $ = json ? null : cheerio.load(html);
|
||||||
|
const root = $?.root();
|
||||||
|
const read = (itemRule?: string) => json
|
||||||
|
? readJsonRule(json, itemRule, source, targetUrl)
|
||||||
|
: readValue($ as cheerio.CheerioAPI, root as cheerio.Cheerio<any>, itemRule, targetUrl);
|
||||||
|
const tocUrl = read(rule.ruleBookInfo.tocUrl) || (rule.tocUrl ? buildUrlFromTemplate(rule.tocUrl, source, undefined, 1, detailHref).replace(/\{bookUrl\}/g, encodeURIComponent(detailHref)) : targetUrl);
|
||||||
|
const cover = read(rule.ruleBookInfo.coverUrl) || fallback?.cover;
|
||||||
|
const title = read(rule.ruleBookInfo.name) || fallback?.title || '未命名电子书';
|
||||||
|
const chapterCountText = json
|
||||||
|
? jsonPrimitiveToString(readJsonPath(json, '@json:$.data.nums') ?? readJsonPath(json, '@json:$.data.chapter_nums'))
|
||||||
|
: '';
|
||||||
|
const chapterCount = chapterCountText ? Number(chapterCountText) : NaN;
|
||||||
|
const hasKnownEmptyChapters = Number.isFinite(chapterCount) && chapterCount <= 0;
|
||||||
|
const acquisitionLinks: BookAcquisitionLink[] = hasKnownEmptyChapters ? [] : [{ rel: 'legado:chapters', type: 'application/x-legado-chapters+json', href: tocUrl, title: '章节目录' }];
|
||||||
|
detail = {
|
||||||
|
id: fallback?.id || stableId(`${source.id}|${detailHref || title}`),
|
||||||
|
sourceId,
|
||||||
|
sourceName: source.name,
|
||||||
|
title,
|
||||||
|
author: read(rule.ruleBookInfo.author) || fallback?.author,
|
||||||
|
cover: cover || undefined,
|
||||||
|
summary: read(rule.ruleBookInfo.intro) || fallback?.summary,
|
||||||
|
tags: read(rule.ruleBookInfo.kind).split(/[,,\s]+/).filter(Boolean),
|
||||||
|
categories: read(rule.ruleBookInfo.kind).split(/[,,\s]+/).filter(Boolean),
|
||||||
|
detailHref,
|
||||||
|
acquisitionLinks,
|
||||||
|
navigation: hasKnownEmptyChapters ? [] : [{ title: '目录', href: tocUrl, rel: 'legado:toc', type: 'application/x-legado-chapters+json' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
const tocUrl = fallback?.acquisitionLinks?.[0]?.href || detailHref;
|
||||||
|
detail = {
|
||||||
|
id: fallback?.id || stableId(`${source.id}|${detailHref || fallback?.title || ''}`),
|
||||||
|
sourceId,
|
||||||
|
sourceName: source.name,
|
||||||
|
title: fallback?.title || '未命名电子书',
|
||||||
|
author: fallback?.author,
|
||||||
|
cover: fallback?.cover,
|
||||||
|
summary: fallback?.summary,
|
||||||
|
detailHref,
|
||||||
|
acquisitionLinks: [{ rel: 'legado:chapters', type: 'application/x-legado-chapters+json', href: tocUrl, title: '章节目录' }],
|
||||||
|
navigation: [{ title: '目录', href: tocUrl, rel: 'legado:toc', type: 'application/x-legado-chapters+json' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
detailCache.set(cacheKey, { data: detail, expiresAt: Date.now() + cacheTTL });
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChapters(sourceId: string, tocHref: string): Promise<BookChapter[]> {
|
||||||
|
const source = await getSourceById(sourceId);
|
||||||
|
const rule = getRule(source);
|
||||||
|
if (!rule.ruleToc?.chapterList) throw new Error('该 Legado 书源缺少目录规则');
|
||||||
|
const cacheKey = `${LEGADO_CACHE_VERSION}|toc|${source.id}|${tocHref}`;
|
||||||
|
const { cacheTTL } = await resolveLegadoConfig();
|
||||||
|
const cached = tocCache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||||
|
|
||||||
|
const targetUrl = normalizeUrl(sourceBase(source), tocHref);
|
||||||
|
const html = await fetchText(source, targetUrl);
|
||||||
|
const chapters: BookChapter[] = [];
|
||||||
|
const json = parseJsonMaybe(html);
|
||||||
|
if (json && ruleIsJson(rule.ruleToc.chapterList)) {
|
||||||
|
const items = selectJsonItems(json, rule.ruleToc.chapterList);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const title = readJsonRule(item, rule.ruleToc?.chapterName, source, targetUrl) || `第 ${index + 1} 章`;
|
||||||
|
const href = readJsonRule(item, rule.ruleToc?.chapterUrl, source, targetUrl)
|
||||||
|
|| fallbackChapterHrefFromItem(item, rule.ruleToc?.chapterUrl, targetUrl);
|
||||||
|
if (!href) return;
|
||||||
|
const normalizedHref = normalizeUrl(targetUrl, href);
|
||||||
|
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: index });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const items = selectElements($, $.root(), rule.ruleToc.chapterList);
|
||||||
|
items.each((index, element) => {
|
||||||
|
const root = $(element);
|
||||||
|
const title = readValue($, root, rule.ruleToc?.chapterName, targetUrl) || `第 ${index + 1} 章`;
|
||||||
|
const href = readValue($, root, rule.ruleToc?.chapterUrl, targetUrl) || root.attr('href') || '';
|
||||||
|
if (!href) return;
|
||||||
|
const normalizedHref = normalizeUrl(targetUrl, href);
|
||||||
|
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: index });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tocCache.set(cacheKey, { data: chapters, expiresAt: Date.now() + cacheTTL });
|
||||||
|
return chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChapterContent(sourceId: string, chapterHref: string, tocHref?: string): Promise<BookChapterContent> {
|
||||||
|
const source = await getSourceById(sourceId);
|
||||||
|
const rule = getRule(source);
|
||||||
|
if (!rule.ruleContent?.content) throw new Error('该 Legado 书源缺少正文规则');
|
||||||
|
const targetUrl = normalizeUrl(sourceBase(source), chapterHref);
|
||||||
|
const cacheKey = `${LEGADO_CACHE_VERSION}|chapter|${source.id}|${targetUrl}`;
|
||||||
|
const cached = chapterCache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||||
|
|
||||||
|
const html = await fetchText(source, targetUrl);
|
||||||
|
const rawContent = contentFromRule(html, rule.ruleContent.content, targetUrl);
|
||||||
|
const chapters = tocHref ? await this.getChapters(sourceId, tocHref).catch(() => []) : [];
|
||||||
|
const index = chapters.findIndex((item) => item.href === targetUrl || item.href === chapterHref);
|
||||||
|
const content: BookChapterContent = {
|
||||||
|
id: stableId(`${source.id}|${targetUrl}`),
|
||||||
|
title: index >= 0 ? chapters[index].title : '',
|
||||||
|
href: targetUrl,
|
||||||
|
content: cleanContent(rawContent),
|
||||||
|
previousHref: index > 0 ? chapters[index - 1].href : undefined,
|
||||||
|
nextHref: index >= 0 && index + 1 < chapters.length ? chapters[index + 1].href : undefined,
|
||||||
|
};
|
||||||
|
chapterCache.set(cacheKey, { data: content, expiresAt: Date.now() + 24 * 60 * 60 * 1000 });
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceById(sourceId: string): Promise<BookSource> {
|
||||||
|
return getSourceById(sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
|
||||||
|
return {
|
||||||
|
searchSupported: !!source.legado?.searchUrl,
|
||||||
|
catalogSupported: false,
|
||||||
|
searchMode: source.legado?.searchUrl ? 'legado' : 'disabled',
|
||||||
|
catalogMode: 'legado',
|
||||||
|
acquisitionTypes: ['application/x-legado-chapters+json'],
|
||||||
|
lastCheckedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const legadoClient = new LegadoClient();
|
||||||
@@ -234,7 +234,7 @@ async function resolveOPDSConfig(): Promise<ResolvedOPDSConfig> {
|
|||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
cacheTTL,
|
cacheTTL,
|
||||||
sources: (sources || []).filter((source) => !!source?.url && source.enabled !== false),
|
sources: (sources || []).filter((source) => !!source?.url && source.enabled !== false && (source.type || 'opds') === 'opds' && !source.legado),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user