/* 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'; import { legadoSubscriptionStore } from './legado/subscription-store'; 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 DEFAULT_LEGADO_SEARCH_PAGES = Number(process.env.LEGADO_SEARCH_PAGES || 5); const textCache = new Map(); const searchCache = new Map(); const detailCache = new Map(); const tocCache = new Map(); const chapterCache = new Map(); function stableId(input: string) { return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16); } function asObjectHeader(value?: string | Record): Record { 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>((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 = { '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 ? `` : '') .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 splitRuleFilters(rule: string) { const parts = (rule || '').split('##'); return { base: (parts.shift() || '').trim(), filters: parts }; } function applyRuleFilters(value: string, filters: string[]) { let result = value; for (let index = 0; index < filters.length; index += 2) { const pattern = filters[index]; const replacement = filters[index + 1] ?? ''; if (!pattern) continue; try { result = result.replace(new RegExp(pattern, 'g'), replacement); } catch { result = result.split(pattern).join(replacement); } } return result; } function isLegadoAttrToken(value: string) { return /^(href|src|title|alt|text|textNodes|html|content|value|data-[\w-]+)$/i.test(value.trim()); } function normalizeLegadoSelector(selector: string) { const trimmed = selector.trim(); if (!trimmed) return ''; const classMatch = trimmed.match(/^class\.([\w-]+(?:\.[\w-]+)*)$/i); if (classMatch) return classMatch[1].split('.').map((item) => `.${item}`).join(''); const idMatch = trimmed.match(/^id\.([\w-]+)$/i); if (idMatch) return `#${idMatch[1]}`; const tagMatch = trimmed.match(/^tag\.([\w-]+)$/i); if (tagMatch) return tagMatch[1]; return trimmed; } function parseStep(step: string): { selector: string; attr: string } { const trimmed = step.trim(); if (isLegadoAttrToken(trimmed)) return { selector: '', attr: trimmed }; const parts = trimmed.split('@').map((item) => item.trim()).filter(Boolean); if (parts.length > 1) { const last = parts[parts.length - 1]; const hasAttr = isLegadoAttrToken(last); const selectorParts = hasAttr ? parts.slice(0, -1) : parts; return { selector: selectorParts.map(normalizeLegadoSelector).filter(Boolean).join(' '), attr: hasAttr ? last : '', }; } const attrMatch = trimmed.match(/(?:@|::)(text|textNodes|html|href|src|title|alt|content|value|data-[\w-]+)$/i); if (attrMatch) { return { selector: normalizeLegadoSelector(trimmed.slice(0, attrMatch.index).trim()), attr: attrMatch[1] }; } const dotAttr = trimmed.match(/\.(text|html|href|src)$/i); if (dotAttr) return { selector: normalizeLegadoSelector(trimmed.slice(0, dotAttr.index).trim()), attr: dotAttr[1] }; return { selector: normalizeLegadoSelector(trimmed), attr: '' }; } function stripFilters(rule: string) { return splitRuleFilters(rule).base; } function selectElements($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: string): cheerio.Cheerio { 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, 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); value = applyRuleFilters(value, splitRuleFilters(alternative).filters); if (value) return value; } return ''; } function readValues($: cheerio.CheerioAPI, root: cheerio.Cheerio, 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 normalizedAttr = attr.toLowerCase(); const values = current.toArray().map((element) => { const node = $(element); let value = ''; 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' || normalizedAttr === 'data-original') && value && baseUrl) value = normalizeUrl(baseUrl, value); return value; }).filter(Boolean); if (values.length > 0) return values; } return []; } function applyContentJsRule(value: string, jsRule: string): string { if (/result\.split\(["']\\n["']\)| item.trim()) .filter(Boolean) .map((src) => ``) .join(''); } return value; } 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 rawRule = rule || ''; const jsIndex = rawRule.indexOf('@js:'); const selectorRule = jsIndex >= 0 ? rawRule.slice(0, jsIndex).trim() : rawRule; const jsRule = jsIndex >= 0 ? rawRule.slice(jsIndex + 4).trim() : ''; const $ = cheerio.load(raw); if (jsRule) { const values = readValues($, $.root(), selectorRule, baseUrl); const value = values.length > 0 ? values.join('\n') : readValue($, $.root(), selectorRule, baseUrl); return applyContentJsRule(value, jsRule); } return readValue($, $.root(), selectorRule, baseUrl); } function cleanContent(value: string) { const decoded = he.decode(value || '').trim(); if (//gi, '\n') .replace(/<\/p>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/\r/g, '') .split('\n') .map((line) => line.trim()) .filter(Boolean) .join('\n\n'); } function proxyChapterImages(content: string, source: BookSource) { if (!/]*?)\bsrc=(['"])(.*?)\2([^>]*)>/gi, (match, before, quote, src, after) => { if (!src || src.startsWith('/api/books/image')) return match; const proxied = `/api/books/image?sourceId=${encodeURIComponent(source.id)}&url=${encodeURIComponent(src)}`; return ``; }); } async function resolveLegadoConfig(): Promise { 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; const subscriptionSources = await legadoSubscriptionStore.getSourcesForSubscriptions(config.OPDSConfig.LegadoSubscriptions || []); sources = [...sources, ...subscriptionSources]; } } 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; } function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchText(source: BookSource, url: string): Promise { if (!url?.trim()) throw new Error('书源请求地址为空'); const safe = await validateProxyUrlServerSide(url); if (!safe) throw new Error(`书源地址未通过安全校验: ${url}`); 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; let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { 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 buffer = await response.arrayBuffer(); const contentType = response.headers.get('content-type') || ''; const charset = contentType.match(/charset=([^;]+)/i)?.[1]?.trim().replace(/^['"]|['"]$/g, ''); const decoderName = /gbk|gb2312|gb18030/i.test(charset || '') ? 'gb18030' : (charset || 'utf-8'); let text: string; try { text = new TextDecoder(decoderName).decode(buffer); } catch { text = new TextDecoder('utf-8').decode(buffer); } if (text.length > MAX_TEXT_BYTES) throw new Error('响应内容过大'); textCache.set(cacheKey, { data: text, expiresAt: Date.now() + cacheTTL }); return text; } catch (error) { lastError = error; if (attempt < 2) await wait(300 * (attempt + 1)); } finally { clearTimeout(timeout); } } throw lastError instanceof Error ? lastError : new Error('请求失败'); } async function getSourceById(sourceId: string): Promise { 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 & { 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 || [], }; } interface ExploreTarget { title: string; template: string; page: number; } function hasExplore(rule: LegadoBookSourceRule) { return rule.enabledExplore !== false && !!rule.exploreUrl && !!rule.ruleExplore?.bookList; } function parseExploreUrl(exploreUrl?: string): Array<{ title: string; template: string }> { const raw = (exploreUrl || '').trim(); if (!raw) return []; const json = parseJsonMaybe(raw); if (Array.isArray(json)) { return json .map((item) => ({ title: jsonPrimitiveToString(item?.title), template: jsonPrimitiveToString(item?.url) })) .filter((item) => !!item.title && !!item.template); } return raw .split('&&') .map((item) => item.trim()) .filter(Boolean) .map((item) => { const index = item.indexOf('::'); if (index >= 0) return { title: item.slice(0, index).trim(), template: item.slice(index + 2).trim() }; return { title: item, template: item }; }) .filter((item) => !!item.template); } function encodeExploreTarget(target: ExploreTarget) { return `legado-explore:${Buffer.from(JSON.stringify(target), 'utf8').toString('base64url')}`; } function decodeExploreTarget(href?: string): ExploreTarget | null { if (!href?.startsWith('legado-explore:')) return null; try { const raw = Buffer.from(href.slice('legado-explore:'.length), 'base64url').toString('utf8'); const parsed = JSON.parse(raw); if (!parsed?.template) return null; return { title: parsed.title || '分类', template: parsed.template, page: Number(parsed.page || 1) || 1 }; } catch { return null; } } function buildExploreTargetUrl(source: BookSource, target: ExploreTarget) { return buildUrlFromTemplate(target.template, source, undefined, target.page); } export class LegadoClient { async getSources(): Promise { const config = await resolveLegadoConfig(); if (!config.enabled) return []; return config.sources.map((source) => ({ ...source, capabilities: { searchSupported: !!source.legado?.searchUrl, catalogSupported: hasExplore(source.legado || {}), searchMode: source.legado?.searchUrl ? 'legado' : 'disabled', catalogMode: hasExplore(source.legado || {}) ? 'legado' : 'disabled', acquisitionTypes: ['application/x-legado-chapters+json'], lastCheckedAt: Date.now(), }, })); } async getSearchSources(sourceId?: string): Promise { 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 results: BookListItem[] = []; const seen = new Set(); for (let page = 1; page <= DEFAULT_LEGADO_SEARCH_PAGES; page += 1) { const targetUrl = buildUrlFromTemplate(rule.searchUrl, source, q, page); const html = await fetchText(source, targetUrl); let pageCount = 0; const json = parseJsonMaybe(html); if (json && ruleIsJson(rule.ruleSearch.bookList)) { const items = selectJsonItems(json, rule.ruleSearch.bookList); pageCount = items.length; 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); const itemId = jsonPrimitiveToString(item?.id) || undefined; const dedupeKey = itemId || detailHref || `${title}|${cover}`; if (dedupeKey && seen.has(dedupeKey)) return; if (dedupeKey) seen.add(dedupeKey); results.push(makeItem(source, { id: itemId, 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); pageCount = items.length; 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); const dedupeKey = detailHref || `${title}|${cover}`; 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), cover: cover || undefined, detailHref, tags: readValue($, root, rule.ruleSearch?.kind, targetUrl).split(/[,,\s]+/).filter(Boolean), })); }); } if (pageCount === 0) break; } searchCache.set(cacheKey, { data: results, expiresAt: Date.now() + cacheTTL }); return { source, results }; } async searchBooks(q: string, sourceId?: string): Promise { 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 { const source = await getSourceById(sourceId); const rule = getRule(source); if (!hasExplore(rule)) { return { sourceId: source.id, sourceName: source.name, title: source.name, href: href || source.url, entries: [], navigation: [] }; } const categories = parseExploreUrl(rule.exploreUrl); const target = decodeExploreTarget(href) || null; const navigation = categories.map((item) => ({ title: item.title, href: encodeExploreTarget({ title: item.title, template: item.template, page: 1 }), rel: 'legado:explore', type: 'application/x-legado-explore', })); if (!target) { return { sourceId: source.id, sourceName: source.name, title: source.name, subtitle: '请选择分类', href: href || source.url, entries: [], navigation }; } const targetUrl = buildExploreTargetUrl(source, target); const html = await fetchText(source, targetUrl); const exploreRule = rule.ruleExplore || rule.ruleSearch; const entries: BookListItem[] = []; let pageCount = 0; const json = parseJsonMaybe(html); if (json && ruleIsJson(exploreRule?.bookList)) { const items = selectJsonItems(json, exploreRule?.bookList); pageCount = items.length; items.forEach((item) => { const detailHref = readJsonRule(item, exploreRule?.bookUrl, source, targetUrl); const title = readJsonRule(item, exploreRule?.name, source, targetUrl); if (!title && !detailHref) return; const cover = readJsonRule(item, exploreRule?.coverUrl, source, targetUrl); entries.push(makeItem(source, { id: jsonPrimitiveToString(item?.id) || detailHref || undefined, title, author: readJsonRule(item, exploreRule?.author, source, targetUrl), summary: readJsonRule(item, exploreRule?.intro, source, targetUrl), cover: cover || undefined, detailHref, tags: readJsonRule(item, exploreRule?.kind, source, targetUrl).split(/[,,\s]+/).filter(Boolean), })); }); } else { const $ = cheerio.load(html); const items = selectElements($, $.root(), exploreRule?.bookList); pageCount = items.length; items.each((_, element) => { const root = $(element); const detailHref = readValue($, root, exploreRule?.bookUrl, targetUrl); const title = readValue($, root, exploreRule?.name, targetUrl); if (!title && !detailHref) return; const cover = readValue($, root, exploreRule?.coverUrl, targetUrl); entries.push(makeItem(source, { id: detailHref || undefined, title, author: readValue($, root, exploreRule?.author, targetUrl), summary: readValue($, root, exploreRule?.intro, targetUrl), cover: cover || undefined, detailHref, tags: readValue($, root, exploreRule?.kind, targetUrl).split(/[,,\s]+/).filter(Boolean), })); }); } return { sourceId: source.id, sourceName: source.name, title: target.title || source.name, subtitle: `第 ${target.page} 页`, href: href || encodeExploreTarget(target), entries, navigation, nextHref: pageCount > 0 ? encodeExploreTarget({ ...target, page: target.page + 1 }) : undefined, previousHref: target.page > 1 ? encodeExploreTarget({ ...target, page: target.page - 1 }) : undefined, }; } async getChaptersByBookId(sourceId: string, bookId: string): Promise { const source = await getSourceById(sourceId); const rule = getRule(source); const base = sourceBase(source); 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 []; return this.getChapters(sourceId, tocHref); } async getBookDetail(sourceId: string, href: string, fallback?: Partial): Promise { 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, 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 { 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 { 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; let pageUrl = targetUrl; const parts: string[] = []; const visited = new Set(); for (let page = 0; page < 8 && pageUrl && !visited.has(pageUrl); page += 1) { visited.add(pageUrl); const html = await fetchText(source, pageUrl); const part = contentFromRule(html, rule.ruleContent.content, pageUrl); if (part) parts.push(part); const next = rule.ruleContent.nextContentUrl ? contentFromRule(html, rule.ruleContent.nextContentUrl, pageUrl) : ''; const normalizedNext = next ? normalizeUrl(pageUrl, next) : ''; if (!normalizedNext || normalizedNext === pageUrl || visited.has(normalizedNext)) break; pageUrl = normalizedNext; } const rawContent = parts.join('\\n\\n'); 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: proxyChapterImages(cleanContent(rawContent), source), 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 { return getSourceById(sourceId); } async detectCapabilitiesFromSource(source: BookSource): Promise { return { searchSupported: !!source.legado?.searchUrl, catalogSupported: hasExplore(source.legado || {}), searchMode: source.legado?.searchUrl ? 'legado' : 'disabled', catalogMode: hasExplore(source.legado || {}) ? 'legado' : 'disabled', acquisitionTypes: ['application/x-legado-chapters+json'], lastCheckedAt: Date.now(), }; } } export const legadoClient = new LegadoClient();