Author SHA1 Message Date
ninoandClaude Opus 5.5 52e9edc214 Recortes con puntos en las esquinas y captura larga en lectura vertical
En lectura vertical la zona sigue creciendo al llegar al borde y une varias
páginas en una sola imagen. El servidor admite hasta 8000 px y 64 MB, y la
galería y el visor se adaptan a los recortes largos. La animación de la hoja
dura 30 ms más.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:52:05 -06:00
ninoandClaude Opus 5.5 28aa0b4051 La hoja se dibuja con WebGL y el toque elige la animación según su altura
Una sola malla en un canvas en lugar de una capa con filtro por cara. Las
páginas se suben a la GPU en reposo; sin WebGL2 se usa la hoja de capas.
Tocar arriba, al centro o abajo levanta la hoja por esa parte, como al
arrastrarla, y el giro dura 20 ms más.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:27:56 -06:00
ninoandClaude Opus 5.5 811df85349 Más curvatura al agarrar la hoja por arriba o por abajo, con el lomo fijo
- Curvatura de 14° en el plano de la página y 10° hacia quien lee.
- La tira del lomo no se curva, así que la hoja no se separa del libro.
- Más solape entre tiras al curvarse, para que no se abran huecos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:38:14 -06:00
ninoandClaude Opus 5.5 ca97cca7f0 Modo manga: capítulo siguiente a la izquierda y la hoja se curva según por dónde se agarra
- En der→izq, la barra inferior del lector va como la lectura: Cap. sig. a la izquierda y Cap. ant. a la derecha.
- En móvil, la página y la barra de progreso van arriba, y cada botón de capítulo en su extremo.
- Al arrastrar con el dedo por arriba o por abajo, esa parte de la hoja va por delante y el lomo no se mueve;
  por el centro, como siempre. Sin capas nuevas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:28:59 -06:00
ninoandClaude Opus 5.5 53e5a0d40e La ficha del manga incluye la info de la obra, menú lateral y banners reescalados
- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones.
- La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas
  seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado.
- Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra.
- Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:33:03 -06:00
ninoandClaude Opus 5.5 41a87bc5ad Filtro de luz azul en el lector
Capa ámbar en modo multiplicar sobre las páginas (también durante las animaciones), bajo las barras.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:58:21 -06:00
ninoandClaude Opus 5.5 09354f675e Colas por usuario y sin pantalla negra mientras carga la página siguiente
- Descargas y reescalados tienen usuarios (download_users): Colas y la biblioteca descargada muestran
  solo lo de cada uno. Un manga ya descargado por otro se lee desde el disco y entra en tu biblioteca
  al empezar a leerlo; borrar uno compartido solo lo quita de la tuya. Lo anterior se asigna a quien lo
  lee, sigue o tiene en una lista (o al primer usuario).
- Lector: si la página siguiente no ha llegado, la actual se queda con un indicador hasta que llega
  (máx. 4 s); si no, la nueva aparece con fundido y un indicador en vez de negro. Precarga de 6
  páginas por delante, ya decodificadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:20:09 -06:00
ninoandClaude Opus 5.5 84a60a6501 La página sigue al dedo al deslizar, sonido de papel al pasar página y arreglos de la hoja en doble página
- Arrastrar con el dedo pasa la página siguiendo el toque (hoja, deslizar o fundido); al soltar termina
  de pasar o vuelve a su sitio según lo recorrido y el impulso.
- Sonido de roce de papel sintetizado en el navegador, al azar entre 6 variantes; volumen o sin sonido
  en las opciones del lector.
- Doble página hacia atrás: se precargan también las 2 páginas anteriores, el lado de las páginas nuevas
  sale del orden de lectura y, si aún no han cargado, se desliza en vez de pasar la hoja.
