// 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) // Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. Orígenes: // - Project Gutenberg, con su catálogo OPDS. IDs: libro "gb~", capítulo "gb~~". // - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~", "ws~~". // - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~". // 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 crypto from 'node:crypto' 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 WS_API = 'https://es.wikisource.org/w/api.php' const WS_EXPORT = 'https://ws-export.wmcloud.org/' const WS_MIN_WORDS = 2000 // sin subpáginas, lo que no llega a esto es un poema o un texto suelto, no un libro const OPDS_PAGE = 25 const GB_NAME = 'Project Gutenberg' const WS_NAME = 'Wikisource' // 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 = 3 const MAX_EPUB_BYTES = 100 * 1024 * 1024 const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen const MAX_UNZIPPED_TOTAL = 256 * 1024 * 1024 // todo lo que se lee del libro: lo que pase de aquí es una ZIP bomb const MAX_UPLOADS_PER_USER = 500 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') let unzipped = 0 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') const buf = readCbzEntry(file, e, MAX_UNZIPPED_ENTRY) unzipped += buf.length // lo real: la cabecera del ZIP puede mentir if (unzipped > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir') return buf } 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') // Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un , que a veces no apunta a una imagen. const coverId = /]*\bname\s*=\s*["']cover["'])[^>]*\bcontent\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1] const coverItem = [...manifest.values()].find(it => /\bcover-image\b/.test(it.props)) || manifest.get(coverId) const cover = coverItem ? imageOf(coverItem.path) : null 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, cover, chapters: out, } } // ---------- Fuente y caché ---------- // Portada generada para lo que no trae una: el título sobre un color que sale del propio título. function titleCover(title, author) { const esc = t => String(t).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])) const wrap = (t, width, max) => { const lines = [] let cur = '' for (const w of t.split(/\s+/)) { if (cur && cur.length + w.length + 1 > width) { lines.push(cur); cur = w } else cur = cur ? `${cur} ${w}` : w } if (cur) lines.push(cur) if (lines.length > max) { lines.length = max; lines[max - 1] += '…' } return lines.map(l => l.slice(0, width + 6)) } const hue = [...String(title)].reduce((n, c) => (n * 31 + c.codePointAt(0)) % 360, 7) const lines = wrap(title, 15, 6) const by = author ? wrap(author, 22, 2) : [] const text = (t, y, size, weight, fill) => `${esc(t)}` return `${lines.map((l, i) => text(l, 150 + i * 48, 38, 'bold', '#f6f1e6')).join('')}${by.map((l, i) => text(l, 500 + i * 30, 23, 'normal', `hsl(${hue} 45% 80%)`)).join('')}` } export function setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) { const DIR = path.join(DATA_DIR, 'books') fs.mkdirSync(DIR, { recursive: true }) db.exec(`CREATE TABLE IF NOT EXISTS user_books ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, authors TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_user_books_user ON user_books(user_id, created_at);`) // Un título de Wikisource puede ser largo para nombre de archivo. const fileKey = id => (id.startsWith('ws~') ? `ws-${crypto.createHash('sha256').update(id).digest('hex').slice(0, 32)}` : id.replace('~', '-')) const epubFile = id => path.join(DIR, `${fileKey(id)}.epub`) const jsonFile = id => path.join(DIR, `${fileKey(id)}.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 fetchSource(url, timeout, name) { 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 ${name}`) if (r.status < 500 && r.status !== 429) throw new HttpError(502, `${name} 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(`${name} ${url}: intento ${i}: ${why}`) if (i >= 3) throw new HttpError(502, `${name} 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 fetchSource(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3, GB_NAME)).text())) } // ---------- Wikisource ---------- const wsId = title => `ws~${Buffer.from(title).toString('base64url')}` const wsTitle = id => Buffer.from(id.slice(3), 'base64url').toString('utf8') const wsCard = root => { const id = wsId(root) // "Realidad (Pérez Galdós)": la obra y, entre paréntesis, de quién es o de qué año. const m = /^(.+?)\s*\(([^()]+)\)$/.exec(root) return { id, source: 'ws', title: m ? m[1] : root, cover: `/api/book/${id}/cover`, sub: m ? m[2] : undefined } } const wsQuery = async params => { const qs = new URLSearchParams({ action: 'query', format: 'json', ...params }) return (await fetchSource(`${WS_API}?${qs}`, 20e3, WS_NAME)).json() } async function wsList(q, mode) { const text = q.trim() if (text) { const d = await wsQuery({ list: 'search', srsearch: text, srnamespace: '0', srlimit: '200', srprop: 'wordcount' }) // Una novela es una página índice corta con muchas subpáginas ("Obra/12"): cuenta la obra, una vez. const works = new Map() for (const r of d.query?.search || []) { const root = r.title.split('/')[0] const w = works.get(root) || { root, chapters: 0, words: 0 } if (r.title.includes('/')) w.chapters++ else w.words = r.wordcount works.set(root, w) } return [...works.values()].filter(w => w.chapters || w.words >= WS_MIN_WORDS).map(w => w.root) } const d = await wsQuery({ list: 'categorymembers', cmtitle: 'Categoría:Novelas', cmnamespace: '0', cmlimit: '500', ...(mode === 'latest' ? { cmsort: 'timestamp', cmdir: 'desc' } : {}) }) return (d.query?.categorymembers || []).map(m => m.title) } async function wsSearch(q, mode, page) { const list = await cached(`ws:${mode === 'latest' ? 'latest' : 'popular'}:${q.trim().toLowerCase()}`, 30 * 60e3, () => wsList(q, mode)) const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE return list.filter(t => Buffer.byteLength(t) <= 280).slice(start, start + OPDS_PAGE).map(wsCard) } // ---------- Libros subidos ---------- const owns = (userId, id) => !!db.prepare('SELECT 1 FROM user_books WHERE id = ? AND user_id = ?').get(id, userId) const ubCard = r => ({ id: r.id, source: 'ub', title: r.title, cover: `/api/book/${r.id}/cover`, sub: JSON.parse(r.authors).join(', ') || undefined }) function ubList(userId, q) { const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') const words = fold(q).split(/\s+/).filter(Boolean) return db.prepare('SELECT * FROM user_books WHERE user_id = ? ORDER BY created_at DESC').all(userId) .filter(r => words.every(w => fold(`${r.title} ${r.authors}`).includes(w))).map(ubCard) } async function upload(req, user) { if (db.prepare('SELECT COUNT(*) n FROM user_books WHERE user_id = ?').get(user.id).n >= MAX_UPLOADS_PER_USER) throw new HttpError(400, `Se admiten hasta ${MAX_UPLOADS_PER_USER} libros por usuario`) let size = 0 const chunks = [] for await (const c of req) { size += c.length if (size > MAX_EPUB_BYTES) throw new HttpError(413, 'El libro es demasiado grande (máximo 100 MB)') chunks.push(c) } if (!size) throw new HttpError(400, 'No se recibió ningún archivo') const data = Buffer.concat(chunks) // El mismo archivo subido dos veces por la misma persona es el mismo libro. const id = `ub~${crypto.createHash('sha256').update(`${user.id}:`).update(data).digest('hex').slice(0, 32)}` const tmp = path.join(DIR, `up-${crypto.randomBytes(8).toString('hex')}.part`) fs.writeFileSync(tmp, data) let book try { book = parseEpub(tmp) } catch (e) { fs.rmSync(tmp, { force: true }) throw new HttpError(400, `No se pudo leer ese archivo como EPUB: ${String(e.message).slice(0, 120)}`) } fs.renameSync(tmp, epubFile(id)) fs.writeFileSync(jsonFile(id), JSON.stringify(book)) remember(id, book) db.prepare(`INSERT INTO user_books (id, user_id, title, authors, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET created_at = excluded.created_at`) .run(id, user.id, (book.title || 'Sin título').slice(0, 300), JSON.stringify(book.authors.map(authorName).slice(0, 10)), Date.now()) return ubCard(db.prepare('SELECT * FROM user_books WHERE id = ?').get(id)) } function removeUpload(id, userId) { db.prepare('DELETE FROM user_books WHERE id = ? AND user_id = ?').run(id, userId) db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(userId, id) db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(userId, id) db.prepare('DELETE FROM manga_prefs WHERE user_id = ? AND manga_id = ?').run(userId, id) db.prepare('DELETE FROM list_items WHERE manga_id = ? AND list_id IN (SELECT id FROM lists WHERE user_id = ?)').run(id, userId) memory.delete(id) packed.delete(id) fs.rmSync(epubFile(id), { force: true }) fs.rmSync(jsonFile(id), { force: true }) } // 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) { if (id.startsWith('ub~')) throw new HttpError(404, 'Ese libro ya no está') const r = id.startsWith('ws~') ? await fetchSource(`${WS_EXPORT}?${new URLSearchParams({ lang: 'es', page: wsTitle(id), format: 'epub-3' })}`, 120e3, WS_NAME) : await fetchSource(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3, GB_NAME) 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) const source = id.slice(0, 2) return { manga: { id, source, type: 'Libro', title: book.title || (source === 'ws' ? wsCard(wsTitle(id)).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: source === 'gb' ? coverOf(id.slice(3), id) : `/api/book/${id}/cover`, }, 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) } async function sendCover(res, id) { if (id.startsWith('ub~')) { const book = await loadBook(id) if (book.cover != null) return sendImage(res, id, book.cover) const r = db.prepare('SELECT title, authors FROM user_books WHERE id = ?').get(id) return sendSvg(res, titleCover(r?.title || book.title || 'Sin título', book.authors.map(authorName)[0])) } if (!id.startsWith('ws~')) throw new HttpError(404, 'Sin portada') const card = wsCard(wsTitle(id)) // en un listado: sin descargar el libro sendSvg(res, titleCover(card.title, card.sub)) } const sendSvg = (res, svg) => { res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' }) res.end(svg) } return { search, wsSearch, ubList, upload, removeUpload, owns, manga, chapter, sendText, sendImage, sendCover } }