diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1ba211e..5ee781d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12396,7 +12396,7 @@ const OPDSConfigComponent = ({

关于电子书馆 / OPDS / Legado

• OPDS 源手动配置。

-

• Legado 通过订阅 URL 导入,完整书源内容独立存储,不写入 admin_config。

+

• Legado 通过订阅 URL 导入。

diff --git a/src/lib/book.types.ts b/src/lib/book.types.ts index 07d799d..d80e251 100644 --- a/src/lib/book.types.ts +++ b/src/lib/book.types.ts @@ -25,6 +25,7 @@ export interface LegadoRuleSearch { export type LegadoRuleExplore = LegadoRuleSearch; export interface LegadoRuleBookInfo { + init?: string; name?: string; author?: string; intro?: string; @@ -52,6 +53,7 @@ export interface LegadoRuleContent { nextContentUrl?: string; imageStyle?: string; sourceRegex?: string; + replaceRegex?: string; payAction?: string; } diff --git a/src/lib/legado.client.ts b/src/lib/legado.client.ts index 88308e0..f77158a 100644 --- a/src/lib/legado.client.ts +++ b/src/lib/legado.client.ts @@ -19,6 +19,7 @@ import { BookSource, BookSourceCapabilities, LegadoBookSourceRule, + LegadoRuleSearch, } from './book.types'; import { validateProxyUrlServerSide } from './server/ssrf'; import { legadoSubscriptionStore } from './legado/subscription-store'; @@ -31,7 +32,6 @@ interface ResolvedLegadoConfig { 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(); @@ -56,16 +56,27 @@ function stableId(input: string) { return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16); } -function asObjectHeader(value?: string | Record): Record { +function isSafeHeaderName(name: string) { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name); +} + +function asObjectHeader(value?: string | Record, context?: Record): Record { 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(value); + const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { - return value.split('\n').reduce>((headers, line) => { + return raw.split('\n').reduce>((headers, line) => { const index = line.indexOf(':'); - if (index > 0) headers[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + if (index > 0) { + const name = line.slice(0, index).trim(); + if (isSafeHeaderName(name)) headers[name] = line.slice(index + 1).trim(); + } return headers; }, {}); } @@ -73,10 +84,11 @@ function asObjectHeader(value?: string | Record): 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), + ...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')}`; @@ -146,8 +158,17 @@ function runJsSnippet(code: string, context: Record, timeout = 1000 htmlFormat: (value: unknown) => he.decode(String(value ?? '')).replace(/]+>/g, ''), }; const sandbox: Record = { ...context, java, Buffer, JSON, String, Number, Math, Array, Object, console: { log: () => undefined } }; + const body = code.replace(/^@js:/, '').trim(); try { - const script = new vm.Script(`(function(){ ${code.replace(/^@js:/, '')}\n})()`); + 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 { @@ -186,7 +207,7 @@ function buildUrlFromTemplate(template: string, source: BookSource, keyword?: st function parseJsonMaybe(value: string): any | null { try { - return JSON.parse(value); + return JSON.parse(value.trim()); } catch { return null; } @@ -435,13 +456,15 @@ function applyLegadoIndexSelector(current: cheerio.Cheerio, selector: strin const real = idx < 0 ? current.length + idx : idx; return { current: current.filter((index) => index !== real), selector: '' }; } - const range = normalized.match(/\[(-?\d*):(-?\d*)(?::(-?\d+))?\]$/); + 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 start = range[1] ? Number(range[1]) : 0; - const end = range[2] ? Number(range[2]) : 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: '' }; @@ -457,6 +480,33 @@ function applyLegadoIndexSelector(current: cheerio.Cheerio, selector: strin return { current, selector: normalized }; } +function applyLegadoSelector($: cheerio.CheerioAPI, current: cheerio.Cheerio, selector: string): cheerio.Cheerio { + 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, rule?: string): cheerio.Cheerio { const normalized = stripFilters(rule || ''); const reverse = normalized.trim().startsWith('-') && !normalized.trim().startsWith('-@'); @@ -472,8 +522,7 @@ function selectElements($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule? current = current.children(); continue; } - const indexed = applyLegadoIndexSelector(current, selector); - current = indexed.selector ? current.find(indexed.selector) : indexed.current; + current = applyLegadoSelector($, current, selector); } if (reverse) current = $(current.toArray().reverse()); return current; @@ -523,6 +572,12 @@ function selectXPath($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule: st function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: string, baseUrl?: string): string { for (const alternative of splitAlternatives(rule)) { 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(); @@ -540,8 +595,7 @@ function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: str if (parsed.selector) { if (/^children$/i.test(parsed.selector)) current = current.children(); else { - const indexed = applyLegadoIndexSelector(current, parsed.selector); - current = indexed.selector ? current.find(indexed.selector) : indexed.current; + current = applyLegadoSelector($, current, parsed.selector); } } if (parsed.attr) attr = parsed.attr; @@ -578,8 +632,7 @@ function readValues($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: st if (parsed.selector) { if (/^children$/i.test(parsed.selector)) current = current.children(); else { - const indexed = applyLegadoIndexSelector(current, parsed.selector); - current = indexed.selector ? current.find(indexed.selector) : indexed.current; + current = applyLegadoSelector($, current, parsed.selector); } } if (parsed.attr) attr = parsed.attr; @@ -606,6 +659,19 @@ function readValues($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: st return []; } +function applyBookInfoInit($: cheerio.CheerioAPI, root: cheerio.Cheerio, 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["']\)| { const request = splitUrlOptions(url); const safe = await validateProxyUrlServerSide(request.url); if (!safe) throw new Error(`书源地址未通过安全校验: ${request.url}`); - const cacheKey = `${LEGADO_CACHE_VERSION}|text|${source.id}|${request.method || 'GET'}|${request.url}|${request.body || ''}`; + 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; @@ -870,8 +948,18 @@ interface ExploreTarget { 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 && !!rule.ruleExplore?.bookList; + return rule.enabledExplore !== false && parseExploreUrl(rule.exploreUrl).length > 0 && !!getEffectiveExploreRule(rule); } function parseExploreUrl(exploreUrl?: string): Array<{ title: string; template: string }> { @@ -939,7 +1027,7 @@ export class LegadoClient { 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 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 }; @@ -1067,7 +1155,7 @@ export class LegadoClient { } const targetUrl = buildExploreTargetUrl(source, target); const html = await fetchText(source, targetUrl); - const exploreRule = rule.ruleExplore || rule.ruleSearch; + const exploreRule = getEffectiveExploreRule(rule); const entries: BookListItem[] = []; let pageCount = 0; const json = parseJsonMaybe(html); @@ -1148,7 +1236,7 @@ export class LegadoClient { const source = await getSourceById(sourceId); const rule = getRule(source); const detailHref = href || fallback?.detailHref || ''; - const cacheKey = `${LEGADO_CACHE_VERSION}|detail|${source.id}|${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 : {}) }; @@ -1161,6 +1249,7 @@ export class LegadoClient { 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 @@ -1214,7 +1303,7 @@ export class LegadoClient { 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 cacheKey = `toc|${source.id}|${tocHref}`; const { cacheTTL } = await resolveLegadoConfig(); const cached = tocCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.data; @@ -1275,7 +1364,7 @@ export class LegadoClient { 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 cacheKey = `chapter|${source.id}|${targetUrl}`; const cached = chapterCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.data; @@ -1290,6 +1379,7 @@ export class LegadoClient { 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'); @@ -1299,7 +1389,10 @@ export class LegadoClient { id: stableId(`${source.id}|${targetUrl}`), title: index >= 0 ? chapters[index].title : '', href: targetUrl, - content: proxyChapterImages(cleanContent(rule.ruleContent.sourceRegex ? applyRuleFilters(rawContent, [rule.ruleContent.sourceRegex, '']) : rawContent), source), + 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, };