- Safari: sin líneas entre las tiras de la hoja (sombreado con filtro en la propia cara y más solape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:46:56 -06:00
ninoandClaude Opus 5.5 e66b5fea95 Vista Info de la obra (AniList y MangaUpdates) y fechas de los capítulos
- Info: autores, revista y editorial, fechas, tomos, puntuaciones, rankings, anime, obras relacionadas,
  recomendaciones y enlaces. La obra se identifica por los enlaces de MangaDex o por título.
- Fechas de los capítulos: primera traducción conocida (MangaDex en cualquier idioma y MangaUpdates),
  ordenadas y con los huecos estimados (≈); se ignoran las fechas de subidas masivas de las fuentes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:32:23 -06:00
ninoandClaude Opus 5.5 29af893917 Animación de hoja de papel al pasar página y URLs versionadas para saltar la caché del proxy
- Lector por páginas: animaciones Pasar hoja (la hoja se curva al levantarla, con sombra), deslizar y
  fundido; hacia atrás la página anterior vuelve y se coloca encima.
- app.js, style.css y sw.js se sirven con ?v=<hash> para que ni el proxy ni el navegador den una copia vieja.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:41:22 -06:00
ninoandClaude Opus 5.5 86999b5725 Fuente M440, modo sin conexión (servidor sin internet y app sin servidor), cola del dispositivo en Colas y arreglo del reescalado de tiras altas
- M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola.
- Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión.
- App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente.
- Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor.
- Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla.
- Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo.
- Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:52:19 -06:00
ninoandClaude Opus 5.5 d3e196a2f3 Librería con listas, recomendaciones, descarga anticipada, app instalable con lectura offline y seguridad para exponerlo a internet
- Historial pasa a Librería: pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y listas propias (Favoritos, Pendientes, Abandonados por defecto)
- Recomendaciones en el inicio según tus géneros más leídos (populares de MangaDex)
- Descarga anticipada de los N capítulos siguientes (opcionalmente en HD) al pasar la mitad de uno
- PWA: manifest, iconos y service worker; capítulos guardados en el dispositivo para leer sin conexión, progreso offline sincronizado después
- Seguridad: baneo de IP tras 6 intentos fallidos (configurable), IP real detrás de proxies de confianza, CSP y cabeceras, cookie Secure/HSTS en HTTPS, login sin diferencias de tiempo, registro cerrado por defecto, contraseñas de 8+, protección SSRF en el proxy de imágenes, errores 500 genéricos
- Ajustes: sección Seguridad con IPs bloqueadas, intentos recientes y la IP vista por el servidor
- LeerCapitulo: reintentos ante caídas (522/5xx), copia anterior si sigue caído y mensaje claro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:52:13 -06:00
ninoandClaude Opus 5.5 9086896a89 Portadas HD de lo seguido o leído; sección Colas con descargas, reescalado y biblioteca en tarjetas
- Portadas: se reescalan (×4/×2) solo las de mangas en historial o seguidos; caché en data/covers/.
- Colas (antes Descargas): cola de descargas y de reescalado HD con progreso, tiempo restante,
  reintentar/detener, recortes y portadas pendientes; biblioteca descargada en tarjetas verticales.
- Refresco de Colas sin parpadeo: solo se actualiza lo que cambia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:31:18 -06:00
ninoandClaude Opus 5.5 36ff53cad4 Historial en tarjetas verticales; «Dejar de leer» vuelve atrás o al inicio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:48:01 -06:00
ninoandClaude Opus 5.5 5d82a5e020 Recortes de paneles desde el lector, reescalados aparte, con galería y acceso directo a la página
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:38:12 -06:00
ninoandClaude Opus 5.5 d48378aa49 Vertical: páginas pegadas, sin separación entre ellas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:54:52 -06:00
ninoandClaude Opus 5.5 aabb87f6c1 Descarga de capítulos sueltos: rango o selección en la lista
Las descargas tienen modo 'all' (manga entero, también lo nuevo) o 'some' (solo
los capítulos pedidos, en download_wanted). El selector de capítulos se comparte
con el reescalado HD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:24:38 -06:00
ninoandClaude Opus 5.5 bc84a9f6fe Reescalado HD, ficha reorganizada y mejoras del lector
- Reescalado HD con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri): cola persistente,
  todo lo pendiente, un rango o capítulos elegidos en la lista, con estimación de tiempo.
  La versión HD va a <carpeta>/.hd/ sin tocar el original; marca HD en la lista de
  capítulos y casilla "Versión HD" en el lector.
