Files
MoonTVPlus/src/lib/legado.client.ts
T
2026-05-20 01:40:42 +08:00

1520 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as cheerio from 'cheerio/slim';
import crypto from 'crypto';
import he from 'he';
import vm from 'vm';
import { DOMParser } from '@xmldom/xmldom';
import xpath from 'xpath';
import { getConfig } from './config';
import {
BookAcquisitionLink,
BookCatalogResult,
BookChapter,
BookChapterContent,
BookDetail,
BookListItem,
BookSearchFailure,
BookSearchResult,
BookSource,
BookSourceCapabilities,
LegadoBookSourceRule,
LegadoRuleSearch,
} 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 DEFAULT_LEGADO_SEARCH_PAGES = Number(process.env.LEGADO_SEARCH_PAGES || 5);
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 }>();
interface RequestOptions {
url: string;
method?: string;
body?: string;
headers?: Record<string, string>;
charset?: string;
retry?: number;
}
const cookieJar = new Map<string, string>();
const variableStore = new Map<string, any>();
const imageMemoryCache = new Map<string, { expiresAt: number; contentType: string; data: Uint8Array }>();
function stableId(input: string) {
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
}
function isSafeHeaderName(name: string) {
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
}
function asObjectHeader(value?: string | Record<string, string>, context?: Record<string, any>): Record<string, string> {
if (!value) return {};
if (typeof value === 'object') return value;
let raw = value.trim();
if (raw.startsWith('@js:')) {
raw = runJsSnippet(raw, context || {});
}
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return raw.split('\n').reduce<Record<string, string>>((headers, line) => {
const index = line.indexOf(':');
if (index > 0) {
const name = line.slice(0, index).trim();
if (isSafeHeaderName(name)) headers[name] = line.slice(index + 1).trim();
}
return headers;
}, {});
}
}
function buildHeaders(source: BookSource): HeadersInit {
const rule = source.legado;
const baseUrl = sourceBase(source);
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, { baseUrl, source: rule }),
};
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 encryptTongrenKeyword(plainText: string) {
const passphrase = 'zc89s30ipHG2Dw';
const key = Buffer.alloc(32);
const iv = Buffer.alloc(16);
Buffer.from(passphrase).copy(key);
Buffer.from(passphrase).copy(iv);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
return encodeURIComponent(Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]).toString('base64'));
}
function decryptTongrenOpenUrl(encryptedBase64: string, num: string, source?: LegadoBookSourceRule) {
try {
const numStr = Buffer.from(String(num || ''), 'base64').toString('utf8');
const userAgent = (asObjectHeader(source?.header)['User-Agent'] || 'Mozilla/5.0 (Linux; Android 9) Mobile Safari/537.36').toLowerCase();
const key = Buffer.from(crypto.createHash('md5').update(userAgent + numStr).digest('hex'), 'utf8');
const encryptedData = Buffer.from(String(encryptedBase64 || ''), 'base64');
const iv = encryptedData.subarray(0, 16);
const data = encryptedData.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
} catch {
return '';
}
}
function safeEvalTemplateExpression(expr: string, keyword: string, page: number) {
const key = keyword;
const searchTerms = keyword;
const java = {
base64Encode: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8').toString('base64'),
base64Decode: (value: unknown) => Buffer.from(String(value ?? ''), 'base64').toString('utf8'),
encodeURI: (value: unknown) => encodeURIComponent(String(value ?? '')),
encodeURIComponent: (value: unknown) => encodeURIComponent(String(value ?? '')),
};
try {
// eslint-disable-next-line no-new-func
const fn = new Function('key', 'keyword', 'searchTerms', 'page', 'java', `return (${expr});`);
return jsonPrimitiveToString(fn(key, keyword, searchTerms, page, java));
} catch {
return '';
}
}
function renderTemplateExpressions(raw: string, keyword = '', page = 1) {
return raw.replace(/<([^,<>]*),\s*\{\{(.*?)\}\}>/g, (_, prefix, expr) => {
const value = safeEvalTemplateExpression(String(expr).trim(), keyword, page);
return value ? `${prefix || ''}${value}` : '';
}).replace(/\{\{(.*?)\}\}/g, (_, expr) => {
const value = String(expr).trim();
if (/^(key|keyword|searchTerms)$/.test(value)) return encodeRuleParam(keyword || '');
if (/^(page|pageIndex)$/.test(value)) return String(page);
const evaluated = safeEvalTemplateExpression(value, keyword, page);
return encodeRuleParam(evaluated);
});
}
function runJsSnippet(code: string, context: Record<string, any>, timeout = 1000): string {
const java = {
base64Encode: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8').toString('base64'),
base64Decode: (value: unknown) => Buffer.from(String(value ?? ''), 'base64').toString('utf8'),
md5Encode: (value: unknown) => crypto.createHash('md5').update(String(value ?? '')).digest('hex'),
strToBytes: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8'),
base64DecodeToByteArray: (value: unknown) => Buffer.from(String(value ?? ''), 'base64'),
encodeURI: (value: unknown) => encodeURIComponent(String(value ?? '')),
encodeURIComponent: (value: unknown) => encodeURIComponent(String(value ?? '')),
put: (key: string, value: any) => { variableStore.set(key, value); return value; },
get: (key: string) => variableStore.get(key),
log: () => undefined,
htmlFormat: (value: unknown) => he.decode(String(value ?? '')).replace(/<br\s*\/?/gi, '\n').replace(/<[^>]+>/g, ''),
};
const cookie = {
removeCookie: () => undefined,
mapToCookie: (input: any) => typeof input === 'string' ? input : Array.isArray(input) ? input.join('; ') : '',
};
const sandbox: Record<string, any> = {
...context,
java,
cookie,
Buffer,
JSON,
String,
Number,
Math,
Array,
Object,
encodeURIComponent,
encryptText: encryptTongrenKeyword,
openUrl: (encryptedText: string, num: string) => decryptTongrenOpenUrl(encryptedText, num, context.source),
console: { log: () => undefined },
};
const body = code.replace(/^@js:/, '').trim();
try {
const script = new vm.Script(`(function(){ ${body}\n})()`);
const result = script.runInNewContext(sandbox, { timeout });
const value = jsonPrimitiveToString(result ?? sandbox.result ?? '');
if (value) return value;
} catch {
// 如果 @js 后面是单个表达式(例如 header: @js:JSON.stringify({...})),上面的函数体不会自动返回。
}
try {
const script = new vm.Script(`(function(){ return (${body}); })()`);
const result = script.runInNewContext(sandbox, { timeout });
return jsonPrimitiveToString(result ?? sandbox.result ?? '');
} catch {
return '';
}
}
function applyPutGetRules(rule: string, value: string) {
const putMatch = rule.match(/@put:\s*\{([\s\S]*?)\}/);
if (putMatch) {
try {
const obj = JSON.parse(`{${putMatch[1]}}`);
Object.entries(obj).forEach(([key, val]) => variableStore.set(key, val));
} catch {
const pair = putMatch[1].match(/([A-Za-z0-9_$-]+)\s*:\s*['"]?([^,'"]+)['"]?/);
if (pair) variableStore.set(pair[1], value || pair[2]);
}
}
const getMatch = rule.match(/@get:\s*\{\s*([A-Za-z0-9_$-]+)\s*\}/);
if (getMatch) return jsonPrimitiveToString(variableStore.get(getMatch[1]));
return value;
}
function buildUrlFromTemplate(template: string, source: BookSource, keyword?: string, page = 1, baseOverride?: string) {
const base = baseOverride || sourceBase(source);
let raw = template || base;
if (raw.trim().startsWith('@js:')) {
if (/\/k-\{\{encryptText\(key\)\}\}-\{\{page\}\}\.html/.test(raw)) {
return `https://www.rrssk.com/k-${encryptTongrenKeyword(keyword || '')}-${page}.html`;
}
const evaluated = runJsSnippet(raw, { key: keyword || '', keyword: keyword || '', page, baseUrl: base, source: { ...(source.legado || {}), key: base } });
raw = evaluated || raw;
if (/,(\s*)\{/.test(raw)) return raw;
}
raw = renderTemplateExpressions(raw, keyword || '', 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));
return normalizeUrl(base, raw);
}
function stripRuleJsBlocks(rule?: string) {
return (rule || '').replace(/<js>[\s\S]*?<\/js>/gi, '').trim();
}
function applyRuleJsBlocks(raw: string, rule?: string) {
const blocks = Array.from((rule || '').matchAll(/<js>([\s\S]*?)<\/js>/gi));
for (const block of blocks) {
runJsSnippet(block[1], { src: raw, result: raw });
}
}
function parseJsonMaybe(value: string): any | null {
try {
return JSON.parse(value.trim());
} 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 filterMatch = normalized.match(/^(.*)\[\?\(@\.([A-Za-z0-9_$-]+)\s*(==|=|!=)\s*['"]?([^'"\]]+)['"]?\)\](.*)$/);
if (filterMatch) {
const base = readJsonPath(input, filterMatch[1] || '$');
const list = Array.isArray(base) ? base : [];
const filtered = list.filter((item) => {
const actual = jsonPrimitiveToString(item?.[filterMatch[2]]);
return filterMatch[3] === '!=' ? actual !== filterMatch[4] : actual === filterMatch[4];
});
return filterMatch[5] ? readJsonPath(filtered, `$${filterMatch[5]}`) : filtered;
}
const recursive = normalized.match(/^\$\.\.([A-Za-z0-9_$-]+)(.*)$/);
if (recursive) {
const key = recursive[1];
const rest = recursive[2] || '';
const out: any[] = [];
const walk = (node: any) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) return node.forEach(walk);
if (node[key] !== undefined) out.push(node[key]);
Object.values(node).forEach(walk);
};
walk(input);
return rest ? readJsonPath(out.flat(), `$${rest}`) : out.flat();
}
normalized = normalized.replace(/^\$\.?/, '');
const tokens = normalized.match(/[^.[\]]+|\[\*\]|\[-?\d+\]|\[-?\d*:-?\d*(?::-?\d+)?\]|\[['"][^\]]+['"](?:,\s*['"][^\]]+['"])*\]/g) || [];
let current = input;
for (const token of tokens) {
if (current === undefined || current === null) return undefined;
if (token === '[*]') {
current = Array.isArray(current) ? current.flat() : [];
} else if (/^\[-?\d+\]$/.test(token)) {
const idx = Number(token.slice(1, -1));
current = Array.isArray(current) ? current[idx < 0 ? current.length + idx : idx] : undefined;
} else if (/^\[-?\d*:-?\d*/.test(token)) {
if (!Array.isArray(current)) return [];
const parts = token.slice(1, -1).split(':').map((item) => item === '' ? undefined : Number(item));
const start = parts[0] === undefined ? 0 : parts[0] < 0 ? current.length + parts[0] : parts[0];
const end = parts[1] === undefined ? current.length : parts[1] < 0 ? current.length + parts[1] : parts[1];
const step = parts[2] || 1;
const sliced = current.slice(start, end);
current = step === 1 ? sliced : sliced.filter((_, index) => index % Math.abs(step) === 0);
} else if (/^\[/.test(token)) {
const keys = Array.from(token.matchAll(/['"]([^'"]+)['"]/g)).map((m) => m[1]);
if (Array.isArray(current)) current = current.map((item) => keys.map((key) => item?.[key])).flat().filter((item) => item !== undefined);
else current = keys.map((key) => current?.[key]).filter((item) => item !== 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('');
}
const resultText = typeof json === 'string' ? json : JSON.stringify(json);
if (/java\.base64Decode/.test(trimmed)) return Buffer.from(resultText, 'base64').toString('utf8');
if (/java\.base64Encode/.test(trimmed)) return Buffer.from(resultText, 'utf8').toString('base64');
if (/java\.md5Encode/.test(trimmed)) return crypto.createHash('md5').update(resultText).digest('hex');
const replaceMatch = trimmed.match(/result\.replace\(\s*\/([^/]+)\/[gimuy]*\s*,\s*['"]([^'"]*)['"]\s*\)/);
if (replaceMatch) return resultText.replace(new RegExp(replaceMatch[1], 'g'), replaceMatch[2]);
const matchMatch = trimmed.match(/result\.match\(\s*\/([^/]+)\/[gimuy]*\s*\)/);
if (matchMatch) return resultText.match(new RegExp(matchMatch[1]))?.[1] || resultText.match(new RegExp(matchMatch[1]))?.[0] || '';
return runJsSnippet(trimmed, { result: json, baseUrl, src: json });
}
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.replace(/\$(\d+)/g, '$$$$1'));
} catch {
result = result.split(pattern).join(replacement);
}
}
return result;
}
function readAllInOneList(raw: string, rule?: string): Array<Record<string, string>> {
const trimmed = (rule || '').trim();
if (!trimmed.startsWith(':')) return [];
const pattern = trimmed.slice(1);
try {
const regex = new RegExp(pattern, 'gs');
const out: Array<Record<string, string>> = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
const groups = (match.groups || {}) as Record<string, string>;
const item: Record<string, string> = { _0: match[0] };
Array.from(match).forEach((value: string | undefined, index: number) => { item[`_${index}`] = value || ''; });
Object.entries(groups).forEach(([key, value]) => { item[key] = String(value || ''); });
out.push(item);
if (match[0] === '') regex.lastIndex += 1;
}
return out;
} catch {
return [];
}
}
function readRegexItem(item: Record<string, string>, rule?: string, baseUrl?: string) {
const key = (rule || '').trim().replace(/^\$?\{?/, '').replace(/\}?$/, '');
const value = item[key] || item[`_${key}`] || '';
if ((/url|href|src|cover/i.test(key) || /^https?:\/\//i.test(value)) && value && baseUrl) return normalizeUrl(baseUrl, value);
return value;
}
function isLegadoAttrToken(value: string) {
return /^(href|src|title|alt|text|textNodes|ownText|all|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|ownText|all|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 applyLegadoIndexSelector(current: cheerio.Cheerio<any>, selector: string): { current: cheerio.Cheerio<any>; selector: string } {
let normalized = selector.trim();
const exclude = normalized.match(/\[!(-?\d+)\]$/);
if (exclude) {
normalized = normalized.slice(0, exclude.index).trim();
current = normalized ? current.find(normalized) : current;
const idx = Number(exclude[1]);
const real = idx < 0 ? current.length + idx : idx;
return { current: current.filter((index) => index !== real), selector: '' };
}
const range = normalized.match(/(?:\[(-?\d*):(-?\d*)(?::(-?\d+))?\]|\.(-?\d*):(-?\d*))$/);
if (range) {
normalized = normalized.slice(0, range.index).trim();
current = normalized ? current.find(normalized) : current;
const length = current.length;
const startRaw = range[1] ?? range[4];
const endRaw = range[2] ?? range[5];
const start = startRaw ? Number(startRaw) : 0;
const end = endRaw ? Number(endRaw) : length;
const realStart = start < 0 ? length + start : start;
const realEnd = end < 0 ? length + end : end;
return { current: current.slice(realStart, realEnd), selector: '' };
}
const indexMatch = normalized.match(/(?:\[(-?\d+)\]|\.(-?\d+))$/);
if (indexMatch) {
normalized = normalized.slice(0, indexMatch.index).trim();
current = normalized ? current.find(normalized) : current;
const idx = Number(indexMatch[1] ?? indexMatch[2]);
const real = idx < 0 ? current.length + idx : idx;
return { current: current.eq(real), selector: '' };
}
return { current, selector: normalized };
}
function applyLegadoSelector($: cheerio.CheerioAPI, current: cheerio.Cheerio<any>, selector: string): cheerio.Cheerio<any> {
const normalized = selector.trim();
if (!normalized) return current;
const textMatch = normalized.match(/^text\.(.+)$/);
if (textMatch) {
const keyword = textMatch[1].trim();
const links = current.find('a[href]').filter((_, el) => $(el).text().includes(keyword));
if (links.length > 0) return links;
return current.find('button,span,div,p,li,a').filter((_, el) => $(el).text().includes(keyword));
}
const tokens = normalized.split(/\s+/).filter(Boolean);
if (
tokens.length > 1
&& tokens.every((token) => !/[>+~]/.test(token))
&& tokens.some((token) => /(?:\[!?\-?\d+\]|\[-?\d*:|-?\d+\]$|\.-?\d+(?::\-?\d*)?)$/.test(token))
) {
let next = current;
for (const token of tokens) {
const indexed = applyLegadoIndexSelector(next, token);
next = indexed.selector ? next.find(indexed.selector) : indexed.current;
}
return next;
}
const indexed = applyLegadoIndexSelector(current, normalized);
return indexed.selector ? current.find(indexed.selector) : indexed.current;
}
function selectElements($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string): cheerio.Cheerio<any> {
const normalized = stripFilters(rule || '');
const reverse = normalized.trim().startsWith('-') && !normalized.trim().startsWith('-@');
const effective = reverse ? normalized.trim().slice(1).trim() : normalized;
if (!effective) return root;
const steps = effective.split(/&&|@css:/).map((item) => item.trim()).filter(Boolean);
let current = root;
for (const rawStep of steps) {
const { selector, attr } = parseStep(rawStep);
if (attr) break;
if (!selector) continue;
if (/^children$/i.test(selector)) {
current = current.children();
continue;
}
current = applyLegadoSelector($, current, selector);
}
if (reverse) current = $(current.toArray().reverse());
return current;
}
function selectXPath($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule: string): { nodes: cheerio.Cheerio<any>; attr: string; value?: string } {
const expr = rule.trim().replace(/^@XPath:/i, '');
try {
const html = $.html(root);
const doc = new DOMParser({ errorHandler: () => undefined }).parseFromString(html, 'text/html');
const selected = xpath.select(expr, doc as any) as any;
const list = Array.isArray(selected) ? selected : [selected];
const values = list.map((node) => {
if (node === undefined || node === null) return '';
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') return String(node);
if (node.nodeType === 2) return node.nodeValue || '';
if (node.nodeType === 3 || node.nodeType === 4) return node.nodeValue || '';
return node.textContent || '';
}).filter(Boolean);
if (values.length > 0) return { nodes: root, attr: '', value: values.join('\n') };
} catch {
// fallback below
}
let fallbackExpr = expr;
let attr = '';
const attrMatch = fallbackExpr.match(/\/@([A-Za-z0-9_-]+)$/);
if (attrMatch) {
attr = attrMatch[1];
fallbackExpr = fallbackExpr.slice(0, attrMatch.index);
} else if (/\/text\(\)$/.test(fallbackExpr)) {
attr = 'text';
fallbackExpr = fallbackExpr.replace(/\/text\(\)$/, '');
}
const parts = fallbackExpr.split('/').filter(Boolean).map((part) => {
const match = part.match(/^([A-Za-z0-9_*.-]+)(?:\[@([A-Za-z0-9_-]+)=['"]([^'"]+)['"]\])?(?:\[(\d+)\])?$/);
if (!match) return '';
const tag = match[1] === '*' ? '*' : match[1];
const filter = match[2] ? `[${match[2]}="${match[3]}"]` : '';
const index = match[4] ? `:nth-of-type(${match[4]})` : '';
return `${tag}${filter}${index}`;
}).filter(Boolean);
return { nodes: parts.length ? root.find(parts.join(' ')) : root, attr };
}
function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string, jsContext?: Record<string, any>): string {
for (const alternative of splitAlternatives(rule)) {
const jsIndex = alternative.indexOf('@js:');
if (jsIndex > 0) {
const selectorRule = alternative.slice(0, jsIndex).trim();
const jsRule = alternative.slice(jsIndex).trim();
const selected = readValue($, root, selectorRule, baseUrl, jsContext);
const transformed = runJsSnippet(jsRule, { ...(jsContext || {}), result: selected, src: selected, baseUrl });
if (transformed) {
if (/,(\s*)\{/.test(transformed)) return transformed;
return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed;
}
if (selected) return selected;
continue;
}
const normalized = stripFilters(alternative);
const getMatch = normalized.match(/^@get:\s*\{\s*([A-Za-z0-9_$-]+)\s*\}$/);
if (getMatch) {
const value = jsonPrimitiveToString(variableStore.get(getMatch[1]));
if (value) return value;
continue;
}
if (/^@?XPath:/i.test(normalized) || normalized.startsWith('//')) {
const { nodes, attr, value: xpathValue } = selectXPath($, root, normalized);
const node = nodes.first();
let value = xpathValue !== undefined ? xpathValue : attr === 'text' || !attr ? node.text() : node.attr(attr) || '';
value = he.decode(value || '').replace(/\u00a0/g, ' ').trim();
value = applyRuleFilters(value, splitRuleFilters(alternative).filters);
if (value) return value;
continue;
}
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) {
if (/^children$/i.test(parsed.selector)) current = current.children();
else {
current = applyLegadoSelector($, current, 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 === 'owntext') value = node.clone().children().remove().end().text();
else if (normalizedAttr === 'all') value = current.toArray().map((el) => $(el).text()).join('\n');
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);
value = applyPutGetRules(alternative, value);
if (value) return value;
}
return '';
}
function readValues($: 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) {
if (/^children$/i.test(parsed.selector)) current = current.children();
else {
current = applyLegadoSelector($, current, 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 === 'owntext') value = node.clone().children().remove().end().text();
else if (normalizedAttr === 'all') 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 applyBookInfoInit($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, initRule?: string, baseUrl?: string) {
const putMatch = (initRule || '').match(/@put:\s*\{([\s\S]*)\}\s*$/);
if (!putMatch) return;
const body = putMatch[1];
const entryRegex = /([A-Za-z0-9_$-]+)\s*:\s*"((?:\\.|[^"\\])*)"/g;
let match: RegExpExecArray | null;
while ((match = entryRegex.exec(body)) !== null) {
const key = match[1];
const selector = match[2].replace(/\\"/g, '"').replace(/\\n/g, '\n');
variableStore.set(key, readValue($, root, selector, baseUrl));
}
}
function applyContentJsRule(value: string, jsRule: string): string {
if (/result\.split\(["']\\n["']\)|<img\s+src=/.test(jsRule)) {
return value
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.map((src) => `<img src="${src}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">`)
.join('');
}
return runJsSnippet(jsRule, { result: value, src: value }) || 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 (/<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');
}
function chapterPageStem(url: string) {
try {
const parsed = new URL(url);
parsed.hash = '';
parsed.search = '';
parsed.pathname = parsed.pathname.replace(/_\d+(?=\.html?$)/i, '');
return parsed.toString();
} catch {
return url.replace(/_\d+(?=\.html?(?:[?#]|$))/i, '');
}
}
function proxyChapterImages(content: string, source: BookSource) {
if (!/<img\b/i.test(content)) return content;
const rule = source.legado;
if (rule?.bookSourceType !== 2 && source.legado?.bookSourceType !== 2) return content;
return content.replace(/<img\b([^>]*?)\bsrc=(['"])(.*?)\2([^>]*)>/gi, (match, before, quote, rawSrc, after) => {
if (!rawSrc || rawSrc.startsWith('/api/books/image')) return match;
const optionIndex = rawSrc.indexOf(',{');
const src = optionIndex > 0 ? rawSrc.slice(0, optionIndex) : rawSrc;
const options = optionIndex > 0 ? rawSrc.slice(optionIndex + 1) : '';
const proxied = `/api/books/image?sourceId=${encodeURIComponent(source.id)}&url=${encodeURIComponent(src)}${options ? `&options=${encodeURIComponent(options)}` : ''}`;
return `<img${before}src=${quote}${proxied}${quote}${after}>`;
});
}
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;
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));
}
function splitUrlOptions(input: string): RequestOptions {
const raw = input.trim();
const comma = raw.indexOf(',');
if (comma <= 0) return { url: raw };
const candidate = raw.slice(comma + 1).trim();
if (!candidate.startsWith('{')) return { url: raw };
try {
const options = JSON.parse(candidate);
return {
url: raw.slice(0, comma).trim(),
method: options.method,
body: options.body,
headers: options.headers && typeof options.headers === 'object' ? options.headers : undefined,
charset: options.charset,
retry: Number.isFinite(Number(options.retry)) ? Number(options.retry) : undefined,
};
} catch {
return { url: raw };
}
}
function getCookieHeader(sourceId: string) {
return cookieJar.get(sourceId) || '';
}
function mergeSetCookie(sourceId: string, setCookie: string | null) {
if (!setCookie) return;
const current = new Map<string, string>();
(cookieJar.get(sourceId) || '').split(/;\s*/).filter(Boolean).forEach((item) => {
const idx = item.indexOf('=');
if (idx > 0) current.set(item.slice(0, idx), item.slice(idx + 1));
});
setCookie.split(/,(?=\s*[^;,]+=)/).forEach((cookie) => {
const pair = cookie.split(';')[0]?.trim();
const idx = pair?.indexOf('=') ?? -1;
if (idx > 0) current.set(pair.slice(0, idx), pair.slice(idx + 1));
});
cookieJar.set(sourceId, Array.from(current.entries()).map(([key, value]) => `${key}=${value}`).join('; '));
}
async function fetchText(source: BookSource, url: string): Promise<string> {
if (!url?.trim()) throw new Error('书源请求地址为空');
const request = splitUrlOptions(url);
const safe = await validateProxyUrlServerSide(request.url);
if (!safe) throw new Error(`书源地址未通过安全校验: ${request.url}`);
const cacheKey = `text|${source.id}|${request.method || 'GET'}|${request.url}|${request.body || ''}`;
const cached = textCache.get(cacheKey);
const { cacheTTL } = await resolveLegadoConfig();
if (cached && cached.expiresAt > Date.now()) return cached.data;
let lastError: unknown;
const maxAttempts = Math.max(1, (request.retry ?? 2) + 1);
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
...(buildHeaders(source) as Record<string, string>),
...(request.headers || {}),
};
const cookie = getCookieHeader(source.id);
if (source.legado?.enabledCookieJar && cookie) headers.Cookie = cookie;
const method = (request.method || (request.body ? 'POST' : 'GET')).toUpperCase();
const response = await fetch(request.url, {
method,
headers,
body: method === 'GET' || method === 'HEAD' ? undefined : request.body,
signal: controller.signal,
cache: 'no-store',
});
if (source.legado?.enabledCookieJar) mergeSetCookie(source.id, response.headers.get('set-cookie'));
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 = request.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 < maxAttempts - 1) await wait(300 * (attempt + 1));
} finally {
clearTimeout(timeout);
}
}
throw lastError instanceof Error ? lastError : new Error('请求失败');
}
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 || [],
};
}
interface ExploreTarget {
title: string;
template: string;
page: number;
}
function hasRuleBookList(rule?: LegadoRuleSearch) {
return !!rule?.bookList?.trim();
}
function getEffectiveExploreRule(rule: LegadoBookSourceRule): LegadoRuleSearch | undefined {
if (hasRuleBookList(rule.ruleExplore)) return rule.ruleExplore;
if (hasRuleBookList(rule.ruleSearch)) return rule.ruleSearch;
return undefined;
}
function hasExplore(rule: LegadoBookSourceRule) {
return rule.enabledExplore !== false && (!!rule.exploreUrl?.trim().startsWith('@js:') || parseExploreUrl(rule.exploreUrl).length > 0) && !!getEffectiveExploreRule(rule);
}
function parseExploreUrl(exploreUrl?: string): Array<{ title: string; template: string }> {
const raw = (exploreUrl || '').trim();
if (!raw) return [];
if (raw.startsWith('@js:')) return [];
const json = parseJsonMaybe(raw);
if (Array.isArray(json)) {
return json
.map((item) => ({ title: jsonPrimitiveToString(item?.title), template: jsonPrimitiveToString(item?.url) || `__group__:${jsonPrimitiveToString(item?.title)}` }))
.filter((item) => !!item.title);
}
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);
}
async function resolveExploreCategories(source: BookSource, rule: LegadoBookSourceRule): Promise<Array<{ title: string; template: string }>> {
const raw = (rule.exploreUrl || '').trim();
if (!raw.startsWith('@js:')) return parseExploreUrl(raw);
try {
const html = await fetchText(source, sourceBase(source));
const $ = cheerio.load(html);
const categories = $('a[href^="/list"]').toArray().map((el) => {
const title = $(el).text().trim();
const href = ($(el).attr('href') || '').replace(/\/$/, '-{{page}}/');
return title && href ? { title, template: href } : null;
}).filter(Boolean) as Array<{ title: string; template: string }>;
if (categories.length > 0) return [{ title: '全部分类', template: '__group__:全部分类' }, ...categories];
} catch {
// 动态分类首页被源站拦截时,至少不要把 @js 当成分类项显示。
}
return [];
}
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: 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<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 = `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<string>();
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);
applyRuleJsBlocks(html, rule.ruleSearch.bookList);
if (page === 1 && rule.ruleSearch.checkKeyWord && !html.includes(rule.ruleSearch.checkKeyWord) && !html.includes(q)) {
throw new Error('搜索结果校验失败');
}
let pageCount = 0;
const json = parseJsonMaybe(html);
const searchBookListRule = stripRuleJsBlocks(rule.ruleSearch.bookList);
if (json && ruleIsJson(searchBookListRule)) {
const items = selectJsonItems(json, searchBookListRule);
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 if (searchBookListRule.trim().startsWith(':')) {
const items = readAllInOneList(html, searchBookListRule);
pageCount = items.length;
items.forEach((item) => {
const detailHref = readRegexItem(item, rule.ruleSearch?.bookUrl, targetUrl);
const title = readRegexItem(item, rule.ruleSearch?.name, targetUrl);
if (!title && !detailHref) return;
const cover = readRegexItem(item, 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: readRegexItem(item, rule.ruleSearch?.author, targetUrl),
summary: readRegexItem(item, rule.ruleSearch?.intro, targetUrl),
cover: cover || undefined,
detailHref,
tags: readRegexItem(item, rule.ruleSearch?.kind, targetUrl).split(/[,\s]+/).filter(Boolean),
}));
});
} else {
const $ = cheerio.load(html);
const items = selectElements($, $.root(), searchBookListRule);
pageCount = items.length;
items.each((_, element) => {
const root = $(element);
const detailHref = readValue($, root, rule.ruleSearch?.bookUrl, targetUrl, { source: rule });
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<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);
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 = await resolveExploreCategories(source, rule);
const target = decodeExploreTarget(href) || null;
const navigation = categories.map((item) => ({
title: item.title,
href: item.template.startsWith('__group__:') ? '' : encodeExploreTarget({ title: item.title, template: item.template, page: 1 }),
rel: item.template.startsWith('__group__:') ? 'legado:group' : '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 };
}
if (target.template.startsWith('__group__:')) {
return { sourceId: source.id, sourceName: source.name, title: target.title, href: href || '', entries: [], navigation };
}
const targetUrl = buildExploreTargetUrl(source, target);
const html = await fetchText(source, targetUrl);
const exploreRule = getEffectiveExploreRule(rule);
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<BookChapter[]> {
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<BookDetail>): Promise<BookDetail> {
const source = await getSourceById(sourceId);
const rule = getRule(source);
const detailHref = href || fallback?.detailHref || '';
const cacheKey = `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 initData = rule.bookInfoInit?.trim().startsWith(':') ? readAllInOneList(html, rule.bookInfoInit)[0] : null;
const json = initData || parseJsonMaybe(html);
const $ = json && initData ? null : json ? null : cheerio.load(html);
const root = $?.root();
if ($ && root) applyBookInfoInit($, root, (rule.ruleBookInfo as any).init || rule.bookInfoInit, targetUrl);
const read = (itemRule?: string) => initData
? readRegexItem(initData, itemRule, targetUrl)
: 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 = `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 });
});
}
if (rule.ruleToc.nextTocUrl) {
let nextTocUrl = contentFromRule(html, rule.ruleToc.nextTocUrl, targetUrl);
const visited = new Set([targetUrl]);
for (let page = 0; page < 8 && nextTocUrl; page += 1) {
const normalizedNext = normalizeUrl(targetUrl, nextTocUrl);
if (!normalizedNext || visited.has(normalizedNext)) break;
visited.add(normalizedNext);
const nextHtml = await fetchText(source, normalizedNext);
const $next = cheerio.load(nextHtml);
const nextItems = selectElements($next, $next.root(), rule.ruleToc.chapterList);
nextItems.each((index, element) => {
const root = $next(element);
const title = readValue($next, root, rule.ruleToc?.chapterName, normalizedNext) || `第 ${chapters.length + index + 1} 章`;
const href = readValue($next, root, rule.ruleToc?.chapterUrl, normalizedNext) || root.attr('href') || '';
if (!href) return;
const normalizedHref = normalizeUrl(normalizedNext, href);
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: chapters.length });
});
nextTocUrl = contentFromRule(nextHtml, rule.ruleToc.nextTocUrl, normalizedNext);
}
}
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 = `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<string>();
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;
if (chapterPageStem(normalizedNext) !== chapterPageStem(targetUrl)) 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(applyRuleFilters(rawContent, [
...(rule.ruleContent.sourceRegex ? [rule.ruleContent.sourceRegex, ''] : []),
...((rule.ruleContent as any).replaceRegex ? splitRuleFilters((rule.ruleContent as any).replaceRegex).filters : []),
])), 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<BookSource> {
return getSourceById(sourceId);
}
async detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
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();