订阅式legado

This commit is contained in:
mtvpls
2026-05-19 21:03:11 +08:00
parent da1be9a07c
commit 79fe582f17
8 changed files with 550 additions and 1071 deletions
+11 -2
View File
@@ -287,7 +287,7 @@ export interface AdminConfig {
Sources?: Array<{
id: string;
name: string;
type?: 'opds' | 'legado';
type?: 'opds';
url: string;
enabled?: boolean;
authMode?: 'none' | 'basic' | 'header';
@@ -298,7 +298,16 @@ export interface AdminConfig {
searchTemplate?: string;
preferFormat?: Array<'epub' | 'pdf'>;
language?: string;
legado?: import('./book.types').LegadoBookSourceRule;
}>;
LegadoSubscriptions?: Array<{
id: string;
name: string;
url: string;
enabled?: boolean;
sourceCount?: number;
lastSyncAt?: number;
lastSuccessAt?: number;
lastError?: string;
}>;
CacheTTL?: number;
};
+7
View File
@@ -715,6 +715,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
adminConfig.OPDSConfig.Sources = [];
}
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter((source: any) => (source?.type || 'opds') === 'opds').map((source: any) => {
const { legado: _legado, ...rest } = source || {};
return { ...rest, type: 'opds' };
});
if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) {
adminConfig.OPDSConfig.LegadoSubscriptions = [];
}
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
}
+16 -13
View File
@@ -18,6 +18,7 @@ import {
LegadoBookSourceRule,
} from './book.types';
import { validateProxyUrlServerSide } from './server/ssrf';
import { legadoSubscriptionStore } from './legado/subscription-store';
interface ResolvedLegadoConfig {
enabled: boolean;
@@ -316,11 +317,8 @@ async function resolveLegadoConfig(): Promise<ResolvedLegadoConfig> {
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);
}
const subscriptionSources = await legadoSubscriptionStore.getSourcesForSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
sources = [...sources, ...subscriptionSources];
}
} catch {}
@@ -378,8 +376,9 @@ function wait(ms: number) {
}
async function fetchText(source: BookSource, url: string): Promise<string> {
if (!url?.trim()) throw new Error('书源请求地址为空');
const safe = await validateProxyUrlServerSide(url);
if (!safe) throw new Error('书源地址未通过安全校验');
if (!safe) throw new Error(`书源地址未通过安全校验: ${url}`);
const cacheKey = `${LEGADO_CACHE_VERSION}|text|${source.id}|${url}`;
const cached = textCache.get(cacheKey);
const { cacheTTL } = await resolveLegadoConfig();
@@ -508,6 +507,7 @@ export class LegadoClient {
if (dedupeKey && seen.has(dedupeKey)) return;
if (dedupeKey) seen.add(dedupeKey);
results.push(makeItem(source, {
id: detailHref || undefined,
title,
author: readValue($, root, rule.ruleSearch?.author, targetUrl),
summary: readValue($, root, rule.ruleSearch?.intro, targetUrl),
@@ -554,13 +554,16 @@ export class LegadoClient {
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 searchBookUrlRule = rule.ruleSearch?.bookUrl || '';
const detailHref = /^https?:\/\//i.test(bookId) || bookId.startsWith('/')
? normalizeUrl(base, bookId)
: /\{\{\s*(?:\$\.id|id)\s*\}\}|\{id\}/.test(searchBookUrlRule)
? normalizeUrl(base, searchBookUrlRule
.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 [];
+211
View File
@@ -0,0 +1,211 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import crypto from 'crypto';
import type { AdminConfig } from '@/lib/admin.types';
import type { BookSource, LegadoBookSourceRule } from '@/lib/book.types';
import { db } from '@/lib/db';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export interface LegadoSubscriptionMeta {
id: string;
name: string;
url: string;
enabled?: boolean;
sourceCount?: number;
lastSyncAt?: number;
lastSuccessAt?: number;
lastError?: string;
}
interface StoredManifest {
id: string;
name: string;
url: string;
hash: string;
sourceCount: number;
chunkCount: number;
updatedAt: number;
etag?: string;
lastModified?: string;
}
const CHUNK_SIZE = Number(process.env.LEGADO_SUBSCRIPTION_CHUNK_SIZE || 100);
const TIMEOUT_MS = Number(process.env.LEGADO_SUBSCRIPTION_TIMEOUT_MS || process.env.LEGADO_TIMEOUT_MS || 30000);
const MAX_BYTES = Number(process.env.LEGADO_SUBSCRIPTION_MAX_BYTES || 20 * 1024 * 1024);
function stableId(input: string) {
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
}
function subscriptionId(url: string, name?: string) {
return `legado_sub_${stableId(`${name || ''}|${url}`)}`;
}
function manifestKey(id: string) {
return `legado:subscription:${id}:manifest`;
}
function chunkKey(id: string, index: number) {
return `legado:subscription:${id}:chunk:${index}`;
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchTextWithRetry(url: string, retries = 2): Promise<{ text: string; etag?: string; lastModified?: string }> {
const safe = await validateProxyUrlServerSide(url);
if (!safe) throw new Error('订阅地址未通过安全校验');
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
Accept: 'application/json,text/plain,*/*',
},
});
if (!response.ok) throw new Error(`订阅请求失败: ${response.status}`);
const contentLength = Number(response.headers.get('content-length') || '0');
if (contentLength > MAX_BYTES) throw new Error('订阅内容过大');
const text = await response.text();
if (text.length > MAX_BYTES) throw new Error('订阅内容过大');
return {
text,
etag: response.headers.get('etag') || undefined,
lastModified: response.headers.get('last-modified') || undefined,
};
} catch (error) {
lastError = error;
if (attempt < retries) await wait(300 * (attempt + 1));
} finally {
clearTimeout(timeout);
}
}
throw lastError instanceof Error ? lastError : new Error('订阅请求失败');
}
function extractRuleList(input: any): LegadoBookSourceRule[] {
if (Array.isArray(input)) return input.filter((item) => item && typeof item === 'object');
if (!input || typeof input !== 'object') return [];
for (const key of ['data', 'sources', 'bookSources', 'items', 'list']) {
if (Array.isArray(input[key])) return input[key].filter((item: any) => item && typeof item === 'object');
}
return [input];
}
function normalizeRule(rule: LegadoBookSourceRule, subId: string, index: number): BookSource | null {
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
const url = rule.bookSourceUrl || '';
if (!url) return null;
return {
id: `legado_${stableId(`${subId}|${name}|${url}|${index}`)}`,
name,
type: 'legado',
url,
enabled: rule.enabled !== false,
authMode: 'none',
username: '',
password: '',
headerName: '',
headerValue: '',
searchTemplate: '',
preferFormat: ['epub'],
language: '',
legado: rule,
};
}
async function readManifest(id: string): Promise<StoredManifest | null> {
const raw = await db.getGlobalValue(manifestKey(id));
if (!raw) return null;
try {
return JSON.parse(raw) as StoredManifest;
} catch {
return null;
}
}
export const legadoSubscriptionStore = {
makeId: subscriptionId,
async sync(input: { id?: string; name?: string; url: string }): Promise<LegadoSubscriptionMeta> {
const url = input.url.trim();
if (!url) throw new Error('订阅 URL 不能为空');
const id = input.id || subscriptionId(url, input.name);
const name = input.name?.trim() || 'Legado 订阅';
const previous = await readManifest(id);
const { text, etag, lastModified } = await fetchTextWithRetry(url);
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('订阅内容不是合法 JSON');
}
const rules = extractRuleList(parsed);
const sources = rules.map((rule, index) => normalizeRule(rule, id, index)).filter((item): item is BookSource => !!item);
if (sources.length === 0) throw new Error('订阅内没有识别到有效 Legado 书源');
const chunkCount = Math.ceil(sources.length / CHUNK_SIZE);
const hash = crypto.createHash('sha1').update(JSON.stringify(sources)).digest('hex');
for (let index = 0; index < chunkCount; index += 1) {
await db.setGlobalValue(chunkKey(id, index), JSON.stringify(sources.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)));
}
if (previous && previous.chunkCount > chunkCount) {
for (let index = chunkCount; index < previous.chunkCount; index += 1) {
await db.deleteGlobalValue(chunkKey(id, index));
}
}
const manifest: StoredManifest = { id, name, url, hash, sourceCount: sources.length, chunkCount, updatedAt: Date.now(), etag, lastModified };
await db.setGlobalValue(manifestKey(id), JSON.stringify(manifest));
return { id, name, url, enabled: true, sourceCount: sources.length, lastSyncAt: manifest.updatedAt, lastSuccessAt: manifest.updatedAt, lastError: '' };
},
async getSources(id: string): Promise<BookSource[]> {
const manifest = await readManifest(id);
if (!manifest) return [];
const chunks = await Promise.all(
Array.from({ length: manifest.chunkCount }, async (_, index) => {
const raw = await db.getGlobalValue(chunkKey(id, index));
if (!raw) return [] as BookSource[];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as BookSource[]) : [];
} catch {
return [] as BookSource[];
}
})
);
return chunks.flat();
},
async getSourcesForSubscriptions(subscriptions: LegadoSubscriptionMeta[] = []): Promise<BookSource[]> {
const enabled = subscriptions.filter((item) => item.enabled !== false);
const groups = await Promise.all(enabled.map((item) => this.getSources(item.id)));
return groups.flat().filter((source) => source.enabled !== false);
},
async delete(id: string): Promise<void> {
const manifest = await readManifest(id);
if (manifest) {
for (let index = 0; index < manifest.chunkCount; index += 1) {
await db.deleteGlobalValue(chunkKey(id, index));
}
}
await db.deleteGlobalValue(manifestKey(id));
},
mergeMeta(config: AdminConfig, meta: LegadoSubscriptionMeta): AdminConfig {
const opds = config.OPDSConfig || { Enabled: false, Sources: [], CacheTTL: 10 * 60 * 1000 };
const list = opds.LegadoSubscriptions || [];
const next = list.some((item) => item.id === meta.id)
? list.map((item) => item.id === meta.id ? { ...item, ...meta } : item)
: [...list, meta];
return { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: next } };
},
};