// 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~", capítulo "gb~~". // Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. // En /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 = /||<[?!][^>]*>|<(\/?)([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 (
) 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
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(/]/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 = /]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1] if (nav) { const out = [] for (const m of nav.matchAll(/]*)>([\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 = /]*\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(/]*>\s*]*>\s*]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*]*)\/?>/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(/]*)>/i.exec(container)?.[1] || '', 'full-path') if (!opfPath) throw new Error('EPUB sin paquete') const opf = read(opfPath) const dc = name => clean(new RegExp(`]*>([\\s\\S]*?)`, 'i').exec(opf)?.[1] || '') const manifest = new Map() for (const m of opf.matchAll(/]*)>/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(/]*)>/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(/]*>([\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]*?)`).exec(xml)?.[1] || '') function parseFeed(xml) { const out = [] for (const [entry] of xml.matchAll(/[\s\S]*?<\/entry>/g)) { const n = /[^<]*\/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 } }