Libros de texto: fuente Project Gutenberg y lectura continua
Los EPUB se convierten en el servidor en una lista cerrada de bloques y el navegador los maqueta en páginas según su pantalla. Cada página se dibuja en un canvas y llega al lector como una imagen más, así que hoja, sombra, hendidura y sonido no cambian. El libro se lee de corrido: cada capítulo empieza debajo del anterior y el progreso se sigue guardando por capítulo. El catálogo sale del OPDS de gutenberg.org, con reintentos. Ajustes: mostrar solo manga, solo libros o ambos, por usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+451
@@ -0,0 +1,451 @@
|
||||
// Libros de texto (EPUB). El EPUB se convierte aquí en una lista cerrada de bloques: al navegador nunca llega
|
||||
// HTML del libro, y es él quien maqueta los bloques en páginas según su pantalla (ver maquetado en app.js).
|
||||
// bloque: { k: 'p' | 'q' | 'h' | 'hr' | 'img', l?: nivel de título, n?: imagen, c?: centrado, v?: verso,
|
||||
// r?: [[texto, estilo]] } estilo: 1 cursiva, 2 negrita (sumables)
|
||||
// Fuente en línea: Project Gutenberg, con su propio catálogo OPDS. IDs: libro "gb~<n>", capítulo "gb~<n>~<i>".
|
||||
// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo.
|
||||
// En <DATA_DIR>/books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido.
|
||||
import fs from 'node:fs'
|
||||
import zlib from 'node:zlib'
|
||||
import path from 'node:path'
|
||||
import { readZipIndex, readCbzEntry, imageType } from './cbz.js'
|
||||
|
||||
export const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
|
||||
const GUTENBERG = 'https://www.gutenberg.org'
|
||||
const OPDS_PAGE = 25
|
||||
// El buscador de Gutenberg filtra por el nombre del idioma, no por el código.
|
||||
const LANG_NAMES = { es: 'spanish', en: 'english', fr: 'french', pt: 'portuguese', it: 'italian', de: 'german', ru: 'russian', ja: 'japanese', zh: 'chinese', ko: 'korean', ca: 'catalan', nl: 'dutch', pl: 'polish' }
|
||||
const PARSER_VERSION = 2
|
||||
const MAX_EPUB_BYTES = 100 * 1024 * 1024
|
||||
const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen
|
||||
const MAX_UNZIPPED_TOTAL = 512 * 1024 * 1024 // todo el libro: lo que pase de aquí es una ZIP bomb
|
||||
const BOOK_IMAGE = /\.(jpe?g|png|gif|webp)$/i
|
||||
|
||||
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', iexcl: '¡', iquest: '¿', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', agrave: 'à', egrave: 'è', igrave: 'ì', ograve: 'ò', ugrave: 'ù', ccedil: 'ç', shy: '', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', copy: '©', deg: '°', ordm: 'º', ordf: 'ª', sect: '§', para: '¶', middot: '·', dagger: '†', Dagger: '‡', bull: '•', times: '×' }
|
||||
const entities = s => s.replace(/&(#x?[0-9a-f]+|[a-z0-9]+);/gi, (all, e) => {
|
||||
if (e[0] !== '#') return ENTITIES[e] ?? all
|
||||
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
|
||||
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
|
||||
})
|
||||
const attr = (attrs, name) => {
|
||||
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs)
|
||||
return m ? entities(m[1] ?? m[2]) : null
|
||||
}
|
||||
// Ruta dentro del ZIP de un enlace relativo a un archivo del libro.
|
||||
function resolveHref(base, href) {
|
||||
let rel = href.split('#')[0]
|
||||
try { rel = decodeURIComponent(rel) } catch {}
|
||||
return path.posix.normalize(path.posix.join(path.posix.dirname(base), rel)).replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
// ---------- XHTML a bloques ----------
|
||||
const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav'])
|
||||
const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'])
|
||||
const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr'])
|
||||
const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g
|
||||
|
||||
// onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo.
|
||||
function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) {
|
||||
let skip = 0, skipTag = null
|
||||
let italic = 0, bold = 0, quote = 0, center = 0, verse = 0
|
||||
const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas
|
||||
let runs = [], heading = 0
|
||||
const flush = () => {
|
||||
// Espacios del código fuente fuera; los saltos de línea reales (<br>) son \n.
|
||||
const out = []
|
||||
let atStart = true
|
||||
for (const [text, style] of runs) {
|
||||
let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un <br>
|
||||
if (atStart) t = t.replace(/^ +/, '')
|
||||
if (!t) continue
|
||||
atStart = t.endsWith('\n')
|
||||
const last = out.at(-1)
|
||||
if (last && last[1] === style) last[0] += t
|
||||
else out.push([t, style])
|
||||
}
|
||||
while (out.length) {
|
||||
const last = out.at(-1)
|
||||
last[0] = last[0].replace(/[ \n]+$/, '')
|
||||
if (last[0]) break
|
||||
out.pop()
|
||||
}
|
||||
runs = []
|
||||
if (out.length && out.some(([t]) => t.trim())) {
|
||||
const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out }
|
||||
if (center || heading) b.c = 1
|
||||
if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1
|
||||
onBlock(b)
|
||||
}
|
||||
}
|
||||
const body = xml.search(/<body[\s>]/i)
|
||||
let pos = body >= 0 ? body : 0
|
||||
TOKEN.lastIndex = pos
|
||||
for (let m; (m = TOKEN.exec(xml));) {
|
||||
if (!skip && m.index > pos) {
|
||||
const text = entities(xml.slice(pos, m.index))
|
||||
if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)])
|
||||
}
|
||||
pos = TOKEN.lastIndex
|
||||
const tag = m[2]?.toLowerCase().replace(/^.*:/, '')
|
||||
if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro)
|
||||
continue
|
||||
}
|
||||
const closing = m[1] === '/'
|
||||
const rawAttrs = m[3] || ''
|
||||
const selfClosed = /\/\s*$/.test(rawAttrs)
|
||||
if (skip) {
|
||||
if (tag === skipTag) {
|
||||
if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (closing) {
|
||||
if (BLOCK.has(tag)) {
|
||||
flush()
|
||||
const top = stack.findLastIndex(s => s.tag === tag)
|
||||
if (top >= 0) {
|
||||
for (const s of stack.splice(top)) {
|
||||
quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold
|
||||
if (s.heading) heading = 0
|
||||
}
|
||||
}
|
||||
} else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1)
|
||||
else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1)
|
||||
continue
|
||||
}
|
||||
const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null)
|
||||
if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ }
|
||||
const cls = (attr(rawAttrs, 'class') || '').toLowerCase()
|
||||
const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase()
|
||||
// Números de página de la edición impresa: ruido entre el texto.
|
||||
if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) {
|
||||
if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag }
|
||||
continue
|
||||
}
|
||||
if (tag === 'br') { runs.push(['\n', 0]); continue }
|
||||
if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue }
|
||||
if (tag === 'img' || tag === 'image') {
|
||||
const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href')
|
||||
const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null
|
||||
if (n != null) { flush(); onBlock({ k: 'img', n }) }
|
||||
continue
|
||||
}
|
||||
if (BLOCK.has(tag)) {
|
||||
flush()
|
||||
const style = attr(rawAttrs, 'style') || ''
|
||||
const s = {
|
||||
tag,
|
||||
quote: tag === 'blockquote' ? 1 : 0,
|
||||
center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0,
|
||||
verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0,
|
||||
italic: 0, bold: 0, heading: 0,
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 }
|
||||
quote += s.quote; center += s.center; verse += s.verse
|
||||
if (!selfClosed) stack.push(s)
|
||||
else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 }
|
||||
continue
|
||||
}
|
||||
if (selfClosed) continue
|
||||
if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++
|
||||
else if (tag === 'b' || tag === 'strong') bold++
|
||||
else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) }
|
||||
}
|
||||
if (!skip && pos < xml.length) {
|
||||
const text = entities(xml.slice(pos).replace(/<[^>]*$/, ''))
|
||||
if (text.trim()) runs.push([text, 0])
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera.
|
||||
function imageSize(b) {
|
||||
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
|
||||
if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }
|
||||
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
|
||||
for (let p = 2; p + 9 < b.length;) {
|
||||
if (b[p] !== 0xff) { p++; continue }
|
||||
const m = b[p + 1]
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) }
|
||||
if (m === 0xff) { p++; continue }
|
||||
p += 2 + b.readUInt16BE(p + 2)
|
||||
}
|
||||
}
|
||||
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
||||
const kind = b.toString('latin1', 12, 16)
|
||||
if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
|
||||
if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
|
||||
if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// ---------- EPUB ----------
|
||||
|
||||
// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx).
|
||||
function readToc(read, opf, manifest) {
|
||||
const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props))
|
||||
if (navItem) {
|
||||
const xml = read(navItem.path)
|
||||
const nav = /<nav\b[^>]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1]
|
||||
if (nav) {
|
||||
const out = []
|
||||
for (const m of nav.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
||||
const href = attr(m[1], 'href')
|
||||
if (!href) continue
|
||||
out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) })
|
||||
}
|
||||
if (out.length) return out
|
||||
}
|
||||
}
|
||||
const spineToc = /<spine\b[^>]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
|
||||
const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type))
|
||||
if (!ncx) return []
|
||||
const xml = read(ncx.path)
|
||||
const out = []
|
||||
// Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan.
|
||||
for (const m of xml.matchAll(/<navPoint\b[^>]*>\s*<navLabel\b[^>]*>\s*<text\b[^>]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*<content\b([^>]*)\/?>/gi)) {
|
||||
const src = attr(m[2], 'src')
|
||||
if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
|
||||
|
||||
export function parseEpub(file) {
|
||||
const index = readZipIndex(file)
|
||||
const entries = new Map(index.map(e => [e.name, e]))
|
||||
if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
|
||||
const readBuf = name => {
|
||||
const e = entries.get(name)
|
||||
if (!e) throw new Error(`Falta ${name} en el EPUB`)
|
||||
if (e.unzipped > MAX_UNZIPPED_ENTRY) throw new Error('Archivo demasiado grande dentro del EPUB')
|
||||
return readCbzEntry(file, e, MAX_UNZIPPED_ENTRY)
|
||||
}
|
||||
const read = name => readBuf(name).toString('utf8').replace(/^/, '')
|
||||
|
||||
const container = read('META-INF/container.xml')
|
||||
const opfPath = attr(/<rootfile\b([^>]*)>/i.exec(container)?.[1] || '', 'full-path')
|
||||
if (!opfPath) throw new Error('EPUB sin paquete')
|
||||
const opf = read(opfPath)
|
||||
const dc = name => clean(new RegExp(`<dc:${name}\\b[^>]*>([\\s\\S]*?)</dc:${name}>`, 'i').exec(opf)?.[1] || '')
|
||||
const manifest = new Map()
|
||||
for (const m of opf.matchAll(/<item\b([^>]*)>/gi)) {
|
||||
const id = attr(m[1], 'id'), href = attr(m[1], 'href')
|
||||
if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' })
|
||||
}
|
||||
const spine = [...opf.matchAll(/<itemref\b([^>]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type))
|
||||
if (!spine.length) throw new Error('EPUB sin contenido')
|
||||
let toc = []
|
||||
try { toc = readToc(read, opf, manifest) } catch {}
|
||||
|
||||
const images = []
|
||||
const imageIdx = new Map()
|
||||
const imageOf = name => {
|
||||
const e = entries.get(name)
|
||||
if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null
|
||||
if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) }
|
||||
return imageIdx.get(name)
|
||||
}
|
||||
|
||||
// Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene.
|
||||
const targets = new Map()
|
||||
for (const t of toc) {
|
||||
if (!targets.has(t.file)) targets.set(t.file, new Map())
|
||||
targets.get(t.file).set(t.anchor, t.title)
|
||||
}
|
||||
const chapters = []
|
||||
const start = title => chapters.push({ title, blocks: [] })
|
||||
for (const item of spine) {
|
||||
const here = targets.get(item.path)
|
||||
if (here?.has(null) || (!toc.length)) start(here?.get(null) || '')
|
||||
if (!chapters.length) start('')
|
||||
xhtmlToBlocks(read(item.path), item.path, imageOf,
|
||||
b => chapters.at(-1).blocks.push(b),
|
||||
(id, flush) => {
|
||||
if (!here?.has(id)) return false
|
||||
flush()
|
||||
start(here.get(id))
|
||||
here.delete(id) // un id repetido no abre otro capítulo
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente.
|
||||
const merged = []
|
||||
let carry = null
|
||||
for (const c of chapters) {
|
||||
if (!c.blocks.length) continue
|
||||
const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks
|
||||
const title = c.title || carry?.title || ''
|
||||
carry = null
|
||||
if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue }
|
||||
merged.push({ title, blocks })
|
||||
}
|
||||
if (carry) merged.push(carry)
|
||||
const out = merged
|
||||
if (!out.length) throw new Error('EPUB sin texto')
|
||||
out.forEach((c, i) => { if (!c.title) c.title = `Sección ${i + 1}` })
|
||||
// El navegador maqueta el libro entero sin cargar las imágenes: necesita saber su tamaño.
|
||||
for (const c of out) {
|
||||
for (const b of c.blocks) {
|
||||
if (b.k !== 'img') continue
|
||||
try { Object.assign(b, imageSize(readBuf(images[b.n]))) } catch {}
|
||||
}
|
||||
}
|
||||
const year = parseInt(dc('date'), 10)
|
||||
return {
|
||||
version: PARSER_VERSION,
|
||||
title: dc('title'), authors: [...opf.matchAll(/<dc:creator\b[^>]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean),
|
||||
language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null,
|
||||
images, chapters: out,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Fuente y caché ----------
|
||||
export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) {
|
||||
const DIR = path.join(DATA_DIR, 'books')
|
||||
fs.mkdirSync(DIR, { recursive: true })
|
||||
const epubFile = id => path.join(DIR, `${id.replace('~', '-')}.epub`)
|
||||
const jsonFile = id => path.join(DIR, `${id.replace('~', '-')}.json`)
|
||||
|
||||
// Hasta 3 intentos: un fallo de red o un 5xx de Gutenberg suele durar poco. El motivo real queda en el log.
|
||||
async function gutenberg(url, timeout) {
|
||||
for (let i = 1; ; i++) {
|
||||
net.guard()
|
||||
let why
|
||||
try {
|
||||
const r = await safeFetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(timeout) })
|
||||
if (r.ok) return r
|
||||
if (r.status === 404) throw new HttpError(404, 'Ese libro no está en Project Gutenberg')
|
||||
if (r.status < 500 && r.status !== 429) throw new HttpError(502, `Project Gutenberg respondió ${r.status}`)
|
||||
why = `HTTP ${r.status}`
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) throw e
|
||||
why = e.cause?.code || e.cause?.message || e.message
|
||||
net.failed()
|
||||
}
|
||||
console.warn(`Gutenberg ${url}: intento ${i}: ${why}`)
|
||||
if (i >= 3) throw new HttpError(502, 'Project Gutenberg no responde ahora mismo. Prueba en un rato.')
|
||||
await new Promise(r => setTimeout(r, 800 * i))
|
||||
}
|
||||
}
|
||||
const coverOf = (n, id) => coverUrl(`${GUTENBERG}/cache/epub/${n}/pg${n}.cover.medium.jpg`, id)
|
||||
// "Cervantes Saavedra, Miguel de" -> "Miguel de Cervantes Saavedra"
|
||||
const authorName = a => a.split(', ').reverse().join(' ')
|
||||
const entryText = (xml, tag) => clean(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] || '')
|
||||
function parseFeed(xml) {
|
||||
const out = []
|
||||
for (const [entry] of xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)) {
|
||||
const n = /<id>[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1]
|
||||
if (!n) continue // "Ordenar por…" y otras entradas de navegación
|
||||
const id = `gb~${n}`
|
||||
// El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)".
|
||||
const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '')
|
||||
out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
// lang del usuario ("es-la") a idioma del catálogo ("spanish").
|
||||
async function search(q, mode, page, lang) {
|
||||
const name = LANG_NAMES[String(lang || 'es').slice(0, 2)]
|
||||
const text = q.trim().replace(/\s+/g, ' ')
|
||||
const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' })
|
||||
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1
|
||||
if (start > 1) qs.set('start_index', String(start))
|
||||
return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await gutenberg(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3)).text()))
|
||||
}
|
||||
|
||||
// Una sola descarga y conversión a la vez por libro.
|
||||
const loading = new Map()
|
||||
const memory = new Map() // últimos libros convertidos
|
||||
function remember(id, book) {
|
||||
memory.delete(id)
|
||||
memory.set(id, book)
|
||||
if (memory.size > 4) memory.delete(memory.keys().next().value)
|
||||
return book
|
||||
}
|
||||
async function downloadEpub(id) {
|
||||
const r = await gutenberg(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3)
|
||||
if (Number(r.headers.get('content-length')) > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
if (buf.length > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
|
||||
const tmp = `${epubFile(id)}.part`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, epubFile(id))
|
||||
}
|
||||
function loadBook(id) {
|
||||
const hit = memory.get(id)
|
||||
if (hit) return Promise.resolve(remember(id, hit))
|
||||
if (loading.has(id)) return loading.get(id)
|
||||
const p = (async () => {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(jsonFile(id), 'utf8'))
|
||||
if (saved.version === PARSER_VERSION) return remember(id, saved)
|
||||
} catch {}
|
||||
if (!fs.existsSync(epubFile(id))) await downloadEpub(id)
|
||||
let book
|
||||
try { book = parseEpub(epubFile(id)) } catch (e) {
|
||||
console.warn(`EPUB ${id}: ${e.message}`)
|
||||
fs.rmSync(epubFile(id), { force: true }) // descarga mala: que el siguiente intento la repita
|
||||
throw new HttpError(502, 'No se pudo leer el EPUB de este libro')
|
||||
}
|
||||
const tmp = `${jsonFile(id)}.part`
|
||||
fs.writeFileSync(tmp, JSON.stringify(book))
|
||||
fs.renameSync(tmp, jsonFile(id))
|
||||
return remember(id, book)
|
||||
})().finally(() => loading.delete(id))
|
||||
loading.set(id, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function manga(id) {
|
||||
const book = await loadBook(id)
|
||||
return {
|
||||
manga: {
|
||||
id, source: 'gb', type: 'Libro',
|
||||
title: book.title || `Libro ${id.slice(3)}`,
|
||||
description: book.description || '',
|
||||
status: null, year: null, // la fecha del EPUB es la de la edición digital, no la de la obra
|
||||
tags: [],
|
||||
authors: book.authors.map(authorName),
|
||||
languages: book.language ? [book.language] : [],
|
||||
cover: coverOf(id.slice(3), id),
|
||||
},
|
||||
chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })),
|
||||
}
|
||||
}
|
||||
async function chapter(chapterId) {
|
||||
const cut = chapterId.lastIndexOf('~')
|
||||
const book = await loadBook(chapterId.slice(0, cut))
|
||||
const c = book.chapters[Number(chapterId.slice(cut + 1))]
|
||||
if (!c) throw new HttpError(404, 'Ese capítulo no existe')
|
||||
return { title: c.title }
|
||||
}
|
||||
// El libro entero (lo maqueta el navegador), comprimido una sola vez: son megas de texto.
|
||||
const packed = new Map()
|
||||
async function sendText(req, res, id) {
|
||||
const book = await loadBook(id)
|
||||
let hit = packed.get(id)
|
||||
if (hit?.book !== book) {
|
||||
hit = { book, gz: zlib.gzipSync(JSON.stringify({ title: book.title, chapters: book.chapters })) }
|
||||
packed.delete(id)
|
||||
packed.set(id, hit)
|
||||
if (packed.size > 4) packed.delete(packed.keys().next().value)
|
||||
}
|
||||
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'private, no-cache', Vary: 'Accept-Encoding' }
|
||||
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) { res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' }); return res.end(hit.gz) }
|
||||
res.writeHead(200, headers)
|
||||
res.end(zlib.gunzipSync(hit.gz))
|
||||
}
|
||||
async function sendImage(res, id, n) {
|
||||
const book = await loadBook(id)
|
||||
const name = book.images[n]
|
||||
const entry = name && readZipIndex(epubFile(id)).find(e => e.name === name)
|
||||
if (!entry) throw new HttpError(404, 'Imagen no encontrada')
|
||||
const data = readCbzEntry(epubFile(id), entry, MAX_UNZIPPED_ENTRY)
|
||||
res.writeHead(200, { 'Content-Type': imageType(name), 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
|
||||
res.end(data)
|
||||
}
|
||||
return { search, manga, chapter, sendText, sendImage }
|
||||
}
|
||||
Reference in New Issue
Block a user