Compare commits
1
Commits
52e9edc214
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40127265f |
+3
-3
@@ -5,14 +5,14 @@ PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# Dónde guardar los mangas descargados en el host (por defecto ./mangas dentro del proyecto).
|
||||
MANGA_DIR_HOST=/zdata/media/mangas
|
||||
MANGA_DIR_HOST=./mangas
|
||||
|
||||
# Interfaz y puerto publicados. 0.0.0.0 = todas.
|
||||
BIND_IP=0.0.0.0
|
||||
HOST_PORT=3352
|
||||
|
||||
# URL de la web que se usa en los enlaces de las notificaciones.
|
||||
PUBLIC_URL=http://192.168.1.141:3352
|
||||
PUBLIC_URL=http://localhost:3352
|
||||
|
||||
# Registro de cuentas nuevas (con la base vacía, el primer usuario siempre puede registrarse).
|
||||
ALLOW_SIGNUP=false
|
||||
@@ -20,7 +20,7 @@ ALLOW_SIGNUP=false
|
||||
# Grupo "render" del host, para usar la GPU en el reescalado HD (getent group render | cut -d: -f3).
|
||||
RENDER_GID=992
|
||||
|
||||
TZ=America/El_Salvador
|
||||
TZ=UTC
|
||||
CHECK_INTERVAL_HOURS=3
|
||||
|
||||
# --- Exponer a internet ---
|
||||
|
||||
@@ -4,7 +4,14 @@ data/*
|
||||
# Descargas cuando se usan dentro del proyecto (MANGA_DIR_HOST por defecto)
|
||||
mangas/*
|
||||
!mangas/.keep
|
||||
# Configuración real de cada servidor (la plantilla sí se versiona)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
# Documentos internos de traspaso (se guardan solo en el servidor)
|
||||
HANDOFF-LIBRO3D.md
|
||||
# Basura del sistema y registros
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -17,7 +17,7 @@ Lector web de manga autoalojado, con las fuentes de
|
||||
- Usuarios con contraseña; el **historial y la página exacta** se guardan en el servidor
|
||||
(`data/manga.db`), así que puedes seguir desde cualquier dispositivo/sesión.
|
||||
- **Descargas**: botón "⬇ Descargar" en la ficha. Baja el manga entero en segundo plano a
|
||||
`/zdata/media/mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
|
||||
`./mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
|
||||
compatible con Kavita/Komga/Mihon. Se reanuda tras reinicios; repetirla baja solo los capítulos
|
||||
nuevos. Los capítulos descargados se leen desde disco y, sin conexión, la ficha usa los datos guardados.
|
||||
- **Novedades**: los mangas que empiezas a leer (o marcas con "Seguir") se revisan cada
|
||||
@@ -62,6 +62,20 @@ Lector web de manga autoalojado, con las fuentes de
|
||||
favoritos y lo más leído.
|
||||
- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`).
|
||||
|
||||
## Libros de texto
|
||||
|
||||
Además del manga, lee libros en EPUB con el mismo lector (hoja que gira, sombra, hendidura, sonido):
|
||||
|
||||
- **Project Gutenberg**: dominio público, con búsqueda en su catálogo OPDS.
|
||||
- **Wikisource en español**: novelas, con el EPUB que genera ws-export.
|
||||
- **Mis libros**: los EPUB que sube cada usuario (hasta 100 MB), visibles solo para quien los sube.
|
||||
|
||||
El servidor convierte el EPUB en una lista cerrada de bloques (`app/books.js`), sin pasar HTML del libro al
|
||||
navegador, y guarda el resultado en `data/books/`. El navegador maqueta el libro entero como texto continuo según su
|
||||
pantalla y dibuja cada página en un canvas. La letra, los márgenes y el papel se eligen en el lector, por dispositivo.
|
||||
Un libro se puede guardar entero para leerlo sin conexión. En Ajustes → «Qué mostrar», cada usuario elige ver manga,
|
||||
libros o ambos.
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
@@ -144,7 +158,7 @@ responde (`app/connectivity.js`).
|
||||
| Código | este repositorio | se recrea con `git clone` |
|
||||
| `.env` | raíz del proyecto (no está en git) | rutas, puerto, PUID/PGID, URL pública |
|
||||
| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes; `covers/`: portadas HD (caché) |
|
||||
| Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas |
|
||||
| Mangas descargados | `MANGA_DIR_HOST` (por defecto `./mangas`) | CBZ + portadas |
|
||||
|
||||
Pasos en el sistema nuevo:
|
||||
|
||||
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
// 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~<n>", capítulo "gb~<n>~<i>".
|
||||
// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~<título en base64url>", "ws~<título>~<i>".
|
||||
// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~<i>".
|
||||
// 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 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 = /<!--[\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')
|
||||
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(/<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')
|
||||
// Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un <meta>, que a veces no apunta a una imagen.
|
||||
const coverId = /<meta\b(?=[^>]*\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(/<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, 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) => `<text x="200" y="${y}" font-family="Georgia,'Times New Roman',serif" font-size="${size}" font-weight="${weight}" text-anchor="middle" fill="${fill}">${esc(t)}</text>`
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600" width="400" height="600"><rect width="400" height="600" fill="hsl(${hue} 38% 24%)"/><rect x="18" y="18" width="364" height="564" fill="none" stroke="hsl(${hue} 45% 72%)" stroke-width="2"/>${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('')}</svg>`
|
||||
}
|
||||
|
||||
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]*?)</${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 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 }
|
||||
}
|
||||
+16
-8
@@ -56,8 +56,14 @@ export function writeCbz(file, entries) {
|
||||
fs.renameSync(tmp, file)
|
||||
}
|
||||
|
||||
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size }]
|
||||
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }]
|
||||
export function readCbzIndex(file) {
|
||||
return readZipIndex(file).filter(e => IMAGE_EXT.test(e.name) && !e.name.startsWith('__MACOSX'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
// Todas las entradas del ZIP, en el orden del archivo (también lo usan los EPUB).
|
||||
export function readZipIndex(file) {
|
||||
const fd = fs.openSync(file, 'r')
|
||||
try {
|
||||
const { size } = fs.fstatSync(fd)
|
||||
@@ -65,10 +71,11 @@ export function readCbzIndex(file) {
|
||||
const tail = Buffer.alloc(tailLen)
|
||||
fs.readSync(fd, tail, 0, tailLen, size - tailLen)
|
||||
const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
|
||||
if (eocd < 0) throw new Error('CBZ inválido')
|
||||
if (eocd < 0) throw new Error('ZIP inválido')
|
||||
const count = tail.readUInt16LE(eocd + 10)
|
||||
const cdSize = tail.readUInt32LE(eocd + 12)
|
||||
const cdOffset = tail.readUInt32LE(eocd + 16)
|
||||
if (cdSize > size) throw new Error('ZIP inválido')
|
||||
const cd = Buffer.alloc(cdSize)
|
||||
fs.readSync(fd, cd, 0, cdSize, cdOffset)
|
||||
const entries = []
|
||||
@@ -76,18 +83,19 @@ export function readCbzIndex(file) {
|
||||
for (let p = 0, i = 0; i < count; i++) {
|
||||
const method = cd.readUInt16LE(p + 10)
|
||||
const compSize = cd.readUInt32LE(p + 20)
|
||||
const unzipped = cd.readUInt32LE(p + 24)
|
||||
const nameLen = cd.readUInt16LE(p + 28)
|
||||
const extraLen = cd.readUInt16LE(p + 30)
|
||||
const commentLen = cd.readUInt16LE(p + 32)
|
||||
const localOffset = cd.readUInt32LE(p + 42)
|
||||
if (compSize > size) throw new Error('ZIP inválido')
|
||||
const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
|
||||
p += 46 + nameLen + extraLen + commentLen
|
||||
if (!IMAGE_EXT.test(name) || name.startsWith('__MACOSX')) continue
|
||||
fs.readSync(fd, lh, 0, 30, localOffset)
|
||||
const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28)
|
||||
entries.push({ name, method, start, size: compSize })
|
||||
entries.push({ name, method, start, size: compSize, unzipped })
|
||||
}
|
||||
return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
return entries
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
@@ -98,13 +106,13 @@ export function imageType(name) {
|
||||
return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
// Lee una entrada completa del CBZ a memoria.
|
||||
export function readCbzEntry(file, entry) {
|
||||
// Lee una entrada completa del CBZ a memoria. maxBytes corta una entrada que al descomprimirse crezca de más.
|
||||
export function readCbzEntry(file, entry, maxBytes) {
|
||||
if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`)
|
||||
const fd = fs.openSync(file, 'r')
|
||||
const buf = Buffer.alloc(entry.size)
|
||||
try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) }
|
||||
return entry.method === 8 ? zlib.inflateRawSync(buf) : buf
|
||||
return entry.method === 8 ? zlib.inflateRawSync(buf, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf
|
||||
}
|
||||
|
||||
// Envía una entrada del CBZ por la respuesta HTTP.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten.
|
||||
// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se
|
||||
// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada.
|
||||
const SIDE = 16
|
||||
const SIG_BYTES = SIDE * SIDE
|
||||
const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima
|
||||
const SAME_SHAPE = 0.03
|
||||
const MIN_PAGES = 5
|
||||
const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra
|
||||
const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan
|
||||
|
||||
const decode = s => Buffer.from(String(s || ''), 'base64')
|
||||
const spread = b => {
|
||||
const mean = b.reduce((a, x) => a + x, 0) / b.length
|
||||
return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255
|
||||
}
|
||||
const difference = (a, b) => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i])
|
||||
return sum / SIG_BYTES / 255
|
||||
}
|
||||
|
||||
/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */
|
||||
export function repeatedPages(rows, ids) {
|
||||
const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) })))
|
||||
.filter(p => p.sig.length === SIG_BYTES)
|
||||
const out = {}
|
||||
for (const r of rows) {
|
||||
if (!ids.includes(r.id) || r.n < MIN_PAGES) continue
|
||||
const hidden = []
|
||||
for (const p of all) {
|
||||
if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue
|
||||
if (![0, r.n - 2, r.n - 1].includes(p.i)) continue
|
||||
if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i)
|
||||
}
|
||||
if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS page_sigs (
|
||||
manga_id TEXT NOT NULL,
|
||||
chapter_id TEXT NOT NULL,
|
||||
n INTEGER NOT NULL,
|
||||
pages TEXT NOT NULL,
|
||||
PRIMARY KEY (manga_id, chapter_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS hide_credits (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, manga_id)
|
||||
);`)
|
||||
const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId)
|
||||
|
||||
function validPages(pages, n) {
|
||||
const entries = Object.entries(pages || {})
|
||||
if (!entries.length || entries.length > 3) return false
|
||||
return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES)
|
||||
}
|
||||
|
||||
async function route(p, method, req, res, user) {
|
||||
let m
|
||||
if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
const { on } = await readBody(req)
|
||||
if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1])
|
||||
else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
|
||||
json(res, 200, { hideCredits: !!on })
|
||||
return true
|
||||
}
|
||||
if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : []
|
||||
if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos')
|
||||
const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)')
|
||||
for (const c of wanted) {
|
||||
const pages = body.sigs?.[c.id]
|
||||
if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages))
|
||||
}
|
||||
const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) }))
|
||||
const known = new Map(rows.map(r => [r.id, r.n]))
|
||||
// Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale.
|
||||
const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id)
|
||||
const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n))
|
||||
json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return { route, enabled }
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
// Seguimiento de capítulos nuevos (+ notificaciones ntfy/Telegram y autodescarga),
|
||||
// preferencias del lector por manga y estadísticas de lectura.
|
||||
const CHECK_HOURS = Number(process.env.CHECK_INTERVAL_HOURS || 3)
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://192.168.1.141:3352').replace(/\/+$/, '')
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://localhost:3352').replace(/\/+$/, '')
|
||||
|
||||
// Día local (TZ del contenedor) en formato YYYY-MM-DD.
|
||||
export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE')
|
||||
@@ -218,6 +218,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
|
||||
if (total && page >= total - 1) {
|
||||
db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day)
|
||||
}
|
||||
if (/^(gb|ws|ub)~/.test(b.mangaId)) return // un libro no tiene capítulos nuevos que seguir
|
||||
// Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir).
|
||||
db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
|
||||
|
||||
+27
-1
@@ -12,6 +12,7 @@
|
||||
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
|
||||
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
|
||||
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
|
||||
import { parseMuVolumes, workTitle } from './volumes.js'
|
||||
const DAY = 24 * 3600e3
|
||||
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
|
||||
const SYNC_EVERY = 3600e3
|
||||
@@ -119,6 +120,31 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// Las entradas de MangaDex con el título de la obra, en el orden en que las devuelve (primero la más parecida).
|
||||
function mdMatches(mangaId) {
|
||||
return stored(`mdworks:${mangaId}`, v => (v.length ? 30 * DAY : 3 * DAY), async () => {
|
||||
const { manga } = await fetchMangaFull(mangaId, 'es')
|
||||
const title = workTitle(manga.title)
|
||||
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3)
|
||||
return (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))).map(m => m.id)
|
||||
}, true)
|
||||
}
|
||||
|
||||
// Capítulo -> tomo según las publicaciones de MangaUpdates. Sirve para las obras licenciadas, que MangaDex deja sin capítulos.
|
||||
async function muVolumes(mangaId) {
|
||||
const l = await links(mangaId, true)
|
||||
if (!l.mu) return {}
|
||||
return stored(`mumap:${mangaId}`, v => (Object.keys(v).length ? 7 * DAY : 3 * DAY), async () => {
|
||||
const rows = []
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page })
|
||||
rows.push(...(r?.results || []).map(x => x.record))
|
||||
if (!r || page * 100 >= r.total_hits) break
|
||||
}
|
||||
return parseMuVolumes(rows)
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- Ficha ----------
|
||||
const AL_FIELDS = `id idMal siteUrl title{romaji english native} synonyms description(asHtml:true) format status
|
||||
startDate{year month day} endDate{year month day} chapters volumes countryOfOrigin isAdult
|
||||
@@ -287,5 +313,5 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
return true
|
||||
}
|
||||
|
||||
return { route, links, info, cachedInfo, dates }
|
||||
return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
||||
// Detector de rostros de anime, en un Worker para no trabar el estante.
|
||||
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
|
||||
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
|
||||
const integral = (gray, w, h) => {
|
||||
const I = new Int32Array((w + 1) * (h + 1))
|
||||
for (let y = 0; y < h; y++) {
|
||||
let row = 0
|
||||
for (let x = 0; x < w; x++) {
|
||||
row += gray[y * w + x]
|
||||
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
|
||||
}
|
||||
}
|
||||
return I
|
||||
}
|
||||
function equalize(gray) {
|
||||
const hist = new Int32Array(256), n = gray.length
|
||||
for (let i = 0; i < n; i++) hist[gray[i]]++
|
||||
let i = 0
|
||||
while (i < 255 && !hist[i]) i++
|
||||
const out = new Uint8Array(n), first = hist[i]
|
||||
if (first === n) return out.fill(i)
|
||||
const scale = 255 / (n - first), lut = new Uint8Array(256)
|
||||
let sum = 0
|
||||
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
|
||||
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
|
||||
return out
|
||||
}
|
||||
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
|
||||
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
|
||||
for (let y = 0; y < H; y++) {
|
||||
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
|
||||
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
|
||||
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
function scan(cascade, gray, W, H, step, emit) {
|
||||
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
|
||||
for (let wy = 0; wy + ch <= H; wy += step) {
|
||||
for (let wx = 0; wx + cw <= W; wx += step) {
|
||||
let pass = true
|
||||
for (let s = 0; s < stages.length && pass; s++) {
|
||||
const [limit, weak] = stages[s]
|
||||
let sum = 0
|
||||
for (let q = 0; q < weak.length; q++) {
|
||||
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
|
||||
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
|
||||
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
|
||||
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
|
||||
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
|
||||
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
|
||||
const c = p10 - p9 - p6 + p5
|
||||
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
|
||||
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
|
||||
}
|
||||
if (sum < limit) pass = false
|
||||
}
|
||||
if (pass) emit(wx, wy)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
|
||||
function group(rects, minNeighbors, eps = 0.2) {
|
||||
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
|
||||
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
|
||||
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
|
||||
const by = new Map()
|
||||
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
|
||||
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
|
||||
// Quita los que quedan dentro de otro mayor con más apoyos.
|
||||
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
|
||||
}
|
||||
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
|
||||
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
|
||||
const raw = new Uint8Array(w * h)
|
||||
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
|
||||
const gray = equalize(raw), rects = []
|
||||
for (let f = 1; ; f *= scale) {
|
||||
const size = Math.round(cascade.w * f)
|
||||
const W = Math.round(w / f), H = Math.round(h / f)
|
||||
if (W < cascade.w || H < cascade.h) break
|
||||
if (size < minSize) continue
|
||||
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
|
||||
}
|
||||
return group(rects, minNeighbors)
|
||||
}
|
||||
|
||||
let cascade = null
|
||||
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
|
||||
try {
|
||||
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
|
||||
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
|
||||
} catch (e) { self.postMessage({ id, faces: null }) }
|
||||
}
|
||||
+1551
-119
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
|
||||
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
|
||||
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="sw-version" content="">
|
||||
<meta name="b3d-version" content="">
|
||||
<meta name="shelf-version" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#14121a">
|
||||
<title>Manga Reader</title>
|
||||
@@ -21,6 +23,7 @@
|
||||
<nav>
|
||||
<a href="#/" data-icon="home">Inicio</a>
|
||||
<a href="#/library" data-icon="library">Librería</a>
|
||||
<a href="#/shelf" data-icon="shelf">Estante</a>
|
||||
<a href="#/queues" data-icon="queue">Colas</a>
|
||||
<a href="#/clips" data-icon="scissors">Recortes</a>
|
||||
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,13 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.chapters a:hover { background: var(--surface-2); }
|
||||
.chapters a.read { opacity: .5; }
|
||||
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
|
||||
.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
|
||||
.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
|
||||
.chapters .voltoggle > span { display: grid; gap: 2px; }
|
||||
.chapters .voltoggle .meta { text-align: left; }
|
||||
.chapters .volhead > .btn { flex: none; }
|
||||
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
|
||||
|
||||
.history { display: grid; gap: 10px; }
|
||||
@@ -86,6 +93,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
|
||||
.settings { max-width: 480px; display: grid; gap: 16px; }
|
||||
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
|
||||
.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
|
||||
.tableprev[hidden] { display: none; }
|
||||
|
||||
.loading { padding: 40px; text-align: center; color: var(--muted); }
|
||||
|
||||
@@ -397,6 +406,17 @@ body.reading #netbanner { display: none; }
|
||||
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
|
||||
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
|
||||
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
|
||||
/* Hendidura del lomo: la página se funde con el negro de detrás. Los puntos coinciden con gutter() en app.js. */
|
||||
.paged img[data-gutter="l"], .paged img[data-gutter="r"] {
|
||||
--gutter-stops: rgba(0,0,0,.4) 0, rgba(0,0,0,.6) 1.2%, rgba(0,0,0,.8) 3%, rgba(0,0,0,.93) 6%, #000 10%;
|
||||
--gutter-mask: linear-gradient(var(--gutter-side), var(--gutter-stops));
|
||||
-webkit-mask-image: var(--gutter-mask);
|
||||
mask-image: var(--gutter-mask);
|
||||
}
|
||||
.paged img[data-gutter="l"] { --gutter-side: to right; }
|
||||
.paged img[data-gutter="r"] { --gutter-side: to left; }
|
||||
.paged .panmask { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: #000; }
|
||||
.paged .leafkeepback { position: absolute; z-index: 2; pointer-events: none; background: #000; }
|
||||
.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
|
||||
|
||||
/* Info de la obra */
|
||||
@@ -439,3 +459,55 @@ body.reading #netbanner { display: none; }
|
||||
.tag.hot { color: var(--accent); }
|
||||
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
|
||||
.chapters .meta .cdate.approx { opacity: .7; }
|
||||
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
|
||||
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
|
||||
#shelf3d canvas { display: block; width: 100%; height: 100%; }
|
||||
.shelftools { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); display: flex; gap: 8px; }
|
||||
.shelfnote:empty { display: none; } /* vacía no se ve; [hidden] dice que ya llegaron los tomos */
|
||||
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
|
||||
.libhead { justify-content: space-between; align-items: center; }
|
||||
#b3d { position: fixed; inset: 0; z-index: 0; }
|
||||
#b3d[hidden] { display: none; }
|
||||
#b3d canvas { display: block; width: 100%; height: 100%; }
|
||||
.reader.b3d { background: #4a2e19; }
|
||||
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
|
||||
.reader.b3d > #pages img { visibility: hidden; }
|
||||
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
|
||||
.reader.b3d > #pages > * { pointer-events: none; }
|
||||
/* El final del capítulo, sobre la contraportada y con sus colores. */
|
||||
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
|
||||
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
|
||||
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
|
||||
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
|
||||
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
|
||||
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
|
||||
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
|
||||
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
|
||||
/* Para recortar se ve la página plana. */
|
||||
.reader.b3d.b3dcrop { background: #000; }
|
||||
.reader.b3d.b3dcrop > #b3d { display: none; }
|
||||
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
|
||||
/* Vistazo del estante: la nota del tomo sacado, como un pósit pegado junto a él (a la derecha en apaisado, abajo en vertical).
|
||||
Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */
|
||||
.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg;
|
||||
position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
|
||||
padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
|
||||
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np);
|
||||
box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); }
|
||||
.shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); }
|
||||
.shelfpeek .ptitle { margin: 0 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
|
||||
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; }
|
||||
.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; }
|
||||
.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; }
|
||||
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
|
||||
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; }
|
||||
.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); }
|
||||
.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; }
|
||||
@media (orientation: portrait) {
|
||||
.shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); }
|
||||
.shelfpeek.on { transform: rotate(var(--nt)); }
|
||||
}
|
||||
body.handing .reader .loading { visibility: hidden; }
|
||||
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
|
||||
.handoff.out { opacity: 0; }
|
||||
@media (max-width: 420px) { #topbar nav a { padding: 8px 7px; } }
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
|
||||
const SHELL = 'shell-v1'
|
||||
const OFFLINE = 'offline-v1'
|
||||
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
||||
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
||||
|
||||
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
|
||||
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
|
||||
@@ -21,7 +21,7 @@ self.addEventListener('install', e => {
|
||||
})
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(caches.keys()
|
||||
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE).map(k => caches.delete(k))))
|
||||
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
|
||||
.then(() => self.clients.claim()))
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ self.addEventListener('fetch', e => {
|
||||
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
|
||||
if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true }))
|
||||
// Imágenes: si están guardadas, sin red (más rápido y sin gastar datos).
|
||||
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/')) {
|
||||
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) {
|
||||
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
|
||||
}
|
||||
})
|
||||
|
||||
+2
-1
@@ -128,7 +128,8 @@ async function hostIsPublic(hostname) {
|
||||
const hit = dnsCache.get(hostname)
|
||||
if (hit && hit.exp > Date.now()) return hit.ok
|
||||
const addrs = await dns.lookup(hostname, { all: true }).catch(() => [])
|
||||
const ok = addrs.length > 0 && addrs.every(a => !isPrivateIp(a.address))
|
||||
if (!addrs.length) return false // fallo de DNS: puede ser pasajero, no se recuerda
|
||||
const ok = addrs.every(a => !isPrivateIp(a.address))
|
||||
dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 })
|
||||
if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value)
|
||||
return ok
|
||||
|
||||
+101
-10
@@ -15,6 +15,10 @@ import { setupSecurity, safeFetch } from './security.js'
|
||||
import { setupM440, MI_BASE } from './m440.js'
|
||||
import { setupConnectivity } from './connectivity.js'
|
||||
import { setupMeta } from './meta.js'
|
||||
import { setupVolumes } from './volumes.js'
|
||||
import { setupCredits } from './credits.js'
|
||||
import { setupShelf } from './shelf.js'
|
||||
import { setupBooks, isBookId } from './books.js'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3352)
|
||||
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
||||
@@ -162,10 +166,12 @@ const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex'))
|
||||
function getUser(req) {
|
||||
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
|
||||
if (!m) return null
|
||||
return db.prepare(`SELECT u.id, u.username, u.lang FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
return db.prepare(`SELECT u.id, u.username, u.lang, u.content_mode FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null
|
||||
}
|
||||
|
||||
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'content_mode')) db.exec("ALTER TABLE users ADD COLUMN content_mode TEXT NOT NULL DEFAULT 'both'")
|
||||
|
||||
// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ----------
|
||||
const cache = new Map()
|
||||
async function cached(key, ttl, fn) {
|
||||
@@ -456,7 +462,7 @@ const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
|
||||
const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net })
|
||||
const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl)
|
||||
// Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex.
|
||||
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : 'md')
|
||||
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : /^(gb|ws|ub)~/.test(id) ? id.slice(0, 2) : 'md')
|
||||
const spanishOnly = id => sourceOf(id) !== 'md'
|
||||
|
||||
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
|
||||
@@ -539,6 +545,7 @@ async function fetchMangaFull(mangaId, lang) {
|
||||
const src = sourceOf(mangaId)
|
||||
return src === 'lc' ? lcManga(mangaId)
|
||||
: src === 'mi' ? mi.manga(mangaId)
|
||||
: isBookId(mangaId) ? books.manga(mangaId)
|
||||
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
|
||||
}
|
||||
|
||||
@@ -767,6 +774,45 @@ function json(res, status, body, headers = {}) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
// Fondo de la mesa del libro 3D, por usuario. El navegador lo reduce antes de subirlo; aquí solo se aceptan estos formatos, mirando el contenido.
|
||||
const TABLE_DIR = path.join(DATA_DIR, 'tables'), TABLE_MAX = 24 * 1024 * 1024
|
||||
const tableType = b => {
|
||||
if (b.length < 16) return null
|
||||
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
||||
if (b.readUInt32BE(0) === 0x89504e47) return 'image/png'
|
||||
if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
fs.mkdirSync(TABLE_DIR, { recursive: true })
|
||||
const tableFile = id => path.join(TABLE_DIR, `${Number(id)}.img`)
|
||||
const tableVersion = id => { try { return Math.round(fs.statSync(tableFile(id)).mtimeMs) } catch { return 0 } }
|
||||
async function tableRoute(method, req, res, user, q) {
|
||||
const file = tableFile(user.id)
|
||||
if (method === 'GET') {
|
||||
let buf
|
||||
try { buf = fs.readFileSync(file) } catch { throw new HttpError(404, 'Sin fondo') }
|
||||
res.writeHead(200, { 'Content-Type': tableType(buf) || 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': q.get('v') ? 'private, max-age=31536000, immutable' : 'private, no-cache' })
|
||||
return res.end(buf)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
fs.rmSync(file, { force: true })
|
||||
return json(res, 200, { tableV: 0 })
|
||||
}
|
||||
if (method !== 'POST') throw new HttpError(405, 'Método no permitido')
|
||||
let size = 0
|
||||
const chunks = []
|
||||
for await (const c of req) {
|
||||
size += c.length
|
||||
if (size > TABLE_MAX) throw new HttpError(413, 'La imagen es demasiado grande')
|
||||
chunks.push(c)
|
||||
}
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (!tableType(buf)) throw new HttpError(400, 'La imagen debe ser JPEG, PNG o WebP')
|
||||
const tmp = `${file}.tmp`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, file)
|
||||
return json(res, 200, { tableV: tableVersion(user.id) })
|
||||
}
|
||||
async function readBody(req) {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
@@ -777,8 +823,8 @@ async function readBody(req) {
|
||||
}
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') }
|
||||
}
|
||||
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+)$/
|
||||
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+)$/
|
||||
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7}|ws~[A-Za-z0-9_-]{1,400}|ub~[0-9a-f]{32})$/
|
||||
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5}|ws~[A-Za-z0-9_-]{1,400}~\d{1,5}|ub~[0-9a-f]{32}~\d{1,5})$/
|
||||
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
|
||||
// Manga en el historial o en seguidos del usuario (sus portadas se reescalan).
|
||||
const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ?
|
||||
@@ -789,7 +835,7 @@ const requireUser = req => {
|
||||
return u
|
||||
}
|
||||
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.json': 'application/json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
|
||||
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
|
||||
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
|
||||
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
|
||||
@@ -797,6 +843,8 @@ const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(
|
||||
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
|
||||
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
|
||||
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
|
||||
.replace('<meta name="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
|
||||
.replace('<meta name="shelf-version" content="">', `<meta name="shelf-version" content="${assetVersion('shelf3d.js')}${assetVersion('animeface.js')}">`)
|
||||
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
|
||||
function serveStatic(res, pathname) {
|
||||
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
||||
@@ -832,7 +880,8 @@ async function route(req, res) {
|
||||
// --- Auth ---
|
||||
if (p === '/api/me' && method === 'GET') {
|
||||
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
|
||||
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
|
||||
const u = getUser(req)
|
||||
return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 })
|
||||
}
|
||||
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
|
||||
rateLimit(ip)
|
||||
@@ -869,15 +918,22 @@ async function route(req, res) {
|
||||
|
||||
// Todo lo demás requiere sesión.
|
||||
const user = requireUser(req)
|
||||
if (p === '/api/table') return tableRoute(method, req, res, user, q)
|
||||
// Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre.
|
||||
let rawUrl = req.url
|
||||
try { rawUrl = decodeURIComponent(req.url) } catch {}
|
||||
for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado')
|
||||
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
|
||||
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
||||
if ((await credits.route(p, method, req, res, user)) !== false) return
|
||||
if (shelf.route(p, method, res, user) !== false) return
|
||||
if ((await upscale.route(p, method, req, res)) !== false) {
|
||||
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
|
||||
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
|
||||
return
|
||||
}
|
||||
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return
|
||||
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
|
||||
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && !isBookId(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
|
||||
if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return
|
||||
|
||||
if (p === '/api/settings' && method === 'POST') {
|
||||
@@ -886,6 +942,10 @@ async function route(req, res) {
|
||||
if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido')
|
||||
db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id)
|
||||
}
|
||||
if (body.contentMode !== undefined) {
|
||||
if (!['both', 'manga', 'books'].includes(body.contentMode)) throw new HttpError(400, 'Valor inválido')
|
||||
db.prepare('UPDATE users SET content_mode = ? WHERE id = ?').run(body.contentMode, user.id)
|
||||
}
|
||||
if (body.newPassword !== undefined) {
|
||||
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
|
||||
if (!verifyPassword(String(body.oldPassword || '').slice(0, 256), row.pass_hash)) { security.fail(ip, 'contraseña actual incorrecta'); throw new HttpError(401, 'Contraseña actual incorrecta') }
|
||||
@@ -919,12 +979,15 @@ async function route(req, res) {
|
||||
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
|
||||
|
||||
if (p === '/api/categories' && method === 'GET') {
|
||||
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' ? [] : await mdCategories())
|
||||
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : ['mi', 'gb', 'ws', 'ub'].includes(q.get('source')) ? [] : await mdCategories())
|
||||
}
|
||||
if (p === '/api/search' && method === 'GET') {
|
||||
const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500)
|
||||
const tag = q.get('tag') || ''
|
||||
if (q.get('source') === 'ub') return json(res, 200, books.ubList(user.id, (q.get('q') || '').slice(0, 100))) // no necesita internet
|
||||
if (!net.online()) return json(res, 200, localSearch(q.get('q') || ''))
|
||||
if (q.get('source') === 'ws') return json(res, 200, await books.wsSearch((q.get('q') || '').slice(0, 100), q.get('mode'), page))
|
||||
if (q.get('source') === 'gb') return json(res, 200, await books.search((q.get('q') || '').slice(0, 100), q.get('mode'), page, user.lang))
|
||||
if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page))
|
||||
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page))
|
||||
const anyLang = q.get('anyLang') === '1'
|
||||
@@ -933,6 +996,17 @@ async function route(req, res) {
|
||||
}
|
||||
|
||||
let m
|
||||
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||
return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
|
||||
}
|
||||
if ((m = /^\/api\/logo\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||
return json(res, 200, { logo: isBookId(m[1]) ? null : await volumes.logo(m[1], lang).catch(() => null) })
|
||||
}
|
||||
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
|
||||
let vol
|
||||
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
|
||||
return volumes.sendFace(res, m[1], vol, m[3])
|
||||
}
|
||||
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||
let manga, chapters, offline = false
|
||||
try {
|
||||
@@ -951,9 +1025,21 @@ async function route(req, res) {
|
||||
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
|
||||
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
|
||||
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, hideCredits: credits.enabled(user.id, m[1]), download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
}
|
||||
if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user))
|
||||
if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') {
|
||||
books.removeUpload(m[1], user.id)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if ((m = /^\/api\/book\/([^/]+)\/cover$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendCover(res, m[1])
|
||||
if ((m = /^\/api\/book\/([^/]+)\/text$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendText(req, res, m[1])
|
||||
if ((m = /^\/api\/book\/([^/]+)\/img\/(\d{1,5})$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendImage(res, m[1], Number(m[2]))
|
||||
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
|
||||
if (isBookId(m[1])) {
|
||||
const read = db.prepare('SELECT page, total_pages FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
|
||||
return json(res, 200, { text: true, ...(await books.chapter(m[1])), pages: [], savedPage: read?.page ?? 0, savedTotal: read?.total_pages ?? 0, local: false, hd: false, hdAvailable: false })
|
||||
}
|
||||
const hdAvailable = !!upscale.hdFileOf(m[1])
|
||||
const hd = hdAvailable && q.get('hd') !== '0'
|
||||
const local = localChapter(m[1], hd)
|
||||
@@ -1013,6 +1099,7 @@ async function route(req, res) {
|
||||
return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) })
|
||||
}
|
||||
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
if (isBookId(m[1])) throw new HttpError(400, 'Los libros no se descargan como capítulos')
|
||||
const b = await readBody(req)
|
||||
const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
|
||||
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
|
||||
@@ -1066,7 +1153,7 @@ async function route(req, res) {
|
||||
.run(user.id, b.chapterId, b.mangaId, page, total, now)
|
||||
if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId)
|
||||
features.onProgress(user.id, b, page, total, prevPage)
|
||||
library.onProgress(user.id, b, page, total)
|
||||
if (!isBookId(b.mangaId)) library.onProgress(user.id, b, page, total)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
||||
@@ -1080,6 +1167,7 @@ async function route(req, res) {
|
||||
}
|
||||
|
||||
const security = setupSecurity({ db })
|
||||
const books = setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch })
|
||||
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net })
|
||||
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
|
||||
const covers = setupCovers({ DATA_DIR, upscale })
|
||||
@@ -1087,6 +1175,9 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
|
||||
// Al volver internet: reanudar las descargas en cola.
|
||||
net.whenOnline(() => pumpDownloads())
|
||||
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
|
||||
const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
|
||||
const shelf = setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS })
|
||||
const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
|
||||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||
|
||||
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Estante 3D: las obras de un usuario que pueden ir en el mueble. Los tomos de cada una se piden aparte (/api/volumes), porque pueden tardar.
|
||||
import { hasNoVolumes } from './volumes.js'
|
||||
|
||||
export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) {
|
||||
function works(userId) {
|
||||
const out = new Map()
|
||||
const add = (id, fields) => {
|
||||
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null }
|
||||
w.title ||= fields.title || ''
|
||||
w.cover ||= fields.cover || null
|
||||
w.last = Math.max(w.last, fields.at || 0)
|
||||
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null })
|
||||
out.set(id, w)
|
||||
}
|
||||
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
|
||||
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at })
|
||||
}
|
||||
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
|
||||
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })
|
||||
}
|
||||
for (const r of db.prepare(`SELECT manga_id, title, folder, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS}`).all(userId)) {
|
||||
add(r.manga_id, { title: r.title, cover: downloadCover(r.manga_id, r.folder, r.meta_cover), at: r.updated_at })
|
||||
}
|
||||
for (const r of db.prepare('SELECT i.manga_id, i.title, i.cover, i.added_at FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?').all(userId)) {
|
||||
add(r.manga_id, { title: r.title, cover: r.cover, at: r.added_at })
|
||||
}
|
||||
const typeOf = db.prepare('SELECT type FROM manga_meta WHERE manga_id = ?')
|
||||
return [...out.values()].filter(w => !isBookId(w.id) && !hasNoVolumes(typeOf.get(w.id)?.type)).sort((a, b) => b.last - a.last)
|
||||
}
|
||||
function route(p, method, res, user) {
|
||||
if (p !== '/api/shelf' || method !== 'GET') return false
|
||||
json(res, 200, { works: works(user.id) })
|
||||
return true
|
||||
}
|
||||
return { route, works }
|
||||
}
|
||||
+770
@@ -0,0 +1,770 @@
|
||||
// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
|
||||
// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
|
||||
// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
|
||||
// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
|
||||
// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
|
||||
// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
|
||||
// Lo descargado queda en <DATA_DIR>/tomes/<id de MangaDex>/<tomo>-<cara>.img.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const DAY = 24 * 3600e3
|
||||
const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
|
||||
const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
|
||||
const AMAZON = 'https://m.media-amazon.com/images/P'
|
||||
const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
|
||||
const MAX_IMAGE = 30 * 1024 * 1024
|
||||
const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
|
||||
const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
|
||||
const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
|
||||
// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
|
||||
export const volKey = v => {
|
||||
const s = String(v ?? '').trim()
|
||||
if (!s || s.toLowerCase() === 'none') return null
|
||||
return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
|
||||
}
|
||||
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
|
||||
const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
|
||||
|
||||
// Título comparable: sin signos, espacios ni diferencias de anchura.
|
||||
export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
export function ndlVolume(s) {
|
||||
const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
|
||||
return m ? String(parseInt(m[1], 10)) : null
|
||||
}
|
||||
export function isbn10(raw) {
|
||||
const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
|
||||
if (/^\d{9}[\dX]$/.test(s)) return s
|
||||
if (!/^978\d{10}$/.test(s)) return null
|
||||
const body = s.slice(3, 12)
|
||||
let sum = 0
|
||||
for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
|
||||
const c = (11 - (sum % 11)) % 11
|
||||
return body + (c === 10 ? 'X' : String(c))
|
||||
}
|
||||
const xmlText = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim()
|
||||
|
||||
// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
|
||||
// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
|
||||
export function pickIsbns(xmlPages, titles, year) {
|
||||
const keys = new Set(titles.map(titleKey).filter(Boolean))
|
||||
const groups = new Map()
|
||||
for (const xml of xmlPages) {
|
||||
for (const [, it] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
||||
if (!keys.has(titleKey(xmlText(/<dc:title>([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
|
||||
const vol = ndlVolume(xmlText(/<dcndl:volume>([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
|
||||
if (!vol) continue
|
||||
const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+)</g)].map(m => isbn10(m[1])).filter(Boolean)
|
||||
if (!isbns.length) continue
|
||||
const author = titleKey(xmlText(/<author>([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
|
||||
const when = parseInt(/<dc:date[^>]*>(\d{4})/.exec(it)?.[1], 10) || null
|
||||
if (!groups.has(author)) groups.set(author, new Map())
|
||||
const g = groups.get(author)
|
||||
if (!g.has(vol)) g.set(vol, { isbns: [], when })
|
||||
const e = g.get(vol)
|
||||
for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
|
||||
if (when && (!e.when || when < e.when)) e.when = when
|
||||
}
|
||||
}
|
||||
const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
|
||||
const best = [...groups.values()]
|
||||
.filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
|
||||
.sort((a, b) => b.size - a.size)[0]
|
||||
if (!best) return {}
|
||||
return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
|
||||
}
|
||||
|
||||
// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
|
||||
// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
|
||||
// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
|
||||
const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
|
||||
export function pickEnglishIsbns(docs, titles, vol) {
|
||||
const out = []
|
||||
for (const title of titles) {
|
||||
const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
|
||||
for (const d of docs) {
|
||||
if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
|
||||
for (const x of d.isbn || []) {
|
||||
const i = isbn10(x)
|
||||
if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
|
||||
export function pickCovers(records, lang) {
|
||||
const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
|
||||
const out = {}
|
||||
for (const r of records) {
|
||||
const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
|
||||
if (!vol || !file) continue
|
||||
if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function imageKind(b) {
|
||||
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
||||
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
||||
if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
|
||||
/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
|
||||
* Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
|
||||
export function strayNums(nums) {
|
||||
const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
|
||||
if (known.length < 3) return new Set()
|
||||
const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
|
||||
const center = middle(known)
|
||||
const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
|
||||
const limit = Math.max(4 * spread, 5)
|
||||
const far = known.filter(n => Math.abs(n - center) > limit)
|
||||
return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
|
||||
}
|
||||
|
||||
/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
|
||||
export function cleanVolMap(map) {
|
||||
const byVol = new Map()
|
||||
for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
|
||||
const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
|
||||
const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
|
||||
const usual = sizes[(sizes.length - 1) >> 1]
|
||||
const out = {}
|
||||
for (const [v, nums] of byVol) {
|
||||
const size = whole(nums)
|
||||
if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
|
||||
const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
|
||||
for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
|
||||
export function volMapsAgree(a, b) {
|
||||
const shared = Object.keys(a).filter(n => n in b)
|
||||
if (shared.length < 5) return true
|
||||
return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
|
||||
}
|
||||
|
||||
/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
|
||||
export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
|
||||
const merged = {}
|
||||
// M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
|
||||
const shown = new Set(Object.values(mi).map(Number))
|
||||
const inMu = new Set(Object.values(mu).map(Number))
|
||||
const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
|
||||
for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
|
||||
if (!volMapsAgree(src, merged)) continue
|
||||
for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
|
||||
export function parseMuVolumes(rows) {
|
||||
const votes = {}
|
||||
for (const { volume, chapter } of rows) {
|
||||
const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
|
||||
if (!vol) continue
|
||||
const ch = String(chapter ?? '').trim()
|
||||
const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
|
||||
const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
|
||||
const nums = one ? [parseFloat(one[1])] : []
|
||||
if (range) {
|
||||
const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
|
||||
if (hi >= lo && hi - lo < 50) {
|
||||
nums.push(lo, hi)
|
||||
for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
|
||||
}
|
||||
}
|
||||
for (const n of new Set(nums)) {
|
||||
const count = (votes[n] ||= {})
|
||||
count[vol] = (count[vol] || 0) + 1
|
||||
}
|
||||
}
|
||||
const map = {}
|
||||
for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
|
||||
return map
|
||||
}
|
||||
|
||||
/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
|
||||
* si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
|
||||
export function orderedMuMap(map, total = 0) {
|
||||
const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
const vol = nums.map(n => parseInt(map[n], 10))
|
||||
// La subsecuencia no decreciente más larga.
|
||||
const len = vol.map(() => 1), from = vol.map(() => -1)
|
||||
let end = 0
|
||||
vol.forEach((v, i) => {
|
||||
for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
|
||||
if (len[i] > len[end]) end = i
|
||||
})
|
||||
const out = {}
|
||||
for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
|
||||
const size = Object.keys(out).length
|
||||
return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
|
||||
}
|
||||
|
||||
/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
|
||||
* antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
|
||||
export function fillVolGaps(map, nums) {
|
||||
const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
|
||||
const out = { ...map }
|
||||
for (const key of nums) {
|
||||
if (key == null || key in map) continue
|
||||
const n = parseFloat(key)
|
||||
const prev = anchors.filter(a => a < n).at(-1)
|
||||
const next = anchors.find(a => a > n)
|
||||
if (next === undefined) continue
|
||||
const after = map[next]
|
||||
if (prev === undefined) {
|
||||
if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
|
||||
continue
|
||||
}
|
||||
const before = map[prev]
|
||||
if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const ownVolMap = chapters => {
|
||||
const map = {}
|
||||
for (const c of chapters) {
|
||||
const n = numKey(c.num), v = volKey(c.volume)
|
||||
if (n && v && !(n in map)) map[n] = v
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
|
||||
export function numbersRestart(chapters) {
|
||||
const seen = new Map()
|
||||
let repeated = 0
|
||||
for (const c of chapters) {
|
||||
const n = numKey(c.num), v = volKey(c.volume)
|
||||
if (!n || !v) continue
|
||||
if (seen.has(n) && seen.get(n) !== v) repeated++
|
||||
else seen.set(n, v)
|
||||
}
|
||||
return repeated >= 3
|
||||
}
|
||||
|
||||
/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
|
||||
* y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
|
||||
export function assignVolumes(chapters, map = {}) {
|
||||
const vols = chapters.map(c => map[numKey(c.num)] ?? null)
|
||||
vols.forEach((v, i) => {
|
||||
const n = parseFloat(chapters[i].num)
|
||||
if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
|
||||
})
|
||||
let last = -1
|
||||
vols.forEach((v, i) => {
|
||||
if (!v) return
|
||||
if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
|
||||
last = i
|
||||
})
|
||||
return vols
|
||||
}
|
||||
|
||||
/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
|
||||
* (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
|
||||
export function dropMisplaced(vols, maxRun = 3) {
|
||||
const out = [...vols]
|
||||
if (!vols.every(v => !v || /^\d+$/.test(v))) return out
|
||||
const runsOf = () => {
|
||||
const runs = []
|
||||
out.forEach((v, i) => {
|
||||
if (!v) return
|
||||
if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
|
||||
else runs.push({ v: parseInt(v, 10), at: [i] })
|
||||
})
|
||||
return runs
|
||||
}
|
||||
// De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
|
||||
for (;;) {
|
||||
const runs = runsOf()
|
||||
const wrong = runs.filter((r, j) => {
|
||||
if (r.at.length > maxRun) return false
|
||||
const [a, b] = [runs[j - 1], runs[j + 1]]
|
||||
if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
|
||||
const near = a || b
|
||||
return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
|
||||
})
|
||||
if (!wrong.length) return out
|
||||
const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
|
||||
for (const i of hit.at) out[i] = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
|
||||
* known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
|
||||
export function placeLoose(chapters, vols, known = []) {
|
||||
const out = dropMisplaced(vols)
|
||||
const isNum = v => /^\d+$/.test(v ?? '')
|
||||
const floorOf = i => Math.floor(parseFloat(chapters[i].num))
|
||||
// Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
|
||||
const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
|
||||
const spread = (from, to, names) => {
|
||||
let start = from
|
||||
names.forEach((name, j) => {
|
||||
const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
|
||||
for (let i = start; i < end; i++) out[i] = name
|
||||
start = Math.max(start, end)
|
||||
})
|
||||
}
|
||||
const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
|
||||
const assigned = out.flatMap((v, i) => (v ? [i] : []))
|
||||
if (!assigned.length) {
|
||||
if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
|
||||
return out
|
||||
}
|
||||
const count = {}
|
||||
for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
|
||||
const sizes = Object.values(count).sort((a, b) => a - b)
|
||||
const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
|
||||
const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
|
||||
const free = []
|
||||
for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
|
||||
return free
|
||||
}
|
||||
// Antes del primer tomo.
|
||||
const first = assigned[0]
|
||||
if (first) {
|
||||
const head = out[first]
|
||||
const free = isNum(head) ? between(0, parseInt(head, 10)) : []
|
||||
const n = Math.min(free.length, Math.round(first / usual))
|
||||
if (n) spread(0, first, free.slice(-n))
|
||||
else for (let i = 0; i < first; i++) out[i] = head
|
||||
}
|
||||
// Entre dos tomos.
|
||||
for (let a = 0; a < assigned.length - 1; a++) {
|
||||
const [l, r] = [assigned[a], assigned[a + 1]]
|
||||
if (r - l < 2) continue
|
||||
const [left, right] = [out[l], out[r]]
|
||||
const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
|
||||
if (free.length) {
|
||||
// Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
|
||||
const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
|
||||
const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
|
||||
const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
|
||||
for (let i = l + 1; i < from; i++) out[i] = left
|
||||
for (let i = to; i < r; i++) out[i] = right
|
||||
spread(from, to, free)
|
||||
count[left] += from - l - 1
|
||||
count[right] += r - to
|
||||
continue
|
||||
}
|
||||
if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
|
||||
for (let i = l + 1; i < r; i++) out[i] = left
|
||||
continue
|
||||
}
|
||||
// Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
|
||||
const whole = []
|
||||
for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
|
||||
if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
|
||||
const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
|
||||
const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
|
||||
const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
|
||||
for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
|
||||
count[left] += at - l - 1
|
||||
count[right] += r - at
|
||||
}
|
||||
// Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
|
||||
const end = assigned.at(-1)
|
||||
const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
|
||||
const rest = chapters.length - end - 1
|
||||
if (free.length && rest) {
|
||||
const fits = rest <= free.length * usual * 1.3
|
||||
spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
|
||||
function numberedListCount(text, from) {
|
||||
let depth = 0, bars = 0
|
||||
for (let i = from; i < text.length - 1; i++) {
|
||||
const two = text.slice(i, i + 2)
|
||||
if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
|
||||
if (--depth < 0) break
|
||||
i++
|
||||
} else if (text[i] === '|' && depth === 0) bars++
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
|
||||
* "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
|
||||
* empezar por el tomo 1, se ignoran. */
|
||||
export function parseWikiVolumes(text) {
|
||||
const map = {}, seen = new Set()
|
||||
let next = 1
|
||||
for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
|
||||
const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
|
||||
if (!vol) continue
|
||||
if (seen.has(vol)) break
|
||||
seen.add(vol)
|
||||
const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
|
||||
if (!fields.length) continue
|
||||
const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
|
||||
for (const f of fields) {
|
||||
const start = f.index + f[0].length
|
||||
const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
|
||||
const body = block.slice(start, end < 0 ? undefined : start + end)
|
||||
const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
|
||||
if (numbered.length) {
|
||||
for (const m of numbered) {
|
||||
const first = parseInt(m[1], 10)
|
||||
for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
|
||||
if (range) {
|
||||
for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
|
||||
continue
|
||||
}
|
||||
for (const line of body.split('\n')) {
|
||||
if (!/^\s*[*#]/.test(line)) continue
|
||||
const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
|
||||
add(own ? parseInt(own[1], 10) : next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
|
||||
export function workTitle(title) {
|
||||
const t = String(title || '').trim()
|
||||
const base = t
|
||||
.replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
|
||||
.replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
|
||||
.replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
|
||||
return base.length >= 3 ? base : t
|
||||
}
|
||||
|
||||
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
|
||||
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
|
||||
|
||||
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
|
||||
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
|
||||
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
|
||||
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
|
||||
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
|
||||
export function pickLogo(files, names) {
|
||||
let best = null, bestScore = 0
|
||||
files.forEach((file, i) => {
|
||||
if (!/\.(png|svg|webp)$/i.test(file)) return
|
||||
const fw = logoWords(file.replace(/^File:/, ''))
|
||||
if (!fw.includes('logo')) return
|
||||
names.forEach((name, k) => {
|
||||
const nw = logoWords(name)
|
||||
if (!nw.length || nw.join('').length < 4) return
|
||||
const hit = nw.filter(w => fw.includes(w)).length / nw.length
|
||||
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
|
||||
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
|
||||
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
|
||||
})
|
||||
})
|
||||
return best
|
||||
}
|
||||
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
|
||||
|
||||
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
|
||||
const DIR = path.join(DATA_DIR, 'tomes')
|
||||
|
||||
const fetchImage = async url => {
|
||||
net.guard()
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
|
||||
if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
|
||||
if (!r.ok) return null
|
||||
const b = Buffer.from(await r.arrayBuffer())
|
||||
return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
|
||||
}
|
||||
|
||||
// ---------- Lo que dice MangaDex de la obra ----------
|
||||
async function mdVolumes(mdId, lang) {
|
||||
return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
|
||||
const [agg, mangaData] = await Promise.all([
|
||||
md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
|
||||
md(`/manga/${mdId}`, {}, 30 * 60e3),
|
||||
])
|
||||
const byNum = {}
|
||||
const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
|
||||
for (const v of vols) {
|
||||
const vol = volKey(v.volume)
|
||||
if (!vol) continue
|
||||
const inVol = Object.values(v.chapters || {})
|
||||
const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
|
||||
for (const c of inVol) {
|
||||
const n = numKey(c.chapter)
|
||||
if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
|
||||
}
|
||||
}
|
||||
const records = []
|
||||
for (let offset = 0; offset < 1000; offset += 100) {
|
||||
const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
|
||||
records.push(...r.data)
|
||||
if (offset + 100 >= r.total || !r.data.length) break
|
||||
}
|
||||
const a = mangaData.data.attributes
|
||||
const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
|
||||
const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
|
||||
const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
|
||||
return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
|
||||
}, true)
|
||||
}
|
||||
|
||||
let gate = Promise.resolve()
|
||||
const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
|
||||
|
||||
// ---------- Otras fuentes del reparto ----------
|
||||
async function wikiPages(titles) {
|
||||
if (!titles.length) return []
|
||||
await turn() // Wikipedia corta a quien va demasiado rápido
|
||||
net.guard()
|
||||
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
|
||||
const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
let r = await get()
|
||||
if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
|
||||
await new Promise(res => setTimeout(res, 3000))
|
||||
r = await get()
|
||||
}
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
|
||||
return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
|
||||
}
|
||||
async function wikiSearch(names) {
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
|
||||
const wanted = new Set(names.map(wikiKey))
|
||||
return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
|
||||
}
|
||||
function wikiMap(mangaId, names) {
|
||||
return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
|
||||
const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
|
||||
let best = {}
|
||||
let pages = await wikiPages(titles)
|
||||
if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
|
||||
for (const text of pages) {
|
||||
// One Piece y otras largas reparten la lista en subpáginas.
|
||||
const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
|
||||
let found = parseWikiVolumes(text)
|
||||
for (let i = 0; i < subs.length; i += 10) {
|
||||
for (const sub of await wikiPages(subs.slice(i, i + 10))) {
|
||||
const part = parseWikiVolumes(sub)
|
||||
found = { ...part, ...found }
|
||||
}
|
||||
}
|
||||
if (Object.keys(found).length > Object.keys(best).length) best = found
|
||||
}
|
||||
return best
|
||||
}, true)
|
||||
}
|
||||
function m440Map(mangaId, names) {
|
||||
return stored(`mimap:${mangaId}`, DAY, async () => {
|
||||
for (const name of names.slice(0, 2)) {
|
||||
const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
|
||||
if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
|
||||
}
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- ISBN de cada tomo ----------
|
||||
async function ndlSearch(title) {
|
||||
const pages = []
|
||||
for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
|
||||
const xml = await r.text()
|
||||
pages.push(xml)
|
||||
if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
|
||||
}
|
||||
return pages
|
||||
}
|
||||
async function isbnsOf(mdId) {
|
||||
return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
|
||||
const d = await mdVolumes(mdId)
|
||||
if (d.original !== 'ja' || !d.jaTitles.length) return {}
|
||||
for (const t of d.jaTitles.slice(0, 3)) {
|
||||
const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
|
||||
if (Object.keys(found).length) return found
|
||||
}
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
async function englishIsbns(mdId, vol) {
|
||||
const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
|
||||
if (!titles.length) return []
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
|
||||
return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
|
||||
}
|
||||
|
||||
// ---------- Imágenes de un tomo ----------
|
||||
async function fetchFace(mdId, vol, side) {
|
||||
const volNum = /^\d+$/.test(vol) ? vol : null
|
||||
if (side === 'front') {
|
||||
const c = (await mdVolumes(mdId)).covers[vol]
|
||||
if (c) {
|
||||
const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
|
||||
if (b) return b
|
||||
}
|
||||
}
|
||||
if (!volNum) return null
|
||||
for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
|
||||
const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
|
||||
if (b) return b
|
||||
}
|
||||
if (side === 'back') {
|
||||
for (const isbn of await englishIsbns(mdId, volNum)) {
|
||||
const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
|
||||
if (b) return b
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const inflight = new Map()
|
||||
function face(mdId, vol, side) {
|
||||
const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
|
||||
const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
|
||||
if (fs.existsSync(file)) return Promise.resolve(file)
|
||||
const key = `tome:${mdId}:${vol}:${label}`
|
||||
if (!inflight.has(key)) {
|
||||
// Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
|
||||
inflight.set(key, stored(key, 14 * DAY, async () => {
|
||||
const b = await fetchFace(mdId, vol, side)
|
||||
if (!b) return { found: false }
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
fs.writeFileSync(tmp, b)
|
||||
fs.renameSync(tmp, file)
|
||||
return { found: true }
|
||||
}, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
|
||||
}
|
||||
return inflight.get(key)
|
||||
}
|
||||
|
||||
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
|
||||
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
|
||||
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
|
||||
async function commonsGet(params) {
|
||||
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
|
||||
await turn()
|
||||
net.guard()
|
||||
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
const text = r?.ok ? await r.text() : ''
|
||||
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
|
||||
const data = text.startsWith('{') ? JSON.parse(text) : null
|
||||
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
|
||||
return data
|
||||
}
|
||||
async function commonsLogo(names) {
|
||||
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
|
||||
for (const q of queries) {
|
||||
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
|
||||
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
|
||||
if (!best) continue
|
||||
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
|
||||
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
|
||||
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
|
||||
function logo(mangaId, lang) {
|
||||
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
|
||||
const { manga } = await fetchMangaFull(mangaId, lang)
|
||||
if (hasNoVolumes(manga.type)) return null
|
||||
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
|
||||
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
|
||||
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
|
||||
return commonsLogo(names)
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- API ----------
|
||||
async function list(mangaId, lang) {
|
||||
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
|
||||
if (hasNoVolumes(manga.type)) return []
|
||||
const source = sourceOf(mangaId)
|
||||
// Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
|
||||
const found = await mdMatches(mangaId).catch(() => [])
|
||||
const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
|
||||
const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
|
||||
const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
|
||||
const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
|
||||
if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
|
||||
const mdId = source === 'md' ? mangaId : best?.id || null
|
||||
const data = editions.find(e => e.id === mdId)?.data || null
|
||||
const own = cleanVolMap(ownVolMap(chapters))
|
||||
let vols
|
||||
if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
|
||||
else {
|
||||
const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
|
||||
const [wiki, mi] = await Promise.all([
|
||||
wikiMap(mangaId, names).catch(() => ({})),
|
||||
source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
|
||||
])
|
||||
const byNum = cleanVolMap(best?.data.byNum || {})
|
||||
const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
|
||||
// Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
|
||||
const known = { ...main.wiki, ...main.md }
|
||||
const missing = chapters.filter(c => !(numKey(c.num) in known)).length
|
||||
const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
|
||||
vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
|
||||
}
|
||||
vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
|
||||
const groups = new Map()
|
||||
vols.forEach((vol, i) => {
|
||||
if (!vol) return
|
||||
if (!groups.has(vol)) groups.set(vol, [])
|
||||
groups.get(vol).push(chapters[i].id)
|
||||
})
|
||||
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
|
||||
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
|
||||
return [...groups.keys()].sort(volOrder).map(vol => {
|
||||
const cover = data?.covers[vol]
|
||||
return {
|
||||
vol,
|
||||
chapters: groups.get(vol),
|
||||
pages: pagesOf(groups.get(vol)),
|
||||
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
|
||||
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
|
||||
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
|
||||
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
async function sendFace(res, mdId, vol, side) {
|
||||
if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
|
||||
const file = await face(mdId, vol, side)
|
||||
if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
|
||||
const head = Buffer.alloc(16)
|
||||
const fd = fs.openSync(file, 'r')
|
||||
fs.readSync(fd, head, 0, 16, 0)
|
||||
fs.closeSync(fd)
|
||||
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
|
||||
fs.createReadStream(file).pipe(res)
|
||||
}
|
||||
return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ services:
|
||||
- "${RENDER_GID:-render}"
|
||||
ports:
|
||||
# 0.0.0.0 no depende de que la IP de la LAN o de Tailscale existan al arrancar.
|
||||
# Para limitarlo a una interfaz: BIND_IP=192.168.1.141 en .env.
|
||||
# Para limitarlo a una interfaz: BIND_IP=192.0.2.10 en .env.
|
||||
- "${BIND_IP:-0.0.0.0}:${HOST_PORT:-3352}:3352"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
|
||||
Reference in New Issue
Block a user