@@ -0,0 +1,687 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as cheerio from 'cheerio/slim' ;
import crypto from 'crypto' ;
import he from 'he' ;
import { getConfig } from './config' ;
import {
BookAcquisitionLink ,
BookCatalogResult ,
BookChapter ,
BookChapterContent ,
BookDetail ,
BookListItem ,
BookSearchFailure ,
BookSearchResult ,
BookSource ,
BookSourceCapabilities ,
LegadoBookSourceRule ,
} from './book.types' ;
import { validateProxyUrlServerSide } from './server/ssrf' ;
interface ResolvedLegadoConfig {
enabled : boolean ;
sources : BookSource [ ] ;
cacheTTL : number ;
}
const DEFAULT_TIMEOUT_MS = Number ( process . env . LEGADO_TIMEOUT_MS || process . env . OPDS_TIMEOUT_MS || 20000 ) ;
const MAX_TEXT_BYTES = Number ( process . env . LEGADO_MAX_TEXT_BYTES || 3 * 1024 * 1024 ) ;
const LEGADO_CACHE_VERSION = 'v5' ;
const textCache = new Map < string , { expiresAt : number ; data : string } > ( ) ;
const searchCache = new Map < string , { expiresAt : number ; data : BookListItem [ ] } > ( ) ;
const detailCache = new Map < string , { expiresAt : number ; data : BookDetail } > ( ) ;
const tocCache = new Map < string , { expiresAt : number ; data : BookChapter [ ] } > ( ) ;
const chapterCache = new Map < string , { expiresAt : number ; data : BookChapterContent } > ( ) ;
function stableId ( input : string ) {
return crypto . createHash ( 'sha1' ) . update ( input ) . digest ( 'hex' ) . slice ( 0 , 16 ) ;
}
function asObjectHeader ( value? : string | Record < string , string > ) : Record < string , string > {
if ( ! value ) return { } ;
if ( typeof value === 'object' ) return value ;
try {
const parsed = JSON . parse ( value ) ;
return parsed && typeof parsed === 'object' ? parsed : { } ;
} catch {
return value . split ( '\n' ) . reduce < Record < string , string > > ( ( headers , line ) = > {
const index = line . indexOf ( ':' ) ;
if ( index > 0 ) headers [ line . slice ( 0 , index ) . trim ( ) ] = line . slice ( index + 1 ) . trim ( ) ;
return headers ;
} , { } ) ;
}
}
function buildHeaders ( source : BookSource ) : HeadersInit {
const rule = source . legado ;
const headers : Record < string , string > = {
'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36' ,
Accept : 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' ,
. . . asObjectHeader ( rule ? . header ) ,
} ;
if ( source . authMode === 'header' && source . headerName && source . headerValue ) headers [ source . headerName ] = source . headerValue ;
if ( source . authMode === 'basic' && source . username ) headers . Authorization = ` Basic ${ Buffer . from ( ` ${ source . username } : ${ source . password || '' } ` ) . toString ( 'base64' ) } ` ;
delete headers . Host ;
delete headers . host ;
delete headers [ 'Content-Length' ] ;
delete headers [ 'content-length' ] ;
return headers ;
}
function sourceBase ( source : BookSource ) {
return source . legado ? . bookSourceUrl || source . url ;
}
function normalizeUrl ( base : string , href? : string ) : string {
if ( ! href ) return base ;
const trimmed = href . trim ( ) ;
if ( ! trimmed ) return base ;
if ( /^javascript:/i . test ( trimmed ) ) return '' ;
return new URL ( trimmed , base ) . toString ( ) ;
}
function encodeRuleParam ( value : string ) {
return encodeURIComponent ( value ) . replace ( /%20/g , '+' ) ;
}
function buildUrlFromTemplate ( template : string , source : BookSource , keyword? : string , page = 1 , baseOverride? : string ) {
const base = baseOverride || sourceBase ( source ) ;
let raw = template || base ;
raw = raw . replace ( /\{\{(?:key|keyword|searchTerms)\}\}/g , encodeRuleParam ( keyword || '' ) ) ;
raw = raw . replace ( /\{\{(?:page|pageIndex)\}\}/g , String ( page ) ) ;
raw = raw
. replace ( /\{searchTerms\}/g , encodeRuleParam ( keyword || '' ) )
. replace ( /\{key\}/g , encodeRuleParam ( keyword || '' ) )
. replace ( /\{keyword\}/g , encodeRuleParam ( keyword || '' ) )
. replace ( /\{page\}/g , String ( page ) )
. replace ( /\{pageIndex\}/g , String ( page ) ) ;
if ( raw . includes ( '{{' ) && keyword ) raw = raw . replace ( /\{\{.*?\}\}/g , encodeRuleParam ( keyword ) ) ;
return normalizeUrl ( base , raw ) ;
}
function parseJsonMaybe ( value : string ) : any | null {
try {
return JSON . parse ( value ) ;
} catch {
return null ;
}
}
function jsonPrimitiveToString ( value : any ) : string {
if ( value === undefined || value === null ) return '' ;
if ( typeof value === 'string' ) return value . trim ( ) ;
if ( typeof value === 'number' || typeof value === 'boolean' ) return String ( value ) ;
if ( Array . isArray ( value ) ) return value . map ( jsonPrimitiveToString ) . filter ( Boolean ) . join ( ', ' ) ;
try {
return JSON . stringify ( value ) ;
} catch {
return String ( value ) ;
}
}
function readJsonPath ( input : any , path? : string ) : any {
if ( ! path ) return input ;
let normalized = path . trim ( ) ;
if ( normalized . startsWith ( '@json:' ) ) normalized = normalized . slice ( 6 ) ;
if ( normalized . startsWith ( '-@json:' ) ) normalized = normalized . slice ( 7 ) ;
if ( ! normalized || normalized === '$' ) return input ;
const recursive = normalized . match ( /^\$\.\.([A-Za-z0-9_$-]+)\[\*\]$/ ) ;
if ( recursive ) {
const key = recursive [ 1 ] ;
const out : any [ ] = [ ] ;
const walk = ( node : any ) = > {
if ( ! node || typeof node !== 'object' ) return ;
if ( Array . isArray ( node ) ) {
node . forEach ( walk ) ;
return ;
}
if ( Array . isArray ( node [ key ] ) ) out . push ( . . . node [ key ] ) ;
Object . values ( node ) . forEach ( walk ) ;
} ;
walk ( input ) ;
return out ;
}
normalized = normalized . replace ( /^\$\.?/ , '' ) ;
const tokens = normalized . match ( /[^.[\]]+|\[\*\]|\[\d+\]/g ) || [ ] ;
let current = input ;
for ( const token of tokens ) {
if ( current === undefined || current === null ) return undefined ;
if ( token === '[*]' ) {
if ( ! Array . isArray ( current ) ) return [ ] ;
current = current . flat ( ) ;
} else if ( /^\[\d+\]$/ . test ( token ) ) {
current = Array . isArray ( current ) ? current [ Number ( token . slice ( 1 , - 1 ) ) ] : undefined ;
} else if ( Array . isArray ( current ) ) {
current = current . map ( ( item ) = > item ? . [ token ] ) . filter ( ( item ) = > item !== undefined ) ;
} else {
current = current [ token ] ;
}
}
return current ;
}
function ruleIsJson ( rule? : string ) {
return ! ! rule && /@json:|-@json:|^\$\./ . test ( rule . trim ( ) ) ;
}
function selectJsonItems ( json : any , rule? : string ) : any [ ] {
const reverse = ! ! rule ? . trim ( ) . startsWith ( '-' ) ;
const value = readJsonPath ( json , rule ) ;
const list = Array . isArray ( value ) ? value : value ? [ value ] : [ ] ;
return reverse ? [ . . . list ] . reverse ( ) : list ;
}
function renderTemplateWithJson ( template : string , json : any , source : BookSource , baseUrl : string ) {
const rendered = template . replace ( /\{\{(.*?)\}\}/g , ( _ , expr ) = > {
const normalizedExpr = String ( expr ) . trim ( ) . replace ( /^@json:/ , '' ) ;
const value = readJsonPath ( json , normalizedExpr ) ;
return encodeRuleParam ( jsonPrimitiveToString ( value ) ) ;
} ) ;
return normalizeUrl ( baseUrl || sourceBase ( source ) , rendered ) ;
}
function readJsonRule ( json : any , rule? : string , source? : BookSource , baseUrl? : string ) : string {
if ( ! rule ) return '' ;
const trimmed = rule . trim ( ) ;
if ( trimmed . includes ( '{{' ) ) return renderTemplateWithJson ( trimmed , json , source as BookSource , baseUrl || sourceBase ( source as BookSource ) ) ;
if ( trimmed . startsWith ( '@js:' ) ) {
if ( /result\s*=\s*['"]([^'"]+)['"]\s*\+\s*result\.([A-Za-z0-9_$-]+)/ . test ( trimmed ) ) {
const match = trimmed . match ( /result\s*=\s*['"]([^'"]+)['"]\s*\+\s*result\.([A-Za-z0-9_$-]+)/ ) ;
return normalizeUrl ( baseUrl || sourceBase ( source as BookSource ) , ` ${ match ? . [ 1 ] || '' } ${ jsonPrimitiveToString ( json ? . [ match ? . [ 2 ] || '' ] ) } ` ) ;
}
if ( /item\.img|\.reverse\(\)/ . test ( trimmed ) ) {
const data = Array . isArray ( json ? . data ) ? [ . . . json . data ] . reverse ( ) : Array . isArray ( json ) ? [ . . . json ] . reverse ( ) : [ ] ;
return data
. map ( ( item ) = > item ? . img ? ` <img src=" ${ String ( item . img ) } " style="max-width:100%; display:block;" referrerpolicy="no-referrer"> ` : '' )
. filter ( Boolean )
. join ( '' ) ;
}
return '' ;
}
const value = readJsonPath ( json , trimmed ) ;
const text = jsonPrimitiveToString ( value ) ;
if ( ( /url|href|pic|cover/i . test ( trimmed ) || /^https?:\/\//i . test ( text ) ) && text && baseUrl ) return normalizeUrl ( baseUrl , text ) ;
return text ;
}
function fallbackChapterHrefFromItem ( item : any , rule? : string , baseUrl? : string ) : string {
const id = jsonPrimitiveToString ( item ? . id || item ? . cid || item ? . chapter_id || item ? . chapterId ) ;
if ( ! id ) return '' ;
const match = ( rule || '' ) . match ( /['"]([^'"]*(?:pic|chapter)[^'"]*(?:cid|id)=)['"]/i ) ;
if ( match ? . [ 1 ] ) return normalizeUrl ( baseUrl || '' , ` ${ match [ 1 ] } ${ id } ` ) ;
return '' ;
}
function splitAlternatives ( rule? : string ) : string [ ] {
return ( rule || '' ) . split ( '||' ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) ;
}
function parseStep ( step : string ) : { selector : string ; attr : string } {
const trimmed = step . trim ( ) ;
const attrMatch = trimmed . match ( /(?:@|::)(text|textNodes|html|href|src|content|value|data-[\w-]+|[\w-]+)$/i ) ;
if ( attrMatch ) {
return { selector : trimmed.slice ( 0 , attrMatch . index ) . trim ( ) , attr : attrMatch [ 1 ] } ;
}
const dotAttr = trimmed . match ( /\.(text|html|href|src)$/i ) ;
if ( dotAttr ) return { selector : trimmed.slice ( 0 , dotAttr . index ) . trim ( ) , attr : dotAttr [ 1 ] } ;
return { selector : trimmed , attr : '' } ;
}
function stripFilters ( rule : string ) {
return rule . split ( '##' ) [ 0 ] . trim ( ) ;
}
function selectElements ( $ : cheerio.CheerioAPI , root : cheerio.Cheerio < any > , rule? : string ) : cheerio . Cheerio < any > {
const normalized = stripFilters ( rule || '' ) ;
if ( ! normalized ) return root ;
const steps = normalized . split ( /&&|@css:/ ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) ;
let current = root ;
for ( const rawStep of steps ) {
const { selector , attr } = parseStep ( rawStep ) ;
if ( ! selector || attr ) break ;
current = current . find ( selector ) ;
}
return current ;
}
function readValue ( $ : cheerio.CheerioAPI , root : cheerio.Cheerio < any > , rule? : string , baseUrl? : string ) : string {
for ( const alternative of splitAlternatives ( rule ) ) {
const normalized = stripFilters ( alternative ) ;
const steps = normalized . split ( /&&|@css:/ ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) ;
let current = root ;
let attr = '' ;
for ( const rawStep of steps ) {
const parsed = parseStep ( rawStep ) ;
if ( parsed . selector ) current = current . find ( parsed . selector ) ;
if ( parsed . attr ) attr = parsed . attr ;
}
if ( current . length === 0 && steps . length === 1 ) {
const parsed = parseStep ( steps [ 0 ] ) ;
if ( ! parsed . selector && parsed . attr ) current = root ;
}
const node = current . first ( ) ;
let value = '' ;
const normalizedAttr = attr . toLowerCase ( ) ;
if ( ! attr || normalizedAttr === 'text' || normalizedAttr === 'textnodes' ) value = node . text ( ) ;
else if ( normalizedAttr === 'html' ) value = node . html ( ) || '' ;
else value = node . attr ( attr ) || '' ;
value = he . decode ( value || '' ) . replace ( /\u00a0/g , ' ' ) . trim ( ) ;
if ( ( normalizedAttr === 'href' || normalizedAttr === 'src' ) && value && baseUrl ) value = normalizeUrl ( baseUrl , value ) ;
if ( value ) return value ;
}
return '' ;
}
function contentFromRule ( raw : string , rule? : string , baseUrl? : string ) : string {
const json = parseJsonMaybe ( raw ) ;
if ( json && ( ruleIsJson ( rule ) || rule ? . trim ( ) . startsWith ( '@js:' ) ) ) {
return readJsonRule ( json , rule , undefined , baseUrl ) ;
}
const $ = cheerio . load ( raw ) ;
return readValue ( $ , $ . root ( ) , rule , baseUrl ) ;
}
function cleanContent ( value : string ) {
const decoded = he . decode ( value || '' ) . trim ( ) ;
if ( /<img\b/i . test ( decoded ) ) return decoded ;
return decoded
. replace ( /<br\s*\/?>/gi , '\n' )
. replace ( /<\/p>/gi , '\n' )
. replace ( /<[^>]+>/g , '' )
. replace ( /\r/g , '' )
. split ( '\n' )
. map ( ( line ) = > line . trim ( ) )
. filter ( Boolean )
. join ( '\n\n' ) ;
}
async function resolveLegadoConfig ( ) : Promise < ResolvedLegadoConfig > {
let enabled = process . env . OPDS_ENABLED === 'true' || process . env . LEGADO_ENABLED === 'true' ;
let sources : BookSource [ ] = [ ] ;
const cacheTTL = Number ( process . env . LEGADO_CACHE_TTL_MS || process . env . OPDS_CACHE_TTL_MS || 10 * 60 * 1000 ) ;
const envJson = process . env . LEGADO_SOURCES_JSON ;
if ( envJson ) {
try {
const parsed = JSON . parse ( envJson ) ;
sources = normalizeImportedSources ( parsed ) ;
} catch { }
}
try {
const config = await getConfig ( ) ;
if ( config . OPDSConfig ) {
enabled = config . OPDSConfig . Enabled ? ? enabled ;
if ( Array . isArray ( config . OPDSConfig . Sources ) ) {
sources = ( config . OPDSConfig . Sources as BookSource [ ] )
. map ( ( source , index ) = > normalizeConfiguredLegadoSource ( source , index ) )
. filter ( ( source ) : source is BookSource = > ! ! source ) ;
}
}
} catch { }
return { enabled , cacheTTL , sources : sources.filter ( ( source ) = > ! ! source . url && source . enabled !== false ) } ;
}
export function normalizeImportedSources ( input : unknown ) : BookSource [ ] {
const list = Array . isArray ( input ) ? input : [ input ] ;
return list
. filter ( ( item ) : item is LegadoBookSourceRule = > ! ! item && typeof item === 'object' )
. map ( ( rule , index ) = > {
const name = rule . bookSourceName || ` Legado 书源 ${ index + 1 } ` ;
const url = rule . bookSourceUrl || '' ;
return {
id : ` legado_ ${ stableId ( ` ${ name } | ${ url } | ${ index } ` ) } ` ,
name ,
type : 'legado' as const ,
url ,
enabled : rule.enabled !== false ,
authMode : 'none' as const ,
preferFormat : [ 'epub' as const ] ,
language : '' ,
legado : rule ,
} ;
} )
. filter ( ( source ) = > ! ! source . url ) ;
}
function normalizeConfiguredLegadoSource ( item : any , index : number ) : BookSource | null {
if ( ! item || typeof item !== 'object' ) return null ;
if ( item . type === 'legado' || item . legado ) {
const rule = item . legado || item ;
const name = item . name || rule . bookSourceName || ` Legado 书源 ${ index + 1 } ` ;
const url = item . url || rule . bookSourceUrl || '' ;
if ( ! url ) return null ;
return {
. . . item ,
id : item.id || ` legado_ ${ stableId ( ` ${ name } | ${ url } | ${ index } ` ) } ` ,
name ,
type : 'legado' ,
url ,
enabled : item.enabled !== false && rule . enabled !== false ,
authMode : item.authMode || 'none' ,
legado : { . . . rule , bookSourceName : rule.bookSourceName || name , bookSourceUrl : rule.bookSourceUrl || url } ,
} ;
}
if ( item . bookSourceUrl || item . searchUrl || item . ruleSearch ) {
return normalizeImportedSources ( [ item ] ) [ 0 ] || null ;
}
return null ;
}
async function fetchText ( source : BookSource , url : string ) : Promise < string > {
const safe = await validateProxyUrlServerSide ( url ) ;
if ( ! safe ) throw new Error ( '书源地址未通过安全校验' ) ;
const cacheKey = ` ${ LEGADO_CACHE_VERSION } |text| ${ source . id } | ${ url } ` ;
const cached = textCache . get ( cacheKey ) ;
const { cacheTTL } = await resolveLegadoConfig ( ) ;
if ( cached && cached . expiresAt > Date . now ( ) ) return cached . data ;
const controller = new AbortController ( ) ;
const timeout = setTimeout ( ( ) = > controller . abort ( ) , DEFAULT_TIMEOUT_MS ) ;
try {
const response = await fetch ( url , { headers : buildHeaders ( source ) , signal : controller.signal , cache : 'no-store' } ) ;
if ( ! response . ok ) throw new Error ( ` 请求失败: ${ response . status } ` ) ;
const contentLength = Number ( response . headers . get ( 'content-length' ) || '0' ) ;
if ( contentLength > MAX_TEXT_BYTES ) throw new Error ( '响应内容过大' ) ;
const text = await response . text ( ) ;
if ( text . length > MAX_TEXT_BYTES ) throw new Error ( '响应内容过大' ) ;
textCache . set ( cacheKey , { data : text , expiresAt : Date.now ( ) + cacheTTL } ) ;
return text ;
} finally {
clearTimeout ( timeout ) ;
}
}
async function getSourceById ( sourceId : string ) : Promise < BookSource > {
const config = await resolveLegadoConfig ( ) ;
const source = config . sources . find ( ( item ) = > item . id === sourceId ) ;
if ( ! source ) throw new Error ( '未找到对应的 Legado 书源' ) ;
return source ;
}
function getRule ( source : BookSource ) : LegadoBookSourceRule {
if ( ! source . legado ) throw new Error ( 'Legado 书源缺少规则' ) ;
return source . legado ;
}
function makeItem ( source : BookSource , partial : Partial < BookListItem > & { detailHref? : string ; title? : string } ) : BookListItem {
const detailHref = partial . detailHref || '' ;
return {
id : partial.id || stableId ( ` ${ source . id } | ${ detailHref || partial . title || Date . now ( ) } ` ) ,
sourceId : source.id ,
sourceName : source.name ,
title : partial.title || '未命名电子书' ,
author : partial.author ,
cover : partial.cover ,
summary : partial.summary ,
tags : partial.tags ,
detailHref ,
acquisitionLinks : partial.acquisitionLinks || [ ] ,
} ;
}
export class LegadoClient {
async getSources ( ) : Promise < BookSource [ ] > {
const config = await resolveLegadoConfig ( ) ;
if ( ! config . enabled ) return [ ] ;
return config . sources . map ( ( source ) = > ( {
. . . source ,
capabilities : {
searchSupported : ! ! source . legado ? . searchUrl ,
catalogSupported : false ,
searchMode : source.legado?.searchUrl ? 'legado' : 'disabled' ,
catalogMode : 'legado' ,
acquisitionTypes : [ 'application/x-legado-chapters+json' ] ,
lastCheckedAt : Date.now ( ) ,
} ,
} ) ) ;
}
async getSearchSources ( sourceId? : string ) : Promise < BookSource [ ] > {
return sourceId ? [ await getSourceById ( sourceId ) ] : ( await resolveLegadoConfig ( ) ) . sources ;
}
async searchBooksSource ( q : string , source : BookSource ) : Promise < { source : BookSource ; results : BookListItem [ ] } > {
const rule = getRule ( source ) ;
if ( ! rule . searchUrl || ! rule . ruleSearch ? . bookList ) throw new Error ( '该 Legado 书源不支持搜索' ) ;
const cacheKey = ` ${ LEGADO_CACHE_VERSION } |search| ${ source . id } | ${ q } ` ;
const { cacheTTL } = await resolveLegadoConfig ( ) ;
const cached = searchCache . get ( cacheKey ) ;
if ( cached && cached . expiresAt > Date . now ( ) ) return { source , results : cached.data } ;
const targetUrl = buildUrlFromTemplate ( rule . searchUrl , source , q , 1 ) ;
const html = await fetchText ( source , targetUrl ) ;
const results : BookListItem [ ] = [ ] ;
const json = parseJsonMaybe ( html ) ;
if ( json && ruleIsJson ( rule . ruleSearch . bookList ) ) {
const items = selectJsonItems ( json , rule . ruleSearch . bookList ) ;
items . forEach ( ( item ) = > {
const detailHref = readJsonRule ( item , rule . ruleSearch ? . bookUrl , source , targetUrl ) ;
const title = readJsonRule ( item , rule . ruleSearch ? . name , source , targetUrl ) ;
if ( ! title && ! detailHref ) return ;
const cover = readJsonRule ( item , rule . ruleSearch ? . coverUrl , source , targetUrl ) ;
results . push ( makeItem ( source , {
id : jsonPrimitiveToString ( item ? . id ) || undefined ,
title ,
author : readJsonRule ( item , rule . ruleSearch ? . author , source , targetUrl ) ,
summary : readJsonRule ( item , rule . ruleSearch ? . intro , source , targetUrl ) ,
cover : cover || undefined ,
detailHref ,
tags : readJsonRule ( item , rule . ruleSearch ? . kind , source , targetUrl ) . split ( /[,, \s]+/ ) . filter ( Boolean ) ,
} ) ) ;
} ) ;
} else {
const $ = cheerio . load ( html ) ;
const items = selectElements ( $ , $ . root ( ) , rule . ruleSearch . bookList ) ;
items . each ( ( _ , element ) = > {
const root = $ ( element ) ;
const detailHref = readValue ( $ , root , rule . ruleSearch ? . bookUrl , targetUrl ) ;
const title = readValue ( $ , root , rule . ruleSearch ? . name , targetUrl ) ;
if ( ! title && ! detailHref ) return ;
const cover = readValue ( $ , root , rule . ruleSearch ? . coverUrl , targetUrl ) ;
results . push ( makeItem ( source , {
title ,
author : readValue ( $ , root , rule . ruleSearch ? . author , targetUrl ) ,
summary : readValue ( $ , root , rule . ruleSearch ? . intro , targetUrl ) ,
cover : cover || undefined ,
detailHref ,
tags : readValue ( $ , root , rule . ruleSearch ? . kind , targetUrl ) . split ( /[,, \s]+/ ) . filter ( Boolean ) ,
} ) ) ;
} ) ;
}
searchCache . set ( cacheKey , { data : results , expiresAt : Date.now ( ) + cacheTTL } ) ;
return { source , results } ;
}
async searchBooks ( q : string , sourceId? : string ) : Promise < BookSearchResult > {
const sources = await this . getSearchSources ( sourceId ) ;
const results : BookListItem [ ] = [ ] ;
const failedSources : BookSearchFailure [ ] = [ ] ;
await Promise . all ( sources . map ( async ( source ) = > {
try {
const sourceResult = await this . searchBooksSource ( q , source ) ;
results . push ( . . . sourceResult . results ) ;
} catch ( error ) {
failedSources . push ( { sourceId : source.id , sourceName : source.name , error : ( error as Error ) . message } ) ;
}
} ) ) ;
return { results , failedSources } ;
}
async getCatalog ( sourceId : string , href? : string ) : Promise < BookCatalogResult > {
const source = await getSourceById ( sourceId ) ;
return {
sourceId : source.id ,
sourceName : source.name ,
title : source.name ,
href : href || source . url ,
entries : [ ] ,
navigation : [ ] ,
} ;
}
async getChaptersByBookId ( sourceId : string , bookId : string ) : Promise < BookChapter [ ] > {
const source = await getSourceById ( sourceId ) ;
const rule = getRule ( source ) ;
const base = sourceBase ( source ) ;
const detailHref = rule . ruleSearch ? . bookUrl
? normalizeUrl ( base , rule . ruleSearch . bookUrl
. replace ( /\{\{\s*\$\.id\s*\}\}/g , encodeURIComponent ( bookId ) )
. replace ( /\{\{\s*id\s*\}\}/g , encodeURIComponent ( bookId ) )
. replace ( /\{id\}/g , encodeURIComponent ( bookId ) ) )
: '' ;
if ( ! detailHref ) throw new Error ( '该 Legado 书源无法通过 bookId 定位详情' ) ;
const detail = await this . getBookDetail ( sourceId , detailHref , { id : bookId , detailHref } ) ;
const tocHref = detail . acquisitionLinks . find ( ( item ) = > item . rel === 'legado:chapters' || item . type . toLowerCase ( ) . includes ( 'legado-chapters' ) ) ? . href ;
if ( ! tocHref ) return [ ] ;
return this . getChapters ( sourceId , tocHref ) ;
}
async getBookDetail ( sourceId : string , href : string , fallback? : Partial < BookDetail > ) : Promise < BookDetail > {
const source = await getSourceById ( sourceId ) ;
const rule = getRule ( source ) ;
const detailHref = href || fallback ? . detailHref || '' ;
const cacheKey = ` ${ LEGADO_CACHE_VERSION } |detail| ${ source . id } | ${ detailHref } ` ;
const { cacheTTL } = await resolveLegadoConfig ( ) ;
const cached = detailCache . get ( cacheKey ) ;
if ( cached && cached . expiresAt > Date . now ( ) ) return { . . . cached . data , . . . ( ! href && fallback ? fallback : { } ) } ;
let detail : BookDetail | null = null ;
if ( detailHref && rule . ruleBookInfo ) {
const targetUrl = rule . bookInfoUrl ? buildUrlFromTemplate ( rule . bookInfoUrl , source , undefined , 1 , detailHref ) . replace ( /\{bookUrl\}/g , encodeURIComponent ( detailHref ) ) : detailHref ;
const html = await fetchText ( source , targetUrl ) ;
const json = parseJsonMaybe ( html ) ;
const $ = json ? null : cheerio . load ( html ) ;
const root = $ ? . root ( ) ;
const read = ( itemRule? : string ) = > json
? readJsonRule ( json , itemRule , source , targetUrl )
: readValue ( $ as cheerio . CheerioAPI , root as cheerio . Cheerio < any > , itemRule , targetUrl ) ;
const tocUrl = read ( rule . ruleBookInfo . tocUrl ) || ( rule . tocUrl ? buildUrlFromTemplate ( rule . tocUrl , source , undefined , 1 , detailHref ) . replace ( /\{bookUrl\}/g , encodeURIComponent ( detailHref ) ) : targetUrl ) ;
const cover = read ( rule . ruleBookInfo . coverUrl ) || fallback ? . cover ;
const title = read ( rule . ruleBookInfo . name ) || fallback ? . title || '未命名电子书' ;
const chapterCountText = json
? jsonPrimitiveToString ( readJsonPath ( json , '@json:$.data.nums' ) ? ? readJsonPath ( json , '@json:$.data.chapter_nums' ) )
: '' ;
const chapterCount = chapterCountText ? Number ( chapterCountText ) : NaN ;
const hasKnownEmptyChapters = Number . isFinite ( chapterCount ) && chapterCount <= 0 ;
const acquisitionLinks : BookAcquisitionLink [ ] = hasKnownEmptyChapters ? [ ] : [ { rel : 'legado:chapters' , type : 'application/x-legado-chapters+json' , href : tocUrl , title : '章节目录' } ] ;
detail = {
id : fallback?.id || stableId ( ` ${ source . id } | ${ detailHref || title } ` ) ,
sourceId ,
sourceName : source.name ,
title ,
author : read ( rule . ruleBookInfo . author ) || fallback ? . author ,
cover : cover || undefined ,
summary : read ( rule . ruleBookInfo . intro ) || fallback ? . summary ,
tags : read ( rule . ruleBookInfo . kind ) . split ( /[,, \s]+/ ) . filter ( Boolean ) ,
categories : read ( rule . ruleBookInfo . kind ) . split ( /[,, \s]+/ ) . filter ( Boolean ) ,
detailHref ,
acquisitionLinks ,
navigation : hasKnownEmptyChapters ? [ ] : [ { title : '目录' , href : tocUrl , rel : 'legado:toc' , type : 'application/x-legado-chapters+json' } ] ,
} ;
}
if ( ! detail ) {
const tocUrl = fallback ? . acquisitionLinks ? . [ 0 ] ? . href || detailHref ;
detail = {
id : fallback?.id || stableId ( ` ${ source . id } | ${ detailHref || fallback ? . title || '' } ` ) ,
sourceId ,
sourceName : source.name ,
title : fallback?.title || '未命名电子书' ,
author : fallback?.author ,
cover : fallback?.cover ,
summary : fallback?.summary ,
detailHref ,
acquisitionLinks : [ { rel : 'legado:chapters' , type : 'application/x-legado-chapters+json' , href : tocUrl , title : '章节目录' } ] ,
navigation : [ { title : '目录' , href : tocUrl , rel : 'legado:toc' , type : 'application/x-legado-chapters+json' } ] ,
} ;
}
detailCache . set ( cacheKey , { data : detail , expiresAt : Date.now ( ) + cacheTTL } ) ;
return detail ;
}
async getChapters ( sourceId : string , tocHref : string ) : Promise < BookChapter [ ] > {
const source = await getSourceById ( sourceId ) ;
const rule = getRule ( source ) ;
if ( ! rule . ruleToc ? . chapterList ) throw new Error ( '该 Legado 书源缺少目录规则' ) ;
const cacheKey = ` ${ LEGADO_CACHE_VERSION } |toc| ${ source . id } | ${ tocHref } ` ;
const { cacheTTL } = await resolveLegadoConfig ( ) ;
const cached = tocCache . get ( cacheKey ) ;
if ( cached && cached . expiresAt > Date . now ( ) ) return cached . data ;
const targetUrl = normalizeUrl ( sourceBase ( source ) , tocHref ) ;
const html = await fetchText ( source , targetUrl ) ;
const chapters : BookChapter [ ] = [ ] ;
const json = parseJsonMaybe ( html ) ;
if ( json && ruleIsJson ( rule . ruleToc . chapterList ) ) {
const items = selectJsonItems ( json , rule . ruleToc . chapterList ) ;
items . forEach ( ( item , index ) = > {
const title = readJsonRule ( item , rule . ruleToc ? . chapterName , source , targetUrl ) || ` 第 ${ index + 1 } 章 ` ;
const href = readJsonRule ( item , rule . ruleToc ? . chapterUrl , source , targetUrl )
|| fallbackChapterHrefFromItem ( item , rule . ruleToc ? . chapterUrl , targetUrl ) ;
if ( ! href ) return ;
const normalizedHref = normalizeUrl ( targetUrl , href ) ;
chapters . push ( { id : stableId ( ` ${ source . id } | ${ normalizedHref } ` ) , title , href : normalizedHref , order : index } ) ;
} ) ;
} else {
const $ = cheerio . load ( html ) ;
const items = selectElements ( $ , $ . root ( ) , rule . ruleToc . chapterList ) ;
items . each ( ( index , element ) = > {
const root = $ ( element ) ;
const title = readValue ( $ , root , rule . ruleToc ? . chapterName , targetUrl ) || ` 第 ${ index + 1 } 章 ` ;
const href = readValue ( $ , root , rule . ruleToc ? . chapterUrl , targetUrl ) || root . attr ( 'href' ) || '' ;
if ( ! href ) return ;
const normalizedHref = normalizeUrl ( targetUrl , href ) ;
chapters . push ( { id : stableId ( ` ${ source . id } | ${ normalizedHref } ` ) , title , href : normalizedHref , order : index } ) ;
} ) ;
}
tocCache . set ( cacheKey , { data : chapters , expiresAt : Date.now ( ) + cacheTTL } ) ;
return chapters ;
}
async getChapterContent ( sourceId : string , chapterHref : string , tocHref? : string ) : Promise < BookChapterContent > {
const source = await getSourceById ( sourceId ) ;
const rule = getRule ( source ) ;
if ( ! rule . ruleContent ? . content ) throw new Error ( '该 Legado 书源缺少正文规则' ) ;
const targetUrl = normalizeUrl ( sourceBase ( source ) , chapterHref ) ;
const cacheKey = ` ${ LEGADO_CACHE_VERSION } |chapter| ${ source . id } | ${ targetUrl } ` ;
const cached = chapterCache . get ( cacheKey ) ;
if ( cached && cached . expiresAt > Date . now ( ) ) return cached . data ;
const html = await fetchText ( source , targetUrl ) ;
const rawContent = contentFromRule ( html , rule . ruleContent . content , targetUrl ) ;
const chapters = tocHref ? await this . getChapters ( sourceId , tocHref ) . catch ( ( ) = > [ ] ) : [ ] ;
const index = chapters . findIndex ( ( item ) = > item . href === targetUrl || item . href === chapterHref ) ;
const content : BookChapterContent = {
id : stableId ( ` ${ source . id } | ${ targetUrl } ` ) ,
title : index >= 0 ? chapters [ index ] . title : '' ,
href : targetUrl ,
content : cleanContent ( rawContent ) ,
previousHref : index > 0 ? chapters [ index - 1 ] . href : undefined ,
nextHref : index >= 0 && index + 1 < chapters . length ? chapters [ index + 1 ] . href : undefined ,
} ;
chapterCache . set ( cacheKey , { data : content , expiresAt : Date.now ( ) + 24 * 60 * 60 * 1000 } ) ;
return content ;
}
async getSourceById ( sourceId : string ) : Promise < BookSource > {
return getSourceById ( sourceId ) ;
}
async detectCapabilitiesFromSource ( source : BookSource ) : Promise < BookSourceCapabilities > {
return {
searchSupported : ! ! source . legado ? . searchUrl ,
catalogSupported : false ,
searchMode : source.legado?.searchUrl ? 'legado' : 'disabled' ,
catalogMode : 'legado' ,
acquisitionTypes : [ 'application/x-legado-chapters+json' ] ,
lastCheckedAt : Date.now ( ) ,
} ;
}
}
export const legadoClient = new LegadoClient ( ) ;