- Imagen Debian slim (glibc + Mesa Vulkan + cwebp); compose con /dev/dri y RENDER_GID.
- Ficha: acciones principales + menú ⋯, panel "Biblioteca local" (copia local y HD),
  idioma junto a la lista de capítulos.
- Lector: la barra de progreso avanza de derecha a izquierda en der → izq; botón rápido
  páginas ⇄ vertical; en vertical "Ajustar alto" y "Separar páginas" para mangas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:51:29 -06:00
ninoandClaude Opus 5.5 9e9139f43a Ocultar de «Continuar leyendo» los mangas terminados salvo que haya capítulos nuevos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:31:20 -06:00
ninoandClaude Opus 5.5 af90d34c95 Docker autónomo: configuración del host en .env e imagen fijada
- Puertos, carpeta de mangas, PUID/PGID, URL pública y registro se
  configuran en .env (plantilla en .env.example); sin .env arranca con
  valores portables (0.0.0.0:3352, ./mangas).
- Publicar en 0.0.0.0 evita fallar al arrancar si la IP de la LAN o de
  Tailscale aún no existe; el acceso lo siguen limitando las reglas
  DOCKER-USER del host.
- Imagen base fijada a node:24.22.2-alpine3.24 (node:sqlite es experimental).
- ALLOW_SIGNUP=false por defecto (el primer usuario puede registrarse igual).
- Comprobación de permisos al arrancar con mensaje claro (chown / PUID).
- README: qué guardar y cómo mover el servicio a otro servidor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:55 -06:00
ninoandClaude Opus 5.5 73fc1d5849 Novedades, ajustes del lector por manga, doble página, zoom y estadísticas
- Seguimiento de mangas (automático al empezar a leer o con "Seguir"):
  comprobación cada CHECK_INTERVAL_HOURS, "N caps. nuevos" en el inicio,
  marcas NUEVO en la ficha, autodescarga si hay copia local y avisos por
  ntfy o Telegram configurables en Ajustes.
- Modo, ajuste y dirección del lector guardados por manga en el servidor,
  con valores por defecto según el tipo (manhwa/manhua vertical, manga der→izq).
- Modo doble página (portada y páginas apaisadas solas; vuelve a una página
  en pantallas estrechas o en vertical).
- Zoom: pellizco, doble toque en el centro, Ctrl+rueda y teclas + - 0.
- Opciones del lector en un panel desplegable.
- Página de estadísticas: rachas, capítulos/páginas por semana, calendario,
  géneros favoritos y lo más leído (con migración de las lecturas previas).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:45:19 -06:00
ninoandClaude Opus 5.5 bb25cde388 Iconos SVG en el menú y en los botones
Iconos en línea (estilo Lucide, sin dependencias) en el menú superior,
pestañas, ficha del manga, lector, historial, descargas y ajustes.
En móvil el menú muestra solo los iconos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:12:18 -06:00
ninoandClaude Opus 5.5 f294c1cdf4 Categorías, tags clicables, progreso de descarga y eliminar copia local
- Pestaña "Categorías" (géneros/temáticas de LeerCapitulo, tags de MangaDex)
  con búsqueda dentro de la categoría y "Cargar más".
- Los tags de la ficha llevan a su categoría.
- Barra de progreso de descarga con el capítulo y la página en curso.
- Eliminar la copia local (también a mitad de descarga) desde la ficha y
  desde la página Descargas, mostrando el espacio ocupado.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:04:09 -06:00
ninoandClaude Opus 5.5 fea5ec0c65 Descarga de mangas completos a /zdata/media/mangas en CBZ
- Cola de descargas en segundo plano (se reanuda tras reinicios; repetirla
  solo baja capítulos nuevos; se puede cancelar).
