Files
manga-reader/app/books.js
T
ninoandClaude Opus 5.5 be19e35156 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>
2026-11-02 21:02:29 -06:00

452 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// 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 }
}