- <Título>/Capitulo NNNN.cbz con ComicInfo.xml + cover.jpg (Kavita/Komga/Mihon).
- Los capítulos descargados se leen desde disco; sin conexión la ficha usa
  los datos guardados.
- Página "Descargas" y marcas ⬇ en la lista de capítulos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:43:13 -06:00
ninoandClaude Opus 5.5 57a37c67c0 Botón "Dejar de leer" en la ficha del manga
Quita el manga del historial y borra el progreso de sus capítulos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:31:17 -06:00
ninoandClaude Opus 5.5 9d031d4ba3 Manga Reader: lector web de manga con historial de lectura
Basado en las fuentes de Tanko (MangaDex y LeerCapitulo), con usuarios
con contraseña, progreso por página guardado en SQLite, lector por página
o vertical, filtros de imagen y proxy de imágenes con URLs firmadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:26:33 -06:00
29 changed files with 151 additions and 5895 deletions
+3 -3
View File
@@ -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=./mangas
MANGA_DIR_HOST=/zdata/media/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://localhost:3352
PUBLIC_URL=http://192.168.1.141: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=UTC
TZ=America/El_Salvador
CHECK_INTERVAL_HOURS=3
# --- Exponer a internet ---
-7
View File
@@ -4,14 +4,7 @@ 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
+2 -16
View File
@@ -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
`./mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
`/zdata/media/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,20 +62,6 @@ 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
@@ -158,7 +144,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` (por defecto `./mangas`) | CBZ + portadas |
| Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas |
Pasos en el sistema nuevo:
-603
View File
@@ -1,603 +0,0 @@
// 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[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 }
}
+8 -16
View File
@@ -56,14 +56,8 @@ export function writeCbz(file, entries) {
fs.renameSync(tmp, file)
}
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }]
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size }]
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)
@@ -71,11 +65,10 @@ export function readZipIndex(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('ZIP inválido')
if (eocd < 0) throw new Error('CBZ 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 = []
@@ -83,19 +76,18 @@ export function readZipIndex(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, unzipped })
entries.push({ name, method, start, size: compSize })
}
return entries
return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
} finally {
fs.closeSync(fd)
}
@@ -106,13 +98,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. maxBytes corta una entrada que al descomprimirse crezca de más.
export function readCbzEntry(file, entry, maxBytes) {
// Lee una entrada completa del CBZ a memoria.
export function readCbzEntry(file, entry) {
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, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf
return entry.method === 8 ? zlib.inflateRawSync(buf) : buf
}
// Envía una entrada del CBZ por la respuesta HTTP.
-92
View File
@@ -1,92 +0,0 @@
// 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 }
}
+1 -2
View File
@@ -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://localhost:3352').replace(/\/+$/, '')
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://192.168.1.141:3352').replace(/\/+$/, '')
// Día local (TZ del contenedor) en formato YYYY-MM-DD.
export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE')
@@ -218,7 +218,6 @@ 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,
+1 -27
View File
@@ -12,7 +12,6 @@
// 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
@@ -120,31 +119,6 @@ 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
@@ -313,5 +287,5 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
return true
}
return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
return { route, links, info, cachedInfo, dates }
}
File diff suppressed because one or more lines are too long
-97
View File
@@ -1,97 +0,0 @@
// 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 }) }
}
+119 -1562
View File
File diff suppressed because it is too large Load Diff
-1091
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
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
View File
@@ -3,8 +3,6 @@
<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>
@@ -23,7 +21,6 @@
<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
-72
View File
@@ -79,13 +79,6 @@ 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; }
@@ -93,8 +86,6 @@ 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); }
@@ -406,17 +397,6 @@ 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 */
@@ -459,55 +439,3 @@ 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, un pósit cuadrado 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; --side: min(34vw, 330px);
position: fixed; z-index: 3; right: calc(5vw + env(safe-area-inset-right)); top: 50%; width: var(--side); height: var(--side); box-sizing: border-box;
display: flex; flex-direction: column; overflow: hidden; transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
padding: 34px 22px 18px; border-radius: 1px 1px 2px 14px / 1px 1px 2px 8px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 24px, transparent 24px), 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 26px 8px 0; font-family: inherit; font-weight: inherit; font-size: calc(clamp(1.3rem, 2.6vw, 1.8rem) * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
.shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { margin: 4px 0; font-size: calc(clamp(1rem, 1.7vw, 1.2rem) * var(--ns)); line-height: 1.2; }
.shelfpeek .psub { opacity: .8; }
.shelfpeek .phint { margin-top: auto; color: inherit; text-decoration: none; opacity: .75; }
.shelfpeek .peekx { position: absolute; top: 2px; right: 4px; width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .6; }
@media (orientation: portrait) {
.shelfpeek { --side: min(64vw, 300px); right: auto; left: 50%; top: auto; bottom: calc(18px + env(safe-area-inset-bottom)); transform: translate(-50%, 14px) rotate(var(--nt)); }
.shelfpeek.on { transform: translate(-50%, 0) rotate(var(--nt)); }
.shelfpeek .ptitle { font-size: calc(1.45rem * var(--ns)); }
.shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { font-size: calc(1.08rem * var(--ns)); }
}
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
View File
@@ -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', '/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']
const SHELL_FILES = ['/', '/app.js', '/style.css', '/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 && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE).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/') || p.startsWith('/api/book/')) {
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/')) {
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
}
})
+1 -2
View File
@@ -128,8 +128,7 @@ 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(() => [])
if (!addrs.length) return false // fallo de DNS: puede ser pasajero, no se recuerda
const ok = addrs.every(a => !isPrivateIp(a.address))
const ok = addrs.length > 0 && 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
+10 -101
View File
@@ -15,10 +15,6 @@ 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'
@@ -166,12 +162,10 @@ 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, u.content_mode FROM sessions s JOIN users u ON u.id = s.user_id
return db.prepare(`SELECT u.id, u.username, u.lang 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) {
@@ -462,7 +456,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' : /^(gb|ws|ub)~/.test(id) ? id.slice(0, 2) : 'md')
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : '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.
@@ -545,7 +539,6 @@ 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) }
}
@@ -774,45 +767,6 @@ 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 = []
@@ -823,8 +777,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._-]+|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 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 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 = ?
@@ -835,7 +789,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', '.json': 'application/json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
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' }
// 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.
@@ -843,8 +797,6 @@ 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(/^(\.\.[/\\])+/, ''))
@@ -880,8 +832,7 @@ async function route(req, res) {
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
const u = getUser(req)
return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 })
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(ip)
@@ -918,22 +869,15 @@ 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] || '')) && !isBookId(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
if (p.startsWith('/api/meta/') && MANGA_ID.test(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') {
@@ -942,10 +886,6 @@ 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') }
@@ -979,15 +919,12 @@ 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() : ['mi', 'gb', 'ws', 'ub'].includes(q.get('source')) ? [] : await mdCategories())
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' ? [] : 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'
@@ -996,17 +933,6 @@ 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 {
@@ -1025,21 +951,9 @@ 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, 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) })
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) })
}
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)
@@ -1099,7 +1013,6 @@ 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
@@ -1153,7 +1066,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)
if (!isBookId(b.mangaId)) library.onProgress(user.id, b, page, total)
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') {
@@ -1167,7 +1080,6 @@ 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 })
@@ -1175,9 +1087,6 @@ 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
-36
View File
@@ -1,36 +0,0 @@
// 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, chapterNum: 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, chapterNum: fields.chapterNum ?? null, 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, chapter_num, 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, chapterNum: r.chapter_num, 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
View File
@@ -1,770 +0,0 @@
// 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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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
View File
@@ -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.0.2.10 en .env.
# Para limitarlo a una interfaz: BIND_IP=192.168.1.141 en .env.
- "${BIND_IP:-0.0.0.0}:${HOST_PORT:-3352}:3352"
read_only: true
tmpfs: