Manga Reader: lector web autoalojado con modo libro 3D y estante
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
data
|
||||
mangas
|
||||
.git
|
||||
.env
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copia a .env y ajusta. Todas son opcionales.
|
||||
|
||||
# Usuario/grupo dueño de ./data y de la carpeta de mangas en el host (salida de `id -u` / `id -g`).
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# Dónde guardar los mangas descargados en el host (por defecto ./mangas dentro del proyecto).
|
||||
MANGA_DIR_HOST=./mangas
|
||||
|
||||
# Interfaz y puerto publicados. 0.0.0.0 = todas.
|
||||
BIND_IP=0.0.0.0
|
||||
HOST_PORT=3352
|
||||
|
||||
# URL de la web que se usa en los enlaces de las notificaciones.
|
||||
PUBLIC_URL=http://localhost:3352
|
||||
|
||||
# Registro de cuentas nuevas (con la base vacía, el primer usuario siempre puede registrarse).
|
||||
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
|
||||
CHECK_INTERVAL_HOURS=3
|
||||
|
||||
# --- Exponer a internet ---
|
||||
# Baneo de IP: tras 6 intentos fallidos (contraseña o registro cerrado) en 60 min, 24 h bloqueada.
|
||||
# La red local, loopback y Tailscale (100.64.0.0/10) nunca se bloquean. BAN_IGNORE: más IPs, separadas por comas.
|
||||
BAN_MAX_FAILS=6
|
||||
BAN_WINDOW_MIN=60
|
||||
BAN_HOURS=24
|
||||
# BAN_IGNORE=203.0.113.7
|
||||
# Si hay un proxy inverso delante (recomendado, con HTTPS): cabecera con la IP real del cliente.
|
||||
# Caddy/Nginx: x-forwarded-for · Cloudflare Tunnel: cf-connecting-ip. Sin proxy, déjalo vacío.
|
||||
# REAL_IP_HEADER=x-forwarded-for
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Datos de ejecución: usuarios, sesiones, historial y clave HMAC del proxy de imágenes
|
||||
data/*
|
||||
!data/.keep
|
||||
# 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
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Real-ESRGAN (ncnn-vulkan) para el reescalado HD. Solo se guardan los modelos de anime ×2 (capítulos) y ×4 (recortes).
|
||||
FROM debian:trixie-slim AS esrgan
|
||||
ARG ESRGAN_URL=https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip
|
||||
ARG ESRGAN_SHA256=e5aa6eb131234b87c0c51f82b89390f5e3e642b7b70f2b9bbe95b6a285a40c96
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip && rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL -o /tmp/esr.zip "$ESRGAN_URL" \
|
||||
&& echo "$ESRGAN_SHA256 /tmp/esr.zip" | sha256sum -c - \
|
||||
&& mkdir -p /opt/realesrgan/models && cd /tmp && unzip -q esr.zip -d esr \
|
||||
&& install -m 755 esr/realesrgan-ncnn-vulkan /opt/realesrgan/ \
|
||||
&& cp esr/models/realesr-animevideov3-x2.* esr/models/realesr-animevideov3-x4.* /opt/realesrgan/models/
|
||||
|
||||
# Versión fijada: node:sqlite aún es experimental y una actualización silenciosa podría romperlo.
|
||||
# Debian (no Alpine): Real-ESRGAN necesita glibc y los controladores Vulkan de Mesa.
|
||||
FROM node:24.22.2-trixie-slim
|
||||
ENV NODE_ENV=production NODE_NO_WARNINGS=1 \
|
||||
UPSCALER_BIN=/opt/realesrgan/realesrgan-ncnn-vulkan \
|
||||
MESA_SHADER_CACHE_DIR=/tmp/mesa-cache
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata libvulkan1 mesa-vulkan-drivers libgomp1 webp \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=esrgan /opt/realesrgan /opt/realesrgan
|
||||
WORKDIR /app
|
||||
COPY app/ ./
|
||||
# Usuario por defecto; docker-compose.yml lo sustituye por PUID:PGID.
|
||||
USER node
|
||||
EXPOSE 3352
|
||||
HEALTHCHECK --interval=60s --timeout=5s --start-period=20s CMD node -e "fetch('http://127.0.0.1:3352/api/me').then(r=>process.exit(r.status<500?0:1),()=>process.exit(1))"
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,177 @@
|
||||
# Manga Reader
|
||||
|
||||
Lector web de manga autoalojado, con las fuentes de
|
||||
[Tanko](https://github.com/Alexandro521/Tanko) (`src/servers/`) más M440:
|
||||
|
||||
- **LeerCapitulo** (español, por defecto): mucho más catálogo. El scraper está reescrito para el
|
||||
sitio actual y ya no necesita navegador headless (las páginas vienen en el HTML).
|
||||
- **MangaDex** (multi-idioma): español = es-la + es combinados. Muchos títulos populares no tienen
|
||||
capítulos por licencias; la ficha ofrece buscarlos en LeerCapitulo o M440.
|
||||
- **M440** (m440.in, español): búsqueda (mínimo 3 letras), populares de su portada y actualizados
|
||||
(paginado); sin categorías, porque ese listado está detrás del desafío de Cloudflare. La lista de
|
||||
capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un
|
||||
proceso aparte sin permisos de archivos ni procesos (`app/m440-key.mjs`) y se vuelve a obtener sola
|
||||
si el sitio la cambia.
|
||||
- Búsqueda, populares y actualizados recientemente.
|
||||
- Lector por página (toques/flechas, izq→der o der→izq) o en vertical, modo ahorro de datos.
|
||||
- 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),
|
||||
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
|
||||
`CHECK_INTERVAL_HOURS` (3 h). El inicio muestra "N caps. nuevos", la ficha marca NUEVO, los
|
||||
mangas con copia local descargan lo nuevo solos, y se puede avisar por ntfy o Telegram (Ajustes).
|
||||
- **Lector**: una página, doble página (tablet horizontal/PC; portada y páginas apaisadas van solas)
|
||||
o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en
|
||||
vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro,
|
||||
Ctrl+rueda o + / − / 0. Al pasar página la hoja se dobla como papel y, al deslizar, sigue al dedo;
|
||||
suena un roce de papel elegido al azar entre 6 variantes sintetizadas en el navegador (volumen o
|
||||
sin sonido en las opciones del lector).
|
||||
- **Reescalado HD**: en la ficha de un manga descargado, "Reescalar a HD" pasa sus capítulos por
|
||||
Real-ESRGAN (`realesr-animevideov3` ×2, en la GPU por Vulkan) y guarda la versión HD en
|
||||
`<Título>/.hd/` (carpeta oculta: el CBZ original no se toca y Kavita/Komga no ven duplicados).
|
||||
Con el manga marcado, los capítulos que se descarguen después también se reescalan. La lista de
|
||||
capítulos marca **HD**, el lector usa la versión HD (se puede desactivar en sus ajustes) y desde
|
||||
allí también se reescala un capítulo suelto. En una Intel HD 630 tarda ~13 s por página y cada
|
||||
página ocupa ~2,5× más (WebP calidad 85).
|
||||
- **Portadas HD**: las portadas de los mangas que sigues o tienes en el historial se reescalan solas en
|
||||
segundo plano (×4 las pequeñas, ×2 el resto; ~4 s cada una) y desde entonces se sirven en HD. Las
|
||||
de búsquedas y populares se quedan como están. Caché en `data/covers/` (se puede borrar: se regeneran).
|
||||
- **Recortes**: en el lector, ✂ y arrastrar sobre un panel lo guarda en "Recortes" con su manga,
|
||||
capítulo y página ("Ir a la página" lleva directo allí). Se recorta del original y solo el recorte se
|
||||
reescala con Real-ESRGAN (×4, o ×2 si ya mide más de 1200 px; unos segundos). Se guardan en
|
||||
`data/clips/` (`<id>.png` original y `<id>-hd.webp`), con nota opcional y descarga.
|
||||
- **Colas** (antes "Descargas"): descargas en curso y en espera, cola de reescalado HD por manga
|
||||
(capítulo y página en curso, siguientes, tiempo restante, errores con "Reintentar", "Detener"),
|
||||
recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas
|
||||
verticales con portada, como el historial.
|
||||
- **Librería** (antes "Historial"): pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y
|
||||
listas propias (Favoritos, Pendientes, Abandonados y las que crees). Desde la ficha, el botón
|
||||
"Listas" añade o quita el manga de cada lista.
|
||||
- **Recomendado para ti** (inicio): populares de MangaDex con capítulos en tu idioma dentro de tus 3
|
||||
géneros más leídos, sin lo que ya tienes en la librería.
|
||||
- **Descarga anticipada** (Ajustes): al pasar la mitad de un capítulo, el servidor baja los N
|
||||
siguientes (por defecto 2) a la copia local y, si se marca, los reescala a HD.
|
||||
- **App instalable y lectura sin conexión**: manifest + service worker. En la ficha, "En este
|
||||
dispositivo" guarda capítulos (páginas incluidas) en el navegador para leerlos sin internet; el
|
||||
progreso leído sin red se envía al volver. **Requiere HTTPS** (o `localhost`): por `http://IP:puerto`
|
||||
el navegador no permite service workers. Por ejemplo `tailscale serve` o un proxy con certificado.
|
||||
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
|
||||
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
|
||||
cp .env.example .env # opcional: ajusta rutas, puerto, usuario…
|
||||
docker compose up -d --build
|
||||
# http://<ip-del-servidor>:3352
|
||||
```
|
||||
|
||||
El contenedor no depende del sistema operativo: imagen Node fijada (`node:24.22.2-trixie-slim`,
|
||||
con Mesa Vulkan y Real-ESRGAN verificado por SHA-256), sin dependencias npm, y todo lo propio de la máquina está en `.env` (ver `.env.example`).
|
||||
Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./mangas`.
|
||||
|
||||
El primer usuario puede registrarse siempre; después el registro queda cerrado salvo
|
||||
`ALLOW_SIGNUP=true` en `.env`.
|
||||
|
||||
## Info de la obra y fechas de los capítulos
|
||||
|
||||
La ficha de cada manga incluye datos de [AniList](https://anilist.co) y
|
||||
[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos,
|
||||
puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones, enlaces oficiales y la
|
||||
imagen de fondo de la obra.
|
||||
La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título
|
||||
(solo si coincide, para no mostrar datos de otra obra).
|
||||
|
||||
Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo
|
||||
(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación;
|
||||
los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas
|
||||
entre dos capítulos con fecha.
|
||||
|
||||
Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se muestra sin
|
||||
consultar nada y, si es antiguo (info 30 días, fechas 1 día), se actualiza en segundo plano. Los mangas que
|
||||
sigue algún usuario se completan y se mantienen al día solos, aunque nadie abra su ficha.
|
||||
|
||||
La imagen de fondo de la ficha se reescala con Real-ESRGAN (×4 si mide menos de 1000 px de ancho, ×2 si no). Si la obra
|
||||
no tiene, se usa uno de tus recortes de ese manga; sin recortes, la ficha va sin imagen de fondo.
|
||||
|
||||
En la ficha solo quedan a la vista «Continuar/Empezar a leer» y «Capítulos». El botón **⋯** abre el menú lateral con
|
||||
seguir, listas, recortes, buscar en otras fuentes, dejar de leer, la copia local, el reescalado HD, «En este dispositivo»
|
||||
y los datos de la obra (autores, editorial, publicación…).
|
||||
|
||||
## Sin internet en el servidor
|
||||
|
||||
Si el servidor pierde la conexión a internet (pero tú sigues llegando a él por la red local o
|
||||
Tailscale), la app pasa sola a **modo sin conexión**:
|
||||
|
||||
- Arriba aparece el aviso «El servidor no tiene internet». La portada muestra lo **descargado en el
|
||||
servidor** con un buscador, y «Continuar leyendo» solo enseña esos mangas.
|
||||
- Fichas y capítulos descargados funcionan como siempre, sin esperas. Lo que no está descargado
|
||||
avisa al momento en vez de quedarse cargando.
|
||||
- Las portadas salen de la copia local o de la caché HD.
|
||||
- Las descargas en cola se pausan (no cuentan como fallidas). La comprobación de capítulos nuevos y la
|
||||
descarga anticipada esperan.
|
||||
- El servidor vuelve a comprobar la conexión cada 20 s. Al volver internet, todo se reanuda solo y
|
||||
la portada vuelve a la normalidad.
|
||||
|
||||
Se detecta cuando una fuente falla por red y ninguno de MangaDex, LeerCapitulo, M440 ni 1.1.1.1
|
||||
responde (`app/connectivity.js`).
|
||||
|
||||
## Exponerlo a internet
|
||||
|
||||
- **Pon HTTPS delante** (Caddy, Nginx, Cloudflare Tunnel o `tailscale funnel`): sin HTTPS la contraseña
|
||||
y la cookie viajan en claro. Con HTTPS la cookie lleva `Secure` y se envía HSTS.
|
||||
- Con un proxy inverso, define `REAL_IP_HEADER` en `.env` (`x-forwarded-for` en Caddy/Nginx,
|
||||
`cf-connecting-ip` en Cloudflare) para que el baneo vea la IP real. Solo se cree esa cabecera si la
|
||||
conexión llega desde una IP privada (el proxy), así que desde internet no se puede falsificar.
|
||||
- **Baneo de IP**: 6 intentos fallidos (contraseña incorrecta, registro con el registro cerrado,
|
||||
contraseña actual incorrecta) en 60 min bloquean la IP 24 h para toda la web. Ajustable con
|
||||
`BAN_MAX_FAILS`, `BAN_WINDOW_MIN`, `BAN_HOURS`; red local, loopback y Tailscale nunca se bloquean.
|
||||
En Ajustes se ven las IPs bloqueadas y se pueden desbloquear.
|
||||
- Registro cerrado por defecto (`ALLOW_SIGNUP=false`); contraseñas de 8+ caracteres; cambiar la
|
||||
contraseña cierra las demás sesiones.
|
||||
- Cabeceras: CSP estricta (sin scripts en línea), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`.
|
||||
- El proxy de imágenes solo descarga URLs firmadas por el servidor y nunca hacia IPs privadas
|
||||
(comprobado también en cada redirección). Los errores internos no se muestran al cliente.
|
||||
|
||||
## Qué guardar / mover a otro servidor
|
||||
|
||||
| Qué | Dónde | Contenido |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
Pasos en el sistema nuevo:
|
||||
|
||||
```bash
|
||||
git clone <repo> manga-reader && cd manga-reader
|
||||
# copiar .env y data/ desde el servidor anterior (con el contenedor parado), y la carpeta de mangas
|
||||
id -u; id -g # si no son 1000, ponlos en PUID/PGID del .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Para el reescalado HD el host necesita `/dev/dri` (GPU Intel/AMD con Mesa) y `RENDER_GID` en `.env`
|
||||
con el grupo `render`. Sin GPU, quita `devices`/`group_add` del compose: todo lo demás funciona igual.
|
||||
|
||||
Si el usuario del host no coincide con PUID/PGID, el contenedor no arranca y el log dice
|
||||
exactamente qué `chown` hacer. Si la carpeta de mangas no existe, Docker la crea como root:
|
||||
créala antes (`mkdir -p` con tu usuario) para que las descargas funcionen.
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
// Libros de texto (EPUB). El EPUB se convierte aquí en una lista cerrada de bloques: al navegador nunca llega
|
||||
// HTML del libro, y es él quien maqueta los bloques en páginas según su pantalla (ver maquetado en app.js).
|
||||
// bloque: { k: 'p' | 'q' | 'h' | 'hr' | 'img', l?: nivel de título, n?: imagen, c?: centrado, v?: verso,
|
||||
// r?: [[texto, estilo]] } estilo: 1 cursiva, 2 negrita (sumables)
|
||||
// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. Orígenes:
|
||||
// - Project Gutenberg, con su catálogo OPDS. IDs: libro "gb~<n>", capítulo "gb~<n>~<i>".
|
||||
// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~<título en base64url>", "ws~<título>~<i>".
|
||||
// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~<i>".
|
||||
// En <DATA_DIR>/books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido.
|
||||
import fs from 'node:fs'
|
||||
import zlib from 'node:zlib'
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { readZipIndex, readCbzEntry, imageType } from './cbz.js'
|
||||
|
||||
export const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
|
||||
const GUTENBERG = 'https://www.gutenberg.org'
|
||||
const WS_API = 'https://es.wikisource.org/w/api.php'
|
||||
const WS_EXPORT = 'https://ws-export.wmcloud.org/'
|
||||
const WS_MIN_WORDS = 2000 // sin subpáginas, lo que no llega a esto es un poema o un texto suelto, no un libro
|
||||
const OPDS_PAGE = 25
|
||||
const GB_NAME = 'Project Gutenberg'
|
||||
const WS_NAME = 'Wikisource'
|
||||
// El buscador de Gutenberg filtra por el nombre del idioma, no por el código.
|
||||
const LANG_NAMES = { es: 'spanish', en: 'english', fr: 'french', pt: 'portuguese', it: 'italian', de: 'german', ru: 'russian', ja: 'japanese', zh: 'chinese', ko: 'korean', ca: 'catalan', nl: 'dutch', pl: 'polish' }
|
||||
const PARSER_VERSION = 3
|
||||
const MAX_EPUB_BYTES = 100 * 1024 * 1024
|
||||
const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen
|
||||
const MAX_UNZIPPED_TOTAL = 256 * 1024 * 1024 // todo lo que se lee del libro: lo que pase de aquí es una ZIP bomb
|
||||
const MAX_UPLOADS_PER_USER = 500
|
||||
const BOOK_IMAGE = /\.(jpe?g|png|gif|webp)$/i
|
||||
|
||||
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', iexcl: '¡', iquest: '¿', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', agrave: 'à', egrave: 'è', igrave: 'ì', ograve: 'ò', ugrave: 'ù', ccedil: 'ç', shy: '', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', copy: '©', deg: '°', ordm: 'º', ordf: 'ª', sect: '§', para: '¶', middot: '·', dagger: '†', Dagger: '‡', bull: '•', times: '×' }
|
||||
const entities = s => s.replace(/&(#x?[0-9a-f]+|[a-z0-9]+);/gi, (all, e) => {
|
||||
if (e[0] !== '#') return ENTITIES[e] ?? all
|
||||
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
|
||||
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
|
||||
})
|
||||
const attr = (attrs, name) => {
|
||||
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs)
|
||||
return m ? entities(m[1] ?? m[2]) : null
|
||||
}
|
||||
// Ruta dentro del ZIP de un enlace relativo a un archivo del libro.
|
||||
function resolveHref(base, href) {
|
||||
let rel = href.split('#')[0]
|
||||
try { rel = decodeURIComponent(rel) } catch {}
|
||||
return path.posix.normalize(path.posix.join(path.posix.dirname(base), rel)).replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
// ---------- XHTML a bloques ----------
|
||||
const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav'])
|
||||
const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'])
|
||||
const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr'])
|
||||
const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g
|
||||
|
||||
// onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo.
|
||||
function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) {
|
||||
let skip = 0, skipTag = null
|
||||
let italic = 0, bold = 0, quote = 0, center = 0, verse = 0
|
||||
const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas
|
||||
let runs = [], heading = 0
|
||||
const flush = () => {
|
||||
// Espacios del código fuente fuera; los saltos de línea reales (<br>) son \n.
|
||||
const out = []
|
||||
let atStart = true
|
||||
for (const [text, style] of runs) {
|
||||
let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un <br>
|
||||
if (atStart) t = t.replace(/^ +/, '')
|
||||
if (!t) continue
|
||||
atStart = t.endsWith('\n')
|
||||
const last = out.at(-1)
|
||||
if (last && last[1] === style) last[0] += t
|
||||
else out.push([t, style])
|
||||
}
|
||||
while (out.length) {
|
||||
const last = out.at(-1)
|
||||
last[0] = last[0].replace(/[ \n]+$/, '')
|
||||
if (last[0]) break
|
||||
out.pop()
|
||||
}
|
||||
runs = []
|
||||
if (out.length && out.some(([t]) => t.trim())) {
|
||||
const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out }
|
||||
if (center || heading) b.c = 1
|
||||
if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1
|
||||
onBlock(b)
|
||||
}
|
||||
}
|
||||
const body = xml.search(/<body[\s>]/i)
|
||||
let pos = body >= 0 ? body : 0
|
||||
TOKEN.lastIndex = pos
|
||||
for (let m; (m = TOKEN.exec(xml));) {
|
||||
if (!skip && m.index > pos) {
|
||||
const text = entities(xml.slice(pos, m.index))
|
||||
if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)])
|
||||
}
|
||||
pos = TOKEN.lastIndex
|
||||
const tag = m[2]?.toLowerCase().replace(/^.*:/, '')
|
||||
if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro)
|
||||
continue
|
||||
}
|
||||
const closing = m[1] === '/'
|
||||
const rawAttrs = m[3] || ''
|
||||
const selfClosed = /\/\s*$/.test(rawAttrs)
|
||||
if (skip) {
|
||||
if (tag === skipTag) {
|
||||
if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (closing) {
|
||||
if (BLOCK.has(tag)) {
|
||||
flush()
|
||||
const top = stack.findLastIndex(s => s.tag === tag)
|
||||
if (top >= 0) {
|
||||
for (const s of stack.splice(top)) {
|
||||
quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold
|
||||
if (s.heading) heading = 0
|
||||
}
|
||||
}
|
||||
} else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1)
|
||||
else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1)
|
||||
continue
|
||||
}
|
||||
const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null)
|
||||
if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ }
|
||||
const cls = (attr(rawAttrs, 'class') || '').toLowerCase()
|
||||
const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase()
|
||||
// Números de página de la edición impresa: ruido entre el texto.
|
||||
if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) {
|
||||
if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag }
|
||||
continue
|
||||
}
|
||||
if (tag === 'br') { runs.push(['\n', 0]); continue }
|
||||
if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue }
|
||||
if (tag === 'img' || tag === 'image') {
|
||||
const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href')
|
||||
const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null
|
||||
if (n != null) { flush(); onBlock({ k: 'img', n }) }
|
||||
continue
|
||||
}
|
||||
if (BLOCK.has(tag)) {
|
||||
flush()
|
||||
const style = attr(rawAttrs, 'style') || ''
|
||||
const s = {
|
||||
tag,
|
||||
quote: tag === 'blockquote' ? 1 : 0,
|
||||
center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0,
|
||||
verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0,
|
||||
italic: 0, bold: 0, heading: 0,
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 }
|
||||
quote += s.quote; center += s.center; verse += s.verse
|
||||
if (!selfClosed) stack.push(s)
|
||||
else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 }
|
||||
continue
|
||||
}
|
||||
if (selfClosed) continue
|
||||
if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++
|
||||
else if (tag === 'b' || tag === 'strong') bold++
|
||||
else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) }
|
||||
}
|
||||
if (!skip && pos < xml.length) {
|
||||
const text = entities(xml.slice(pos).replace(/<[^>]*$/, ''))
|
||||
if (text.trim()) runs.push([text, 0])
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera.
|
||||
function imageSize(b) {
|
||||
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
|
||||
if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }
|
||||
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
|
||||
for (let p = 2; p + 9 < b.length;) {
|
||||
if (b[p] !== 0xff) { p++; continue }
|
||||
const m = b[p + 1]
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) }
|
||||
if (m === 0xff) { p++; continue }
|
||||
p += 2 + b.readUInt16BE(p + 2)
|
||||
}
|
||||
}
|
||||
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
||||
const kind = b.toString('latin1', 12, 16)
|
||||
if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
|
||||
if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
|
||||
if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// ---------- EPUB ----------
|
||||
|
||||
// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx).
|
||||
function readToc(read, opf, manifest) {
|
||||
const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props))
|
||||
if (navItem) {
|
||||
const xml = read(navItem.path)
|
||||
const nav = /<nav\b[^>]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1]
|
||||
if (nav) {
|
||||
const out = []
|
||||
for (const m of nav.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
||||
const href = attr(m[1], 'href')
|
||||
if (!href) continue
|
||||
out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) })
|
||||
}
|
||||
if (out.length) return out
|
||||
}
|
||||
}
|
||||
const spineToc = /<spine\b[^>]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
|
||||
const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type))
|
||||
if (!ncx) return []
|
||||
const xml = read(ncx.path)
|
||||
const out = []
|
||||
// Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan.
|
||||
for (const m of xml.matchAll(/<navPoint\b[^>]*>\s*<navLabel\b[^>]*>\s*<text\b[^>]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*<content\b([^>]*)\/?>/gi)) {
|
||||
const src = attr(m[2], 'src')
|
||||
if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
|
||||
|
||||
export function parseEpub(file) {
|
||||
const index = readZipIndex(file)
|
||||
const entries = new Map(index.map(e => [e.name, e]))
|
||||
if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
|
||||
let unzipped = 0
|
||||
const readBuf = name => {
|
||||
const e = entries.get(name)
|
||||
if (!e) throw new Error(`Falta ${name} en el EPUB`)
|
||||
if (e.unzipped > MAX_UNZIPPED_ENTRY) throw new Error('Archivo demasiado grande dentro del EPUB')
|
||||
const buf = readCbzEntry(file, e, MAX_UNZIPPED_ENTRY)
|
||||
unzipped += buf.length // lo real: la cabecera del ZIP puede mentir
|
||||
if (unzipped > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
|
||||
return buf
|
||||
}
|
||||
const read = name => readBuf(name).toString('utf8').replace(/^/, '')
|
||||
|
||||
const container = read('META-INF/container.xml')
|
||||
const opfPath = attr(/<rootfile\b([^>]*)>/i.exec(container)?.[1] || '', 'full-path')
|
||||
if (!opfPath) throw new Error('EPUB sin paquete')
|
||||
const opf = read(opfPath)
|
||||
const dc = name => clean(new RegExp(`<dc:${name}\\b[^>]*>([\\s\\S]*?)</dc:${name}>`, 'i').exec(opf)?.[1] || '')
|
||||
const manifest = new Map()
|
||||
for (const m of opf.matchAll(/<item\b([^>]*)>/gi)) {
|
||||
const id = attr(m[1], 'id'), href = attr(m[1], 'href')
|
||||
if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' })
|
||||
}
|
||||
const spine = [...opf.matchAll(/<itemref\b([^>]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type))
|
||||
if (!spine.length) throw new Error('EPUB sin contenido')
|
||||
let toc = []
|
||||
try { toc = readToc(read, opf, manifest) } catch {}
|
||||
|
||||
const images = []
|
||||
const imageIdx = new Map()
|
||||
const imageOf = name => {
|
||||
const e = entries.get(name)
|
||||
if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null
|
||||
if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) }
|
||||
return imageIdx.get(name)
|
||||
}
|
||||
|
||||
// Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene.
|
||||
const targets = new Map()
|
||||
for (const t of toc) {
|
||||
if (!targets.has(t.file)) targets.set(t.file, new Map())
|
||||
targets.get(t.file).set(t.anchor, t.title)
|
||||
}
|
||||
const chapters = []
|
||||
const start = title => chapters.push({ title, blocks: [] })
|
||||
for (const item of spine) {
|
||||
const here = targets.get(item.path)
|
||||
if (here?.has(null) || (!toc.length)) start(here?.get(null) || '')
|
||||
if (!chapters.length) start('')
|
||||
xhtmlToBlocks(read(item.path), item.path, imageOf,
|
||||
b => chapters.at(-1).blocks.push(b),
|
||||
(id, flush) => {
|
||||
if (!here?.has(id)) return false
|
||||
flush()
|
||||
start(here.get(id))
|
||||
here.delete(id) // un id repetido no abre otro capítulo
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente.
|
||||
const merged = []
|
||||
let carry = null
|
||||
for (const c of chapters) {
|
||||
if (!c.blocks.length) continue
|
||||
const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks
|
||||
const title = c.title || carry?.title || ''
|
||||
carry = null
|
||||
if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue }
|
||||
merged.push({ title, blocks })
|
||||
}
|
||||
if (carry) merged.push(carry)
|
||||
const out = merged
|
||||
if (!out.length) throw new Error('EPUB sin texto')
|
||||
// Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un <meta>, que a veces no apunta a una imagen.
|
||||
const coverId = /<meta\b(?=[^>]*\bname\s*=\s*["']cover["'])[^>]*\bcontent\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
|
||||
const coverItem = [...manifest.values()].find(it => /\bcover-image\b/.test(it.props)) || manifest.get(coverId)
|
||||
const cover = coverItem ? imageOf(coverItem.path) : null
|
||||
out.forEach((c, i) => { if (!c.title) c.title = `Sección ${i + 1}` })
|
||||
// El navegador maqueta el libro entero sin cargar las imágenes: necesita saber su tamaño.
|
||||
for (const c of out) {
|
||||
for (const b of c.blocks) {
|
||||
if (b.k !== 'img') continue
|
||||
try { Object.assign(b, imageSize(readBuf(images[b.n]))) } catch {}
|
||||
}
|
||||
}
|
||||
const year = parseInt(dc('date'), 10)
|
||||
return {
|
||||
version: PARSER_VERSION,
|
||||
title: dc('title'), authors: [...opf.matchAll(/<dc:creator\b[^>]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean),
|
||||
language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null,
|
||||
images, cover, chapters: out,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Fuente y caché ----------
|
||||
// Portada generada para lo que no trae una: el título sobre un color que sale del propio título.
|
||||
function titleCover(title, author) {
|
||||
const esc = t => String(t).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]))
|
||||
const wrap = (t, width, max) => {
|
||||
const lines = []
|
||||
let cur = ''
|
||||
for (const w of t.split(/\s+/)) {
|
||||
if (cur && cur.length + w.length + 1 > width) { lines.push(cur); cur = w } else cur = cur ? `${cur} ${w}` : w
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
if (lines.length > max) { lines.length = max; lines[max - 1] += '…' }
|
||||
return lines.map(l => l.slice(0, width + 6))
|
||||
}
|
||||
const hue = [...String(title)].reduce((n, c) => (n * 31 + c.codePointAt(0)) % 360, 7)
|
||||
const lines = wrap(title, 15, 6)
|
||||
const by = author ? wrap(author, 22, 2) : []
|
||||
const text = (t, y, size, weight, fill) => `<text x="200" y="${y}" font-family="Georgia,'Times New Roman',serif" font-size="${size}" font-weight="${weight}" text-anchor="middle" fill="${fill}">${esc(t)}</text>`
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600" width="400" height="600"><rect width="400" height="600" fill="hsl(${hue} 38% 24%)"/><rect x="18" y="18" width="364" height="564" fill="none" stroke="hsl(${hue} 45% 72%)" stroke-width="2"/>${lines.map((l, i) => text(l, 150 + i * 48, 38, 'bold', '#f6f1e6')).join('')}${by.map((l, i) => text(l, 500 + i * 30, 23, 'normal', `hsl(${hue} 45% 80%)`)).join('')}</svg>`
|
||||
}
|
||||
|
||||
export function setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) {
|
||||
const DIR = path.join(DATA_DIR, 'books')
|
||||
fs.mkdirSync(DIR, { recursive: true })
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS user_books (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
authors TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_books_user ON user_books(user_id, created_at);`)
|
||||
// Un título de Wikisource puede ser largo para nombre de archivo.
|
||||
const fileKey = id => (id.startsWith('ws~') ? `ws-${crypto.createHash('sha256').update(id).digest('hex').slice(0, 32)}` : id.replace('~', '-'))
|
||||
const epubFile = id => path.join(DIR, `${fileKey(id)}.epub`)
|
||||
const jsonFile = id => path.join(DIR, `${fileKey(id)}.json`)
|
||||
|
||||
// Hasta 3 intentos: un fallo de red o un 5xx de Gutenberg suele durar poco. El motivo real queda en el log.
|
||||
async function fetchSource(url, timeout, name) {
|
||||
for (let i = 1; ; i++) {
|
||||
net.guard()
|
||||
let why
|
||||
try {
|
||||
const r = await safeFetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(timeout) })
|
||||
if (r.ok) return r
|
||||
if (r.status === 404) throw new HttpError(404, `Ese libro no está en ${name}`)
|
||||
if (r.status < 500 && r.status !== 429) throw new HttpError(502, `${name} respondió ${r.status}`)
|
||||
why = `HTTP ${r.status}`
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) throw e
|
||||
why = e.cause?.code || e.cause?.message || e.message
|
||||
net.failed()
|
||||
}
|
||||
console.warn(`${name} ${url}: intento ${i}: ${why}`)
|
||||
if (i >= 3) throw new HttpError(502, `${name} no responde ahora mismo. Prueba en un rato.`)
|
||||
await new Promise(r => setTimeout(r, 800 * i))
|
||||
}
|
||||
}
|
||||
const coverOf = (n, id) => coverUrl(`${GUTENBERG}/cache/epub/${n}/pg${n}.cover.medium.jpg`, id)
|
||||
// "Cervantes Saavedra, Miguel de" -> "Miguel de Cervantes Saavedra"
|
||||
const authorName = a => a.split(', ').reverse().join(' ')
|
||||
const entryText = (xml, tag) => clean(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] || '')
|
||||
function parseFeed(xml) {
|
||||
const out = []
|
||||
for (const [entry] of xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)) {
|
||||
const n = /<id>[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1]
|
||||
if (!n) continue // "Ordenar por…" y otras entradas de navegación
|
||||
const id = `gb~${n}`
|
||||
// El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)".
|
||||
const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '')
|
||||
out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
// lang del usuario ("es-la") a idioma del catálogo ("spanish").
|
||||
async function search(q, mode, page, lang) {
|
||||
const name = LANG_NAMES[String(lang || 'es').slice(0, 2)]
|
||||
const text = q.trim().replace(/\s+/g, ' ')
|
||||
const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' })
|
||||
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1
|
||||
if (start > 1) qs.set('start_index', String(start))
|
||||
return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await fetchSource(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3, GB_NAME)).text()))
|
||||
}
|
||||
|
||||
// ---------- Wikisource ----------
|
||||
const wsId = title => `ws~${Buffer.from(title).toString('base64url')}`
|
||||
const wsTitle = id => Buffer.from(id.slice(3), 'base64url').toString('utf8')
|
||||
const wsCard = root => {
|
||||
const id = wsId(root)
|
||||
// "Realidad (Pérez Galdós)": la obra y, entre paréntesis, de quién es o de qué año.
|
||||
const m = /^(.+?)\s*\(([^()]+)\)$/.exec(root)
|
||||
return { id, source: 'ws', title: m ? m[1] : root, cover: `/api/book/${id}/cover`, sub: m ? m[2] : undefined }
|
||||
}
|
||||
const wsQuery = async params => {
|
||||
const qs = new URLSearchParams({ action: 'query', format: 'json', ...params })
|
||||
return (await fetchSource(`${WS_API}?${qs}`, 20e3, WS_NAME)).json()
|
||||
}
|
||||
async function wsList(q, mode) {
|
||||
const text = q.trim()
|
||||
if (text) {
|
||||
const d = await wsQuery({ list: 'search', srsearch: text, srnamespace: '0', srlimit: '200', srprop: 'wordcount' })
|
||||
// Una novela es una página índice corta con muchas subpáginas ("Obra/12"): cuenta la obra, una vez.
|
||||
const works = new Map()
|
||||
for (const r of d.query?.search || []) {
|
||||
const root = r.title.split('/')[0]
|
||||
const w = works.get(root) || { root, chapters: 0, words: 0 }
|
||||
if (r.title.includes('/')) w.chapters++
|
||||
else w.words = r.wordcount
|
||||
works.set(root, w)
|
||||
}
|
||||
return [...works.values()].filter(w => w.chapters || w.words >= WS_MIN_WORDS).map(w => w.root)
|
||||
}
|
||||
const d = await wsQuery({ list: 'categorymembers', cmtitle: 'Categoría:Novelas', cmnamespace: '0', cmlimit: '500', ...(mode === 'latest' ? { cmsort: 'timestamp', cmdir: 'desc' } : {}) })
|
||||
return (d.query?.categorymembers || []).map(m => m.title)
|
||||
}
|
||||
async function wsSearch(q, mode, page) {
|
||||
const list = await cached(`ws:${mode === 'latest' ? 'latest' : 'popular'}:${q.trim().toLowerCase()}`, 30 * 60e3, () => wsList(q, mode))
|
||||
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE
|
||||
return list.filter(t => Buffer.byteLength(t) <= 280).slice(start, start + OPDS_PAGE).map(wsCard)
|
||||
}
|
||||
|
||||
// ---------- Libros subidos ----------
|
||||
const owns = (userId, id) => !!db.prepare('SELECT 1 FROM user_books WHERE id = ? AND user_id = ?').get(id, userId)
|
||||
const ubCard = r => ({ id: r.id, source: 'ub', title: r.title, cover: `/api/book/${r.id}/cover`, sub: JSON.parse(r.authors).join(', ') || undefined })
|
||||
function ubList(userId, q) {
|
||||
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
const words = fold(q).split(/\s+/).filter(Boolean)
|
||||
return db.prepare('SELECT * FROM user_books WHERE user_id = ? ORDER BY created_at DESC').all(userId)
|
||||
.filter(r => words.every(w => fold(`${r.title} ${r.authors}`).includes(w))).map(ubCard)
|
||||
}
|
||||
async function upload(req, user) {
|
||||
if (db.prepare('SELECT COUNT(*) n FROM user_books WHERE user_id = ?').get(user.id).n >= MAX_UPLOADS_PER_USER) throw new HttpError(400, `Se admiten hasta ${MAX_UPLOADS_PER_USER} libros por usuario`)
|
||||
let size = 0
|
||||
const chunks = []
|
||||
for await (const c of req) {
|
||||
size += c.length
|
||||
if (size > MAX_EPUB_BYTES) throw new HttpError(413, 'El libro es demasiado grande (máximo 100 MB)')
|
||||
chunks.push(c)
|
||||
}
|
||||
if (!size) throw new HttpError(400, 'No se recibió ningún archivo')
|
||||
const data = Buffer.concat(chunks)
|
||||
// El mismo archivo subido dos veces por la misma persona es el mismo libro.
|
||||
const id = `ub~${crypto.createHash('sha256').update(`${user.id}:`).update(data).digest('hex').slice(0, 32)}`
|
||||
const tmp = path.join(DIR, `up-${crypto.randomBytes(8).toString('hex')}.part`)
|
||||
fs.writeFileSync(tmp, data)
|
||||
let book
|
||||
try { book = parseEpub(tmp) } catch (e) {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
throw new HttpError(400, `No se pudo leer ese archivo como EPUB: ${String(e.message).slice(0, 120)}`)
|
||||
}
|
||||
fs.renameSync(tmp, epubFile(id))
|
||||
fs.writeFileSync(jsonFile(id), JSON.stringify(book))
|
||||
remember(id, book)
|
||||
db.prepare(`INSERT INTO user_books (id, user_id, title, authors, created_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET created_at = excluded.created_at`)
|
||||
.run(id, user.id, (book.title || 'Sin título').slice(0, 300), JSON.stringify(book.authors.map(authorName).slice(0, 10)), Date.now())
|
||||
return ubCard(db.prepare('SELECT * FROM user_books WHERE id = ?').get(id))
|
||||
}
|
||||
function removeUpload(id, userId) {
|
||||
db.prepare('DELETE FROM user_books WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(userId, id)
|
||||
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(userId, id)
|
||||
db.prepare('DELETE FROM manga_prefs WHERE user_id = ? AND manga_id = ?').run(userId, id)
|
||||
db.prepare('DELETE FROM list_items WHERE manga_id = ? AND list_id IN (SELECT id FROM lists WHERE user_id = ?)').run(id, userId)
|
||||
memory.delete(id)
|
||||
packed.delete(id)
|
||||
fs.rmSync(epubFile(id), { force: true })
|
||||
fs.rmSync(jsonFile(id), { force: true })
|
||||
}
|
||||
|
||||
// Una sola descarga y conversión a la vez por libro.
|
||||
const loading = new Map()
|
||||
const memory = new Map() // últimos libros convertidos
|
||||
function remember(id, book) {
|
||||
memory.delete(id)
|
||||
memory.set(id, book)
|
||||
if (memory.size > 4) memory.delete(memory.keys().next().value)
|
||||
return book
|
||||
}
|
||||
async function downloadEpub(id) {
|
||||
if (id.startsWith('ub~')) throw new HttpError(404, 'Ese libro ya no está')
|
||||
const r = id.startsWith('ws~')
|
||||
? await fetchSource(`${WS_EXPORT}?${new URLSearchParams({ lang: 'es', page: wsTitle(id), format: 'epub-3' })}`, 120e3, WS_NAME)
|
||||
: await fetchSource(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3, GB_NAME)
|
||||
if (Number(r.headers.get('content-length')) > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
if (buf.length > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
|
||||
const tmp = `${epubFile(id)}.part`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, epubFile(id))
|
||||
}
|
||||
function loadBook(id) {
|
||||
const hit = memory.get(id)
|
||||
if (hit) return Promise.resolve(remember(id, hit))
|
||||
if (loading.has(id)) return loading.get(id)
|
||||
const p = (async () => {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(jsonFile(id), 'utf8'))
|
||||
if (saved.version === PARSER_VERSION) return remember(id, saved)
|
||||
} catch {}
|
||||
if (!fs.existsSync(epubFile(id))) await downloadEpub(id)
|
||||
let book
|
||||
try { book = parseEpub(epubFile(id)) } catch (e) {
|
||||
console.warn(`EPUB ${id}: ${e.message}`)
|
||||
fs.rmSync(epubFile(id), { force: true }) // descarga mala: que el siguiente intento la repita
|
||||
throw new HttpError(502, 'No se pudo leer el EPUB de este libro')
|
||||
}
|
||||
const tmp = `${jsonFile(id)}.part`
|
||||
fs.writeFileSync(tmp, JSON.stringify(book))
|
||||
fs.renameSync(tmp, jsonFile(id))
|
||||
return remember(id, book)
|
||||
})().finally(() => loading.delete(id))
|
||||
loading.set(id, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function manga(id) {
|
||||
const book = await loadBook(id)
|
||||
const source = id.slice(0, 2)
|
||||
return {
|
||||
manga: {
|
||||
id, source, type: 'Libro',
|
||||
title: book.title || (source === 'ws' ? wsCard(wsTitle(id)).title : `Libro ${id.slice(3)}`),
|
||||
description: book.description || '',
|
||||
status: null, year: null, // la fecha del EPUB es la de la edición digital, no la de la obra
|
||||
tags: [],
|
||||
authors: book.authors.map(authorName),
|
||||
languages: book.language ? [book.language] : [],
|
||||
cover: source === 'gb' ? coverOf(id.slice(3), id) : `/api/book/${id}/cover`,
|
||||
},
|
||||
chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })),
|
||||
}
|
||||
}
|
||||
async function chapter(chapterId) {
|
||||
const cut = chapterId.lastIndexOf('~')
|
||||
const book = await loadBook(chapterId.slice(0, cut))
|
||||
const c = book.chapters[Number(chapterId.slice(cut + 1))]
|
||||
if (!c) throw new HttpError(404, 'Ese capítulo no existe')
|
||||
return { title: c.title }
|
||||
}
|
||||
// El libro entero (lo maqueta el navegador), comprimido una sola vez: son megas de texto.
|
||||
const packed = new Map()
|
||||
async function sendText(req, res, id) {
|
||||
const book = await loadBook(id)
|
||||
let hit = packed.get(id)
|
||||
if (hit?.book !== book) {
|
||||
hit = { book, gz: zlib.gzipSync(JSON.stringify({ title: book.title, chapters: book.chapters })) }
|
||||
packed.delete(id)
|
||||
packed.set(id, hit)
|
||||
if (packed.size > 4) packed.delete(packed.keys().next().value)
|
||||
}
|
||||
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'private, no-cache', Vary: 'Accept-Encoding' }
|
||||
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) { res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' }); return res.end(hit.gz) }
|
||||
res.writeHead(200, headers)
|
||||
res.end(zlib.gunzipSync(hit.gz))
|
||||
}
|
||||
async function sendImage(res, id, n) {
|
||||
const book = await loadBook(id)
|
||||
const name = book.images[n]
|
||||
const entry = name && readZipIndex(epubFile(id)).find(e => e.name === name)
|
||||
if (!entry) throw new HttpError(404, 'Imagen no encontrada')
|
||||
const data = readCbzEntry(epubFile(id), entry, MAX_UNZIPPED_ENTRY)
|
||||
res.writeHead(200, { 'Content-Type': imageType(name), 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
|
||||
res.end(data)
|
||||
}
|
||||
async function sendCover(res, id) {
|
||||
if (id.startsWith('ub~')) {
|
||||
const book = await loadBook(id)
|
||||
if (book.cover != null) return sendImage(res, id, book.cover)
|
||||
const r = db.prepare('SELECT title, authors FROM user_books WHERE id = ?').get(id)
|
||||
return sendSvg(res, titleCover(r?.title || book.title || 'Sin título', book.authors.map(authorName)[0]))
|
||||
}
|
||||
if (!id.startsWith('ws~')) throw new HttpError(404, 'Sin portada')
|
||||
const card = wsCard(wsTitle(id)) // en un listado: sin descargar el libro
|
||||
sendSvg(res, titleCover(card.title, card.sub))
|
||||
}
|
||||
const sendSvg = (res, svg) => {
|
||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
|
||||
res.end(svg)
|
||||
}
|
||||
return { search, wsSearch, ubList, upload, removeUpload, owns, manga, chapter, sendText, sendImage, sendCover }
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// Lectura/escritura mínima de CBZ (ZIP) sin dependencias.
|
||||
// Escribe sin compresión (las imágenes ya vienen comprimidas); lee entradas "stored" y "deflate".
|
||||
import fs from 'node:fs'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i
|
||||
|
||||
// entries: [{ name, data: Buffer }]. Escribe a un temporal y renombra para no dejar CBZ a medias.
|
||||
export function writeCbz(file, entries) {
|
||||
const parts = []
|
||||
const central = []
|
||||
let offset = 0
|
||||
for (const { name, data } of entries) {
|
||||
const nameBuf = Buffer.from(name, 'utf8')
|
||||
const crc = zlib.crc32(data)
|
||||
const local = Buffer.alloc(30)
|
||||
local.writeUInt32LE(0x04034b50, 0)
|
||||
local.writeUInt16LE(20, 4) // versión necesaria
|
||||
local.writeUInt16LE(0x0800, 6) // nombres en UTF-8
|
||||
local.writeUInt16LE(0, 8) // sin compresión
|
||||
local.writeUInt16LE(0, 10) // hora DOS
|
||||
local.writeUInt16LE(0x21, 12) // fecha DOS (1980-01-01)
|
||||
local.writeUInt32LE(crc, 14)
|
||||
local.writeUInt32LE(data.length, 18)
|
||||
local.writeUInt32LE(data.length, 22)
|
||||
local.writeUInt16LE(nameBuf.length, 26)
|
||||
local.writeUInt16LE(0, 28)
|
||||
parts.push(local, nameBuf, data)
|
||||
|
||||
const cd = Buffer.alloc(46)
|
||||
cd.writeUInt32LE(0x02014b50, 0)
|
||||
cd.writeUInt16LE(20, 4)
|
||||
cd.writeUInt16LE(20, 6)
|
||||
cd.writeUInt16LE(0x0800, 8)
|
||||
cd.writeUInt16LE(0, 10)
|
||||
cd.writeUInt16LE(0, 12)
|
||||
cd.writeUInt16LE(0x21, 14)
|
||||
cd.writeUInt32LE(crc, 16)
|
||||
cd.writeUInt32LE(data.length, 20)
|
||||
cd.writeUInt32LE(data.length, 24)
|
||||
cd.writeUInt16LE(nameBuf.length, 28)
|
||||
cd.writeUInt32LE(offset, 42)
|
||||
central.push(cd, nameBuf)
|
||||
offset += 30 + nameBuf.length + data.length
|
||||
}
|
||||
const cdSize = central.reduce((n, b) => n + b.length, 0)
|
||||
const end = Buffer.alloc(22)
|
||||
end.writeUInt32LE(0x06054b50, 0)
|
||||
end.writeUInt16LE(entries.length, 8)
|
||||
end.writeUInt16LE(entries.length, 10)
|
||||
end.writeUInt32LE(cdSize, 12)
|
||||
end.writeUInt32LE(offset, 16)
|
||||
|
||||
const tmp = `${file}.part`
|
||||
fs.writeFileSync(tmp, Buffer.concat([...parts, ...central, end]))
|
||||
fs.renameSync(tmp, file)
|
||||
}
|
||||
|
||||
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }]
|
||||
export function readCbzIndex(file) {
|
||||
return readZipIndex(file).filter(e => IMAGE_EXT.test(e.name) && !e.name.startsWith('__MACOSX'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
// Todas las entradas del ZIP, en el orden del archivo (también lo usan los EPUB).
|
||||
export function readZipIndex(file) {
|
||||
const fd = fs.openSync(file, 'r')
|
||||
try {
|
||||
const { size } = fs.fstatSync(fd)
|
||||
const tailLen = Math.min(size, 65557)
|
||||
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')
|
||||
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 = []
|
||||
const lh = Buffer.alloc(30)
|
||||
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
|
||||
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 })
|
||||
}
|
||||
return entries
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
export function imageType(name) {
|
||||
const ext = name.split('.').pop().toLowerCase()
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// Envía una entrada del CBZ por la respuesta HTTP.
|
||||
export function sendCbzEntry(res, file, entry) {
|
||||
const headers = { 'Content-Type': imageType(entry.name), 'Cache-Control': 'private, max-age=604800' }
|
||||
if (entry.method === 0) {
|
||||
res.writeHead(200, { ...headers, 'Content-Length': entry.size })
|
||||
fs.createReadStream(file, { start: entry.start, end: entry.start + entry.size - 1 }).pipe(res)
|
||||
return
|
||||
}
|
||||
const data = readCbzEntry(file, entry)
|
||||
res.writeHead(200, headers)
|
||||
res.end(data)
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
// Recortes: trozos de página (un panel, una viñeta) que el usuario guarda desde el lector, con la
|
||||
// referencia al manga/capítulo/página de donde salen. Cada recorte se reescala solo (Real-ESRGAN ×4,
|
||||
// o ×2 si ya es grande) en segundo plano. Los archivos van a <DATA_DIR>/clips/: <id>.png (original
|
||||
// tal como se recortó) y <id>-hd.webp (reescalado).
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { BIN, MODELS, MODEL, run } from './upscale.js'
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024 // un recorte largo de lectura vertical en PNG
|
||||
const MAX_SIDE = 8000 // a ×2 cabe en el máximo de WebP (16383 px)
|
||||
const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle
|
||||
const WEBP_QUALITY = 90
|
||||
|
||||
// Ancho y alto de un PNG (cabecera IHDR), o null si no es un PNG.
|
||||
function pngSize(buf) {
|
||||
if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return null
|
||||
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }
|
||||
}
|
||||
|
||||
export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) {
|
||||
const DIR = path.join(DATA_DIR, 'clips')
|
||||
fs.mkdirSync(DIR, { recursive: true })
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
manga_title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
chapter_id TEXT NOT NULL,
|
||||
chapter_label TEXT,
|
||||
page INTEGER NOT NULL, -- 0-based
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
scale INTEGER, -- 2 o 4 cuando ya está reescalado
|
||||
note TEXT,
|
||||
status TEXT NOT NULL, -- queued | running | done | error | off (sin GPU)
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at);
|
||||
`)
|
||||
db.prepare("UPDATE clips SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad
|
||||
|
||||
const file = (id, hd) => path.join(DIR, hd ? `${id}-hd.webp` : `${id}.png`)
|
||||
const hasX4 = () => fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`))
|
||||
|
||||
async function upscaleClip(clip) {
|
||||
const scale = Math.max(clip.width, clip.height) <= X4_MAX_SIDE && hasX4() ? 4 : 2
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-'))
|
||||
try {
|
||||
const out = path.join(work, 'out.png')
|
||||
await run(BIN, ['-i', file(clip.id), '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
|
||||
const tmp = path.join(DIR, `${clip.id}-hd.tmp.webp`)
|
||||
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
|
||||
if (!db.prepare('SELECT 1 FROM clips WHERE id = ?').get(clip.id)) return fs.rmSync(tmp, { force: true }) // borrado mientras tanto
|
||||
fs.renameSync(tmp, file(clip.id, true))
|
||||
db.prepare("UPDATE clips SET status = 'done', scale = ?, error = NULL WHERE id = ?").run(scale, clip.id)
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
let pumping = false
|
||||
async function pump() {
|
||||
if (pumping || !upscale.available()) return
|
||||
pumping = true
|
||||
try {
|
||||
for (;;) {
|
||||
const clip = db.prepare("SELECT * FROM clips WHERE status = 'queued' ORDER BY id LIMIT 1").get()
|
||||
if (!clip) break
|
||||
db.prepare("UPDATE clips SET status = 'running' WHERE id = ?").run(clip.id)
|
||||
const t = Date.now()
|
||||
try {
|
||||
await upscaleClip(clip)
|
||||
console.log(`Recorte ${clip.id} (${clip.width}×${clip.height}) reescalado en ${((Date.now() - t) / 1000).toFixed(1)} s`)
|
||||
} catch (e) {
|
||||
console.warn('Recorte', clip.id, e.message)
|
||||
db.prepare("UPDATE clips SET status = 'error', error = ? WHERE id = ?").run(e.message.slice(0, 500), clip.id)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pumping = false
|
||||
}
|
||||
}
|
||||
setTimeout(pump, 3000) // tras arrancar (cwebp se comprueba de forma asíncrona)
|
||||
|
||||
const view = c => ({
|
||||
id: c.id, mangaId: c.manga_id, mangaTitle: c.manga_title, cover: c.cover, chapterId: c.chapter_id, chapterLabel: c.chapter_label,
|
||||
page: c.page, width: c.width, height: c.height, scale: c.scale, note: c.note, status: c.status, error: c.error, createdAt: c.created_at,
|
||||
img: `/api/clips/${c.id}/img`, hd: c.status === 'done' ? `/api/clips/${c.id}/img?hd=1` : null,
|
||||
})
|
||||
const own = (userId, id) => {
|
||||
const c = db.prepare('SELECT * FROM clips WHERE id = ? AND user_id = ?').get(id, userId)
|
||||
if (!c) throw new HttpError(404, 'Recorte no encontrado')
|
||||
return c
|
||||
}
|
||||
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
|
||||
|
||||
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
|
||||
// llena la franja) y, si los hay, de entre los ya reescalados.
|
||||
function bannerFor(userId, mangaId) {
|
||||
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
|
||||
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
|
||||
if (!c) return null
|
||||
const v = view(c)
|
||||
return v.hd || v.img
|
||||
}
|
||||
|
||||
async function readImage(req) {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
for await (const c of req) {
|
||||
size += c.length
|
||||
if (size > MAX_BYTES) throw new HttpError(413, 'El recorte es demasiado grande')
|
||||
chunks.push(c)
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
async function route(p, method, q, req, res, user) {
|
||||
if (p === '/api/clips' && method === 'GET') {
|
||||
const mangaId = q.get('manga')
|
||||
const rows = mangaId
|
||||
? db.prepare('SELECT * FROM clips WHERE user_id = ? AND manga_id = ? ORDER BY id DESC').all(user.id, mangaId)
|
||||
: db.prepare('SELECT * FROM clips WHERE user_id = ? ORDER BY id DESC').all(user.id)
|
||||
const mangas = db.prepare(`SELECT manga_id id, manga_title title, COUNT(*) n, MAX(id) last FROM clips WHERE user_id = ?
|
||||
GROUP BY manga_id ORDER BY last DESC`).all(user.id)
|
||||
return json(res, 200, { clips: rows.map(view), mangas, available: upscale.available() }), true
|
||||
}
|
||||
if (p === '/api/clips' && method === 'POST') {
|
||||
// Imagen PNG en el cuerpo; la referencia va en la query.
|
||||
const mangaId = q.get('mangaId') || '', chapterId = q.get('chapterId') || '', page = Number(q.get('page'))
|
||||
if (!MANGA_ID.test(mangaId) || !CHAPTER_ID.test(chapterId) || !Number.isInteger(page) || page < 0 || page > 5000) throw new HttpError(400, 'Referencia inválida')
|
||||
const buf = await readImage(req)
|
||||
const dim = pngSize(buf)
|
||||
if (!dim) throw new HttpError(400, 'El recorte debe ser PNG')
|
||||
if (dim.w < 16 || dim.h < 16 || dim.w > MAX_SIDE || dim.h > MAX_SIDE) throw new HttpError(400, `El recorte debe medir entre 16 y ${MAX_SIDE} px por lado`)
|
||||
const cover = (q.get('cover') || '').slice(0, 2000)
|
||||
const r = db.prepare(`INSERT INTO clips (user_id, manga_id, manga_title, cover, chapter_id, chapter_label, page, width, height, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
user.id, mangaId, (q.get('mangaTitle') || '').slice(0, 300) || 'Sin título', cover.startsWith('/api/') ? cover : null, chapterId,
|
||||
(q.get('chapterLabel') || '').slice(0, 300), page, dim.w, dim.h, upscale.available() ? 'queued' : 'off', Date.now())
|
||||
const id = Number(r.lastInsertRowid)
|
||||
fs.writeFileSync(file(id), buf)
|
||||
pump()
|
||||
return json(res, 200, view(own(user.id, id))), true
|
||||
}
|
||||
let m
|
||||
if ((m = /^\/api\/clips\/(\d+)\/img$/.exec(p)) && method === 'GET') {
|
||||
const c = own(user.id, Number(m[1]))
|
||||
const hd = q.get('hd') === '1' && c.status === 'done'
|
||||
const f = file(c.id, hd)
|
||||
if (!fs.existsSync(f)) throw new HttpError(404, 'Imagen no encontrada')
|
||||
const name = `${c.manga_title} - ${c.chapter_label || 'cap'} - p${c.page + 1}${hd ? ' HD' : ''}`.replace(/[\\/:*?"<>|]+/g, '_')
|
||||
res.writeHead(200, {
|
||||
'Content-Type': hd ? 'image/webp' : 'image/png', 'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
...(q.get('dl') === '1' ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}.${hd ? 'webp' : 'png'}` } : {}),
|
||||
})
|
||||
fs.createReadStream(f).pipe(res)
|
||||
return true
|
||||
}
|
||||
if ((m = /^\/api\/clips\/(\d+)$/.exec(p))) {
|
||||
const c = own(user.id, Number(m[1]))
|
||||
if (method === 'GET') return json(res, 200, view(c)), true
|
||||
if (method === 'PATCH') {
|
||||
const b = await readBody(req)
|
||||
if (b.note !== undefined) db.prepare('UPDATE clips SET note = ? WHERE id = ?').run(String(b.note).slice(0, 1000) || null, c.id)
|
||||
return json(res, 200, view(own(user.id, c.id))), true
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
db.prepare('DELETE FROM clips WHERE id = ?').run(c.id)
|
||||
fs.rmSync(file(c.id), { force: true })
|
||||
fs.rmSync(file(c.id, true), { force: true })
|
||||
return json(res, 200, { ok: true }), true
|
||||
}
|
||||
}
|
||||
if ((m = /^\/api\/clips\/(\d+)\/retry$/.exec(p)) && method === 'POST') {
|
||||
const c = own(user.id, Number(m[1]))
|
||||
if (!upscale.available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)')
|
||||
if (c.status === 'error' || c.status === 'off') db.prepare("UPDATE clips SET status = 'queued', error = NULL WHERE id = ?").run(c.id)
|
||||
pump()
|
||||
return json(res, 200, view(own(user.id, c.id))), true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
|
||||
FROM clips WHERE user_id = ?`).get(userId)
|
||||
return { route, count, bannerFor, pump, queueInfo }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// ¿Tiene el servidor salida a internet? Si las fuentes fallan por red, se comprueba contra varios sitios
|
||||
// a la vez; si ninguno responde, el servidor pasa a "sin conexión": las peticiones a las fuentes fallan
|
||||
// al instante (en vez de esperar timeouts y reintentos), las descargas y comprobaciones se pausan, y la
|
||||
// app trabaja solo con lo descargado. Mientras dure, se vuelve a comprobar cada poco y, al volver la
|
||||
// conexión, se reanuda todo solo.
|
||||
const PROBES = ['https://api.mangadex.org/ping', 'https://www.leercapitulo.co/', 'https://m440.in/', 'https://1.1.1.1/']
|
||||
const EVERY_ONLINE = 3 * 60e3
|
||||
const EVERY_OFFLINE = 20e3
|
||||
|
||||
export function setupConnectivity({ httpError }) {
|
||||
const state = { online: true, since: Date.now(), checkedAt: 0 }
|
||||
const onOnline = []
|
||||
let probing = null
|
||||
|
||||
// Cualquier respuesta HTTP (aunque sea un error) demuestra que hay internet.
|
||||
const probeOne = u => fetch(u, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(6e3) }).then(() => true, () => false)
|
||||
function check() {
|
||||
probing ??= (async () => {
|
||||
const ok = (await Promise.all(PROBES.map(probeOne))).some(Boolean)
|
||||
state.checkedAt = Date.now()
|
||||
if (ok !== state.online) {
|
||||
state.online = ok
|
||||
state.since = Date.now()
|
||||
console.log(ok ? 'Conexión a internet recuperada: se reanudan descargas y comprobaciones.' : 'Sin conexión a internet: modo sin conexión (solo lo descargado).')
|
||||
if (ok) for (const f of onOnline) { try { f() } catch (e) { console.warn('Reconexión:', e.message) } }
|
||||
}
|
||||
return ok
|
||||
})().finally(() => { probing = null })
|
||||
return probing
|
||||
}
|
||||
// Una fuente falló por red (no por un error HTTP): comprobar si es la fuente o es internet.
|
||||
function failed() {
|
||||
if (state.online && Date.now() - state.checkedAt > 10e3) check()
|
||||
}
|
||||
// Para cortar al instante las peticiones a las fuentes mientras no hay internet.
|
||||
function guard() {
|
||||
if (!state.online) throw httpError(503, 'El servidor no tiene conexión a internet: solo está disponible lo descargado.')
|
||||
}
|
||||
|
||||
// Sin conexión: cada 20 s. En línea solo hace falta confirmar de vez en cuando (los fallos de red ya
|
||||
// disparan la comprobación).
|
||||
setInterval(() => {
|
||||
if (!state.online || Date.now() - state.checkedAt > EVERY_ONLINE) check().catch(() => {})
|
||||
}, EVERY_OFFLINE).unref()
|
||||
check().catch(() => {})
|
||||
|
||||
return {
|
||||
online: () => state.online,
|
||||
status: () => ({ online: state.online, since: state.since, checkedAt: state.checkedAt }),
|
||||
check, failed, guard,
|
||||
whenOnline: f => onOnline.push(f),
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
|
||||
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
|
||||
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
|
||||
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { BIN, MODELS, MODEL, run } from './upscale.js'
|
||||
|
||||
const MAX_QUEUE = 300
|
||||
const X4_MAX_SIDE = 400
|
||||
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
|
||||
const WEBP_QUALITY = 88
|
||||
const BANNER_X4_MAX_WIDTH = 1000
|
||||
|
||||
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
|
||||
export function imageSize(b) {
|
||||
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
|
||||
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
||||
const f = b.toString('latin1', 12, 16)
|
||||
if (f === 'VP8 ') return { type: 'webp', w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
|
||||
if (f === 'VP8L') { const n = b.readUInt32LE(21); return { type: 'webp', w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1 } }
|
||||
if (f === 'VP8X') return { type: 'webp', w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
|
||||
return null
|
||||
}
|
||||
if (b[0] === 0xff && b[1] === 0xd8) {
|
||||
for (let i = 2; i + 9 < b.length;) {
|
||||
if (b[i] !== 0xff) { i++; continue }
|
||||
const marker = b[i + 1]
|
||||
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { i += marker === 0xff ? 1 : 2; continue }
|
||||
const len = b.readUInt16BE(i + 2)
|
||||
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpg', w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) }
|
||||
i += 2 + len
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function setupCovers({ DATA_DIR, upscale }) {
|
||||
const DIR = path.join(DATA_DIR, 'covers')
|
||||
fs.mkdirSync(DIR, { recursive: true })
|
||||
const fileOf = key => path.join(DIR, `${crypto.createHash('sha256').update(key).digest('hex').slice(0, 32)}.webp`)
|
||||
const queue = [] // { key, buf }
|
||||
const pending = new Set()
|
||||
const failed = new Set() // no reintentar hasta reiniciar
|
||||
|
||||
// Si ya hay versión HD, la envía y devuelve true.
|
||||
function sendCached(res, key) {
|
||||
if (!upscale.available()) return false
|
||||
const f = fileOf(key)
|
||||
if (!fs.existsSync(f)) return false
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=2592000, immutable' })
|
||||
fs.createReadStream(f).pipe(res)
|
||||
return true
|
||||
}
|
||||
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
|
||||
// Un banner no se guarda: en la siguiente visita ya se ve el reescalado.
|
||||
const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300')
|
||||
|
||||
// ¿Falta generar esta imagen? (para no descargarla si no hace falta)
|
||||
const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
|
||||
function enqueue(key, buf, banner = false) {
|
||||
if (!wants(key)) return
|
||||
pending.add(key)
|
||||
queue.push({ key, buf, banner })
|
||||
pump()
|
||||
}
|
||||
|
||||
async function processOne({ key, buf, banner }) {
|
||||
const size = imageSize(buf)
|
||||
if (!size) throw new Error('formato no reconocido')
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
|
||||
try {
|
||||
const out = path.join(work, 'out.png')
|
||||
const tmp = `${fileOf(key)}.tmp`
|
||||
if (!banner && Math.max(size.w, size.h) > MAX_SIDE) {
|
||||
// Ya es grande: solo se recomprime para no volver a intentarlo.
|
||||
const inp = path.join(work, `in.${size.type}`)
|
||||
fs.writeFileSync(inp, buf)
|
||||
if (size.type === 'webp') fs.copyFileSync(inp, tmp)
|
||||
else await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), inp, '-o', tmp])
|
||||
} else {
|
||||
const inp = path.join(work, `in.${size.type}`)
|
||||
fs.writeFileSync(inp, buf)
|
||||
const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE
|
||||
const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
|
||||
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
|
||||
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
|
||||
}
|
||||
fs.renameSync(tmp, fileOf(key))
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
let pumping = false
|
||||
async function pump() {
|
||||
if (pumping) return
|
||||
pumping = true
|
||||
try {
|
||||
let n = 0
|
||||
const t = Date.now()
|
||||
while (queue.length) {
|
||||
const job = queue.shift()
|
||||
try { await processOne(job); n++ } catch (e) { failed.add(job.key); console.warn('Portada HD:', e.message) } finally { pending.delete(job.key) }
|
||||
}
|
||||
if (n) console.log(`Portadas HD: ${n} en ${Math.round((Date.now() - t) / 1000)} s`)
|
||||
} finally {
|
||||
pumping = false
|
||||
}
|
||||
}
|
||||
|
||||
return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten.
|
||||
// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se
|
||||
// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada.
|
||||
const SIDE = 16
|
||||
const SIG_BYTES = SIDE * SIDE
|
||||
const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima
|
||||
const SAME_SHAPE = 0.03
|
||||
const MIN_PAGES = 5
|
||||
const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra
|
||||
const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan
|
||||
|
||||
const decode = s => Buffer.from(String(s || ''), 'base64')
|
||||
const spread = b => {
|
||||
const mean = b.reduce((a, x) => a + x, 0) / b.length
|
||||
return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255
|
||||
}
|
||||
const difference = (a, b) => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i])
|
||||
return sum / SIG_BYTES / 255
|
||||
}
|
||||
|
||||
/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */
|
||||
export function repeatedPages(rows, ids) {
|
||||
const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) })))
|
||||
.filter(p => p.sig.length === SIG_BYTES)
|
||||
const out = {}
|
||||
for (const r of rows) {
|
||||
if (!ids.includes(r.id) || r.n < MIN_PAGES) continue
|
||||
const hidden = []
|
||||
for (const p of all) {
|
||||
if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue
|
||||
if (![0, r.n - 2, r.n - 1].includes(p.i)) continue
|
||||
if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i)
|
||||
}
|
||||
if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS page_sigs (
|
||||
manga_id TEXT NOT NULL,
|
||||
chapter_id TEXT NOT NULL,
|
||||
n INTEGER NOT NULL,
|
||||
pages TEXT NOT NULL,
|
||||
PRIMARY KEY (manga_id, chapter_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS hide_credits (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, manga_id)
|
||||
);`)
|
||||
const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId)
|
||||
|
||||
function validPages(pages, n) {
|
||||
const entries = Object.entries(pages || {})
|
||||
if (!entries.length || entries.length > 3) return false
|
||||
return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES)
|
||||
}
|
||||
|
||||
async function route(p, method, req, res, user) {
|
||||
let m
|
||||
if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
const { on } = await readBody(req)
|
||||
if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1])
|
||||
else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
|
||||
json(res, 200, { hideCredits: !!on })
|
||||
return true
|
||||
}
|
||||
if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : []
|
||||
if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos')
|
||||
const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)')
|
||||
for (const c of wanted) {
|
||||
const pages = body.sigs?.[c.id]
|
||||
if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages))
|
||||
}
|
||||
const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) }))
|
||||
const known = new Map(rows.map(r => [r.id, r.n]))
|
||||
// Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale.
|
||||
const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id)
|
||||
const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n))
|
||||
json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return { route, enabled }
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
// 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(/\/+$/, '')
|
||||
|
||||
// Día local (TZ del contenedor) en formato YYYY-MM-DD.
|
||||
export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE')
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const chapterKey = c => c.num ?? c.id
|
||||
|
||||
// Nombres de género unificados para las estadísticas (LeerCapitulo mezcla inglés y español).
|
||||
const GENRE_ES = {
|
||||
action: 'Acción', accion: 'Acción', adventure: 'Aventura', comedy: 'Comedia', drama: 'Drama', fantasy: 'Fantasía',
|
||||
fantasia: 'Fantasía', romance: 'Romance', horror: 'Terror', terror: 'Terror', mystery: 'Misterio', misterio: 'Misterio',
|
||||
psychological: 'Psicológico', psicologico: 'Psicológico', 'sci-fi': 'Ciencia ficción', 'science fiction': 'Ciencia ficción',
|
||||
'ciencia ficcion': 'Ciencia ficción', 'slice of life': 'Recuentos de la vida', 'recuentos de la vida': 'Recuentos de la vida',
|
||||
sports: 'Deportes', deportes: 'Deportes', supernatural: 'Sobrenatural', sobrenatural: 'Sobrenatural', thriller: 'Thriller',
|
||||
tragedy: 'Tragedia', tragedia: 'Tragedia', historical: 'Histórico', historico: 'Histórico', mecha: 'Mecha',
|
||||
'martial arts': 'Artes marciales', 'artes marciales': 'Artes marciales', 'school life': 'Vida escolar', 'vida escolar': 'Vida escolar',
|
||||
shounen: 'Shounen', shoujo: 'Shoujo', seinen: 'Seinen', josei: 'Josei', isekai: 'Isekai', magic: 'Magia', magia: 'Magia',
|
||||
harem: 'Harem', ecchi: 'Ecchi', music: 'Música', musica: 'Música', military: 'Militar', militar: 'Militar', medical: 'Médico',
|
||||
crime: 'Crimen', crimen: 'Crimen', philosophical: 'Filosófico', reincarnation: 'Reencarnación', reencarnacion: 'Reencarnación',
|
||||
monsters: 'Monstruos', monstruos: 'Monstruos', vampires: 'Vampiros', vampiros: 'Vampiros', zombies: 'Zombis', zombis: 'Zombis',
|
||||
'video games': 'Videojuegos', videojuegos: 'Videojuegos', 'time travel': 'Viajes en el tiempo', survival: 'Supervivencia',
|
||||
supervivencia: 'Supervivencia', cooking: 'Cocina', cocina: 'Cocina', 'office workers': 'Oficinistas', villainess: 'Villana',
|
||||
demons: 'Demonios', demonios: 'Demonios', gore: 'Gore', superhero: 'Superhéroes', 'virtual reality': 'Realidad virtual',
|
||||
'post-apocalyptic': 'Postapocalíptico', apocaliptico: 'Postapocalíptico', animals: 'Animales', 'gender bender': 'Gender bender',
|
||||
'boys\' love': 'Boys\' love', yaoi: 'Boys\' love', "girls' love": "Girls' love", yuri: "Girls' love", wuxia: 'Wuxia',
|
||||
aliens: 'Alienígenas', ninja: 'Ninjas', samurai: 'Samuráis', police: 'Policial', mafia: 'Mafia', delinquents: 'Delincuentes',
|
||||
'reverse harem': 'Harem inverso', 'traditional games': 'Juegos tradicionales', ghosts: 'Fantasmas', 'monster girls': 'Monster girls',
|
||||
}
|
||||
// Etiquetas de formato que no son géneros.
|
||||
const NOT_GENRE = new Set(['long strip', 'full color', 'web comic', 'official colored', 'fan colored', 'adaptation', 'anthology',
|
||||
'award winning', 'doujinshi', 'oneshot', '4-koma', 'self-published', 'user created', 'manga', 'manhwa', 'manhua', 'webtoon'])
|
||||
export const genreName = raw => {
|
||||
const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
if (NOT_GENRE.has(k)) return null
|
||||
return GENRE_ES[k] || String(raw).trim()
|
||||
}
|
||||
const MODES = ['paged', 'double', 'vertical']
|
||||
const FITS = ['height', 'width']
|
||||
const DIRS = ['ltr', 'rtl']
|
||||
|
||||
export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS follows (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
lang TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
known TEXT, -- JSON: números de capítulo ya vistos (null = sin inicializar)
|
||||
new_keys TEXT NOT NULL DEFAULT '[]', -- JSON: números de capítulo nuevos aún no vistos por el usuario
|
||||
last_checked INTEGER,
|
||||
last_new_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, manga_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS manga_prefs (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
prefs TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, manga_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS manga_meta (
|
||||
manga_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
type TEXT,
|
||||
tags TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS page_reads (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
chapter_id TEXT NOT NULL,
|
||||
page INTEGER NOT NULL,
|
||||
manga_id TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, chapter_id, page)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_reads_day ON page_reads(user_id, day);
|
||||
CREATE TABLE IF NOT EXISTS chapter_done (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
chapter_id TEXT NOT NULL,
|
||||
manga_id TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, chapter_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT);
|
||||
`)
|
||||
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'notify')) db.exec('ALTER TABLE users ADD COLUMN notify TEXT')
|
||||
|
||||
// Migración única: estadísticas e historial previos a esta versión.
|
||||
if (!db.prepare("SELECT 1 FROM kv WHERE k = 'migr_features_v1'").get()) {
|
||||
const insPage = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)')
|
||||
const insDone = db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)')
|
||||
for (const r of db.prepare('SELECT * FROM chapter_reads').all()) {
|
||||
const day = dayOf(r.updated_at)
|
||||
for (let p = 0; p <= Math.min(r.page, 500); p++) insPage.run(r.user_id, r.chapter_id, p, r.manga_id, day)
|
||||
if (r.total_pages && r.page >= r.total_pages - 1) insDone.run(r.user_id, r.chapter_id, r.manga_id, day)
|
||||
}
|
||||
db.prepare(`INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at)
|
||||
SELECT user_id, manga_id, manga_title, cover, lang, updated_at FROM progress`).run()
|
||||
db.prepare("INSERT INTO kv (k, v) VALUES ('migr_features_v1', ?)").run(String(Date.now()))
|
||||
}
|
||||
|
||||
function saveMeta(manga) {
|
||||
db.prepare(`INSERT INTO manga_meta (manga_id, title, source, type, tags, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (manga_id) DO UPDATE SET title = excluded.title, source = excluded.source, type = excluded.type,
|
||||
tags = excluded.tags, updated_at = excluded.updated_at`)
|
||||
.run(manga.id, manga.title, manga.source, manga.type || null, JSON.stringify((manga.tags || []).map(t => t.name ?? t)), Date.now())
|
||||
}
|
||||
|
||||
// ---------- Notificaciones ----------
|
||||
const getNotify = userId => {
|
||||
try { return JSON.parse(db.prepare('SELECT notify FROM users WHERE id = ?').get(userId)?.notify || '{}') } catch { return {} }
|
||||
}
|
||||
async function sendNotification(cfg, { title, message, click }) {
|
||||
const errors = []
|
||||
if (cfg.ntfyUrl && cfg.ntfyTopic) {
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (cfg.ntfyToken) headers.Authorization = cfg.ntfyToken.includes(':') ? `Basic ${Buffer.from(cfg.ntfyToken).toString('base64')}` : `Bearer ${cfg.ntfyToken}`
|
||||
// Publicación JSON: admite UTF-8 en el título (en cabeceras no).
|
||||
const r = await fetch(cfg.ntfyUrl, {
|
||||
method: 'POST', headers, signal: AbortSignal.timeout(15e3),
|
||||
body: JSON.stringify({ topic: cfg.ntfyTopic, title, message, click, tags: ['books'] }),
|
||||
})
|
||||
if (!r.ok) errors.push(`ntfy: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`)
|
||||
} catch (e) { errors.push(`ntfy: ${e.message}`) }
|
||||
}
|
||||
if (cfg.tgToken && cfg.tgChat) {
|
||||
try {
|
||||
const r = await fetch(`https://api.telegram.org/bot${cfg.tgToken}/sendMessage`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15e3),
|
||||
body: JSON.stringify({ chat_id: cfg.tgChat, text: `${title}\n${message}\n${click}`, disable_web_page_preview: true }),
|
||||
})
|
||||
if (!r.ok) errors.push(`Telegram: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`)
|
||||
} catch (e) { errors.push(`Telegram: ${e.message}`) }
|
||||
}
|
||||
return errors
|
||||
}
|
||||
const hasNotify = cfg => !!((cfg.ntfyUrl && cfg.ntfyTopic) || (cfg.tgToken && cfg.tgChat))
|
||||
|
||||
// ---------- Comprobación de capítulos nuevos ----------
|
||||
const state = { checking: null, lastCheckAt: null, nextCheckAt: null }
|
||||
|
||||
async function checkManga(mangaId, lang) {
|
||||
const { manga, chapters } = await fetchMangaFull(mangaId, lang || 'es-la')
|
||||
saveMeta(manga)
|
||||
if (!chapters.length) return 0 // respuesta vacía: no tocar lo conocido
|
||||
const keys = chapters.map(chapterKey)
|
||||
const byKey = new Map(chapters.map(c => [chapterKey(c), c]))
|
||||
let anyNew = 0
|
||||
const now = Date.now()
|
||||
for (const f of db.prepare('SELECT * FROM follows WHERE active = 1 AND manga_id = ? AND lang IS ?').all(mangaId, lang ?? null)) {
|
||||
if (f.known == null) {
|
||||
db.prepare('UPDATE follows SET known = ?, last_checked = ? WHERE user_id = ? AND manga_id = ?').run(JSON.stringify(keys), now, f.user_id, mangaId)
|
||||
continue
|
||||
}
|
||||
const known = new Set(JSON.parse(f.known))
|
||||
const fresh = keys.filter(k => !known.has(k))
|
||||
const pending = [...new Set([...JSON.parse(f.new_keys), ...fresh])]
|
||||
db.prepare(`UPDATE follows SET known = ?, new_keys = ?, last_checked = ?, last_new_at = COALESCE(?, last_new_at), title = ?
|
||||
WHERE user_id = ? AND manga_id = ?`)
|
||||
.run(JSON.stringify([...known, ...fresh]), JSON.stringify(pending), now, fresh.length ? now : null, manga.title, f.user_id, mangaId)
|
||||
if (!fresh.length) continue
|
||||
anyNew += fresh.length
|
||||
const cfg = getNotify(f.user_id)
|
||||
if (hasNotify(cfg)) {
|
||||
const nums = fresh.map(k => byKey.get(k)?.num).filter(Boolean)
|
||||
const message = `${fresh.length === 1 ? '1 capítulo nuevo' : `${fresh.length} capítulos nuevos`}${nums.length ? `: ${nums.slice(0, 8).join(', ')}${nums.length > 8 ? '…' : ''}` : ''}`
|
||||
const errs = await sendNotification(cfg, { title: manga.title, message, click: `${PUBLIC_URL}/#/manga/${mangaId}` })
|
||||
if (errs.length) console.warn('Notificación', f.user_id, errs.join('; '))
|
||||
}
|
||||
}
|
||||
// Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo.
|
||||
const dl = anyNew && downloadInfo(mangaId)
|
||||
// Solo si se baja el manga entero; con capítulos sueltos, lo nuevo no se descarga solo.
|
||||
if (dl && dl.scope !== 'some' && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang)
|
||||
return anyNew
|
||||
}
|
||||
|
||||
let skippedOffline = false
|
||||
function checkFollows() {
|
||||
if (state.checking) return state.checking
|
||||
// Sin internet no se puede comprobar nada: se hace en cuanto vuelva la conexión.
|
||||
if (!net.online()) { skippedOffline = true; return Promise.resolve() }
|
||||
state.checking = (async () => {
|
||||
const groups = db.prepare('SELECT manga_id, lang FROM follows WHERE active = 1 GROUP BY manga_id, lang').all()
|
||||
let found = 0
|
||||
for (const g of groups) {
|
||||
try { found += await checkManga(g.manga_id, g.lang) } catch (e) { console.warn('Comprobación', g.manga_id, e.message) }
|
||||
await sleep(/^(lc|mi)~/.test(g.manga_id) ? 1500 : 2500)
|
||||
}
|
||||
state.lastCheckAt = Date.now()
|
||||
if (groups.length) console.log(`Comprobados ${groups.length} mangas seguidos: ${found} capítulo(s) nuevo(s)`)
|
||||
})().finally(() => {
|
||||
state.checking = null
|
||||
state.nextCheckAt = Date.now() + CHECK_HOURS * 3600e3
|
||||
})
|
||||
return state.checking
|
||||
}
|
||||
net.whenOnline(() => { if (skippedOffline) { skippedOffline = false; checkFollows() } })
|
||||
state.nextCheckAt = Date.now() + 90e3
|
||||
setTimeout(() => {
|
||||
checkFollows()
|
||||
setInterval(checkFollows, CHECK_HOURS * 3600e3)
|
||||
}, 90e3)
|
||||
|
||||
// ---------- Hooks llamados desde server.js ----------
|
||||
function onProgress(userId, b, page, total, prevPage) {
|
||||
const day = dayOf(Date.now())
|
||||
const from = prevPage != null && page > prevPage && page - prevPage <= 300 ? prevPage + 1 : page
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)')
|
||||
for (let p = from; p <= page; p++) ins.run(userId, b.chapterId, p, b.mangaId, day)
|
||||
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,
|
||||
/^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang || null, Date.now())
|
||||
}
|
||||
|
||||
// Datos extra de la ficha; marca como vistos los capítulos nuevos.
|
||||
function mangaExtras(userId, manga) {
|
||||
saveMeta(manga)
|
||||
const f = db.prepare('SELECT active, new_keys FROM follows WHERE user_id = ? AND manga_id = ?').get(userId, manga.id)
|
||||
const newKeys = f?.active ? JSON.parse(f.new_keys) : []
|
||||
if (newKeys.length) db.prepare("UPDATE follows SET new_keys = '[]' WHERE user_id = ? AND manga_id = ?").run(userId, manga.id)
|
||||
const prefs = db.prepare('SELECT prefs FROM manga_prefs WHERE user_id = ? AND manga_id = ?').get(userId, manga.id)
|
||||
return { following: !!f?.active, newKeys, prefs: prefs ? JSON.parse(prefs.prefs) : null }
|
||||
}
|
||||
|
||||
function unfollow(userId, mangaId) {
|
||||
db.prepare('UPDATE follows SET active = 0 WHERE user_id = ? AND manga_id = ?').run(userId, mangaId)
|
||||
}
|
||||
|
||||
// ---------- Estadísticas ----------
|
||||
function stats(userId) {
|
||||
const pagesByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM page_reads WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n]))
|
||||
const chapsByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n]))
|
||||
const today = dayOf(Date.now())
|
||||
const addDays = (d, n) => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() + n); return x.toLocaleDateString('sv-SE') }
|
||||
|
||||
// Rachas: días consecutivos con alguna página leída.
|
||||
const days = [...pagesByDay.keys()].sort()
|
||||
let best = 0, run = 0, prev = null
|
||||
for (const d of days) {
|
||||
run = prev && addDays(prev, 1) === d ? run + 1 : 1
|
||||
best = Math.max(best, run)
|
||||
prev = d
|
||||
}
|
||||
let current = 0
|
||||
for (let d = pagesByDay.has(today) ? today : addDays(today, -1); pagesByDay.has(d); d = addDays(d, -1)) current++
|
||||
|
||||
// Semanas (lunes a domingo), las últimas 12.
|
||||
const monday = d => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x.toLocaleDateString('sv-SE') }
|
||||
const weeks = []
|
||||
for (let i = 11, start = monday(today); i >= 0; i--) {
|
||||
const ws = addDays(start, -7 * i)
|
||||
let pages = 0, chapters = 0
|
||||
for (let k = 0; k < 7; k++) { const d = addDays(ws, k); pages += pagesByDay.get(d) || 0; chapters += chapsByDay.get(d) || 0 }
|
||||
weeks.push({ start: ws, pages, chapters })
|
||||
}
|
||||
|
||||
// Calendario: últimas 18 semanas día a día.
|
||||
const calStart = addDays(monday(today), -7 * 17)
|
||||
const calendar = []
|
||||
for (let d = calStart; d <= today; d = addDays(d, 1)) calendar.push({ day: d, pages: pagesByDay.get(d) || 0, chapters: chapsByDay.get(d) || 0 })
|
||||
|
||||
// Géneros ponderados por páginas leídas de cada manga.
|
||||
const perManga = db.prepare(`SELECT r.manga_id, COUNT(*) pages, m.tags, m.title, m.type FROM page_reads r
|
||||
LEFT JOIN manga_meta m ON m.manga_id = r.manga_id WHERE r.user_id = ? GROUP BY r.manga_id`).all(userId)
|
||||
const genreMap = new Map()
|
||||
let withMeta = 0
|
||||
for (const m of perManga) {
|
||||
if (!m.tags) continue
|
||||
withMeta += m.pages
|
||||
const names = [...new Set(JSON.parse(m.tags).map(genreName).filter(Boolean))]
|
||||
for (const g of names) genreMap.set(g, (genreMap.get(g) || 0) + m.pages)
|
||||
}
|
||||
const genres = [...genreMap].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
.map(([name, pages]) => ({ name, pages, pct: withMeta ? Math.round((pages / withMeta) * 100) : 0 }))
|
||||
|
||||
const chapByManga = new Map(db.prepare('SELECT manga_id, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY manga_id').all(userId).map(r => [r.manga_id, r.n]))
|
||||
const progress = new Map(db.prepare('SELECT manga_id, manga_title, cover FROM progress WHERE user_id = ?').all(userId).map(r => [r.manga_id, r]))
|
||||
const topMangas = perManga.sort((a, b) => b.pages - a.pages).slice(0, 8).map(m => ({
|
||||
id: m.manga_id, title: m.title || progress.get(m.manga_id)?.manga_title || m.manga_id, cover: progress.get(m.manga_id)?.cover || null,
|
||||
pages: m.pages, chapters: chapByManga.get(m.manga_id) || 0,
|
||||
}))
|
||||
const types = new Map()
|
||||
for (const m of perManga) if (m.type) types.set(m.type, (types.get(m.type) || 0) + m.pages)
|
||||
|
||||
return {
|
||||
totals: {
|
||||
pages: [...pagesByDay.values()].reduce((a, b) => a + b, 0),
|
||||
chapters: [...chapsByDay.values()].reduce((a, b) => a + b, 0),
|
||||
mangas: perManga.length,
|
||||
activeDays: days.length,
|
||||
thisWeekPages: weeks.at(-1).pages,
|
||||
thisWeekChapters: weeks.at(-1).chapters,
|
||||
},
|
||||
streak: { current, best, readToday: pagesByDay.has(today) },
|
||||
weeks, calendar, genres, topMangas,
|
||||
types: [...types].sort((a, b) => b[1] - a[1]).map(([name, pages]) => ({ name, pages })),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Rutas ----------
|
||||
async function route(p, method, q, req, res, user) {
|
||||
let m
|
||||
if (p === '/api/follows' && method === 'GET') {
|
||||
const list = db.prepare('SELECT manga_id, title, cover, lang, new_keys, last_checked, last_new_at FROM follows WHERE user_id = ? AND active = 1 ORDER BY COALESCE(last_new_at, 0) DESC, created_at DESC').all(user.id)
|
||||
return json(res, 200, {
|
||||
follows: list.map(f => ({ ...f, newCount: JSON.parse(f.new_keys).length, new_keys: undefined })),
|
||||
checking: !!state.checking, lastCheckAt: state.lastCheckAt, nextCheckAt: state.nextCheckAt, intervalHours: CHECK_HOURS,
|
||||
})
|
||||
}
|
||||
if (p === '/api/follows/check' && method === 'POST') {
|
||||
checkFollows()
|
||||
return json(res, 200, { checking: true })
|
||||
}
|
||||
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
const b = await readBody(req)
|
||||
const lang = /^(lc|mi)~/.test(m[1]) ? 'es' : /^[a-z]{2}(-[a-z]{2})?$/.test(b.lang || '') ? b.lang : user.lang
|
||||
db.prepare(`INSERT INTO follows (user_id, manga_id, title, cover, lang, active, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||
ON CONFLICT (user_id, manga_id) DO UPDATE SET active = 1, lang = excluded.lang, title = excluded.title, cover = excluded.cover,
|
||||
known = CASE WHEN follows.lang IS excluded.lang THEN follows.known ELSE NULL END`)
|
||||
.run(user.id, m[1], String(b.title || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, lang, Date.now())
|
||||
checkManga(m[1], lang).catch(e => console.warn('Seguir', m[1], e.message)) // inicializa lo conocido
|
||||
return json(res, 200, { following: true })
|
||||
}
|
||||
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
||||
unfollow(user.id, m[1])
|
||||
return json(res, 200, { following: false })
|
||||
}
|
||||
if ((m = /^\/api\/prefs\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
const b = await readBody(req)
|
||||
if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir) || (b.gap != null && typeof b.gap !== 'boolean')) throw new HttpError(400, 'Preferencias inválidas')
|
||||
db.prepare('INSERT OR REPLACE INTO manga_prefs (user_id, manga_id, prefs) VALUES (?, ?, ?)')
|
||||
.run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir, ...(b.gap != null ? { gap: b.gap } : {}) }))
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if (p === '/api/stats' && method === 'GET') return json(res, 200, stats(user.id))
|
||||
if (p === '/api/notify' && method === 'GET') {
|
||||
const cfg = getNotify(user.id)
|
||||
return json(res, 200, { ntfyUrl: cfg.ntfyUrl || '', ntfyTopic: cfg.ntfyTopic || '', hasNtfyToken: !!cfg.ntfyToken, tgChat: cfg.tgChat || '', hasTgToken: !!cfg.tgToken })
|
||||
}
|
||||
if (p === '/api/notify' && method === 'POST') {
|
||||
const b = await readBody(req)
|
||||
const old = getNotify(user.id)
|
||||
const str = (v, max) => String(v ?? '').trim().slice(0, max)
|
||||
const cfg = {
|
||||
ntfyUrl: str(b.ntfyUrl, 300).replace(/\/+$/, ''),
|
||||
ntfyTopic: str(b.ntfyTopic, 64),
|
||||
// Campo de token vacío = conservar el guardado; "-" = borrarlo.
|
||||
ntfyToken: b.ntfyToken === '-' ? '' : str(b.ntfyToken, 300) || old.ntfyToken || '',
|
||||
tgToken: b.tgToken === '-' ? '' : str(b.tgToken, 100) || old.tgToken || '',
|
||||
tgChat: str(b.tgChat, 40),
|
||||
}
|
||||
if (cfg.ntfyUrl && !/^https?:\/\/[^\s/]+/.test(cfg.ntfyUrl)) throw new HttpError(400, 'URL de ntfy inválida')
|
||||
if (cfg.ntfyTopic && !/^[\w-]{1,64}$/.test(cfg.ntfyTopic)) throw new HttpError(400, 'Tema de ntfy inválido (letras, números, - y _)')
|
||||
if (cfg.tgToken && !/^\d+:[\w-]{20,}$/.test(cfg.tgToken)) throw new HttpError(400, 'Token de Telegram inválido')
|
||||
if (cfg.tgChat && !/^-?\d+$|^@\w+$/.test(cfg.tgChat)) throw new HttpError(400, 'Chat de Telegram inválido')
|
||||
db.prepare('UPDATE users SET notify = ? WHERE id = ?').run(JSON.stringify(cfg), user.id)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if (p === '/api/notify/test' && method === 'POST') {
|
||||
const cfg = getNotify(user.id)
|
||||
if (!hasNotify(cfg)) throw new HttpError(400, 'No hay ningún canal de notificación configurado')
|
||||
const errors = await sendNotification(cfg, { title: 'Manga Reader', message: 'Notificación de prueba: los avisos de capítulos nuevos llegarán aquí.', click: PUBLIC_URL })
|
||||
if (errors.length) throw new HttpError(502, errors.join(' · '))
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { route, onProgress, mangaExtras, unfollow, checkFollows, stats }
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
// Librería: listas propias del usuario, recomendaciones según sus géneros y descarga anticipada
|
||||
// de los capítulos siguientes a lo que se está leyendo.
|
||||
const PREFETCH_DEFAULT = { n: 2, hd: false }
|
||||
const DEFAULT_LISTS = ['Favoritos', 'Pendientes', 'Abandonados']
|
||||
const REC_TTL = 6 * 3600e3
|
||||
import { genreName } from './features.js'
|
||||
const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
|
||||
export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats, searchManga, mdCategories, net }) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS list_items (
|
||||
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (list_id, manga_id)
|
||||
);
|
||||
`)
|
||||
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'prefetch')) db.exec('ALTER TABLE users ADD COLUMN prefetch TEXT')
|
||||
|
||||
// ---------- Listas ----------
|
||||
function listsOf(userId) {
|
||||
let rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
|
||||
if (!rows.length && !db.prepare('SELECT 1 FROM kv WHERE k = ?').get(`lists_init_${userId}`)) {
|
||||
const ins = db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)')
|
||||
for (const n of DEFAULT_LISTS) ins.run(userId, n, Date.now())
|
||||
db.prepare('INSERT INTO kv (k, v) VALUES (?, ?)').run(`lists_init_${userId}`, '1')
|
||||
rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
|
||||
}
|
||||
const count = db.prepare('SELECT COUNT(*) n FROM list_items WHERE list_id = ?')
|
||||
return rows.map(l => ({ ...l, count: count.get(l.id).n }))
|
||||
}
|
||||
const ownList = (userId, id) => {
|
||||
const l = db.prepare('SELECT * FROM lists WHERE id = ? AND user_id = ?').get(id, userId)
|
||||
if (!l) throw new HttpError(404, 'Lista no encontrada')
|
||||
return l
|
||||
}
|
||||
// Listas que contienen un manga (para la ficha).
|
||||
const listsWith = (userId, mangaId) => db.prepare(`SELECT l.id FROM lists l JOIN list_items i ON i.list_id = l.id
|
||||
WHERE l.user_id = ? AND i.manga_id = ?`).all(userId, mangaId).map(r => r.id)
|
||||
|
||||
// ---------- Descarga anticipada ----------
|
||||
function prefetchCfg(userId) {
|
||||
try { return { ...PREFETCH_DEFAULT, ...JSON.parse(db.prepare('SELECT prefetch FROM users WHERE id = ?').get(userId)?.prefetch || '{}') } } catch { return { ...PREFETCH_DEFAULT } }
|
||||
}
|
||||
const recent = new Map() // `${manga}|${chapter}` -> ts, para no repetir en cada guardado de progreso
|
||||
// Al pasar la mitad de un capítulo, encola los N siguientes (y, si se pidió, su versión HD).
|
||||
function onProgress(userId, b, page, total) {
|
||||
const cfg = prefetchCfg(userId)
|
||||
if (!cfg.n || !total || page < Math.floor(total / 2) || !net.online()) return
|
||||
const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}`
|
||||
if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return
|
||||
recent.set(key, Date.now())
|
||||
if (recent.size > 2000) recent.delete(recent.keys().next().value)
|
||||
const lang = /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang
|
||||
fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => {
|
||||
const i = chapters.findIndex(c => c.id === b.chapterId)
|
||||
if (i < 0) return
|
||||
const next = chapters.slice(i + 1, i + 1 + cfg.n).map(c => c.id)
|
||||
if (!next.length) return
|
||||
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(b.mangaId).map(r => r.chapter_id))
|
||||
const missing = next.filter(id => !have.has(id))
|
||||
if (missing.length) {
|
||||
enqueueDownload(b.mangaId, manga.title, lang, missing)
|
||||
linkDownload(userId, b.mangaId)
|
||||
console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`)
|
||||
}
|
||||
if (cfg.hd && upscale.available()) upscale.request(next)
|
||||
}).catch(e => console.warn('Descarga anticipada:', e.message))
|
||||
}
|
||||
|
||||
// ---------- Recomendaciones ----------
|
||||
const recCache = new Map() // userId -> { at, data }
|
||||
// Populares de MangaDex (por seguidores) en tus 3 géneros principales, con capítulos en tu idioma.
|
||||
// LeerCapitulo no ordena sus géneros por popularidad, por eso la fuente es MangaDex.
|
||||
async function recommendations(userId, refresh) {
|
||||
const hit = recCache.get(userId)
|
||||
if (hit && !refresh && Date.now() - hit.at < REC_TTL) return hit.data
|
||||
if (!net.online()) return hit?.data ?? { genres: [], items: [] }
|
||||
const lang = db.prepare('SELECT lang FROM users WHERE id = ?').get(userId)?.lang || 'es-la'
|
||||
const genres = stats(userId).genres.slice(0, 5)
|
||||
const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas')
|
||||
const byName = new Map(cats.map(c => [norm(genreName(c.name) || c.name), c]))
|
||||
const picked = genres.map(g => ({ g, cat: byName.get(norm(g.name)) })).filter(x => x.cat).slice(0, 3)
|
||||
const mine = db.prepare(`SELECT manga_id, manga_title title FROM progress WHERE user_id = ? UNION SELECT manga_id, title FROM follows WHERE user_id = ?
|
||||
UNION SELECT i.manga_id, i.title FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?`).all(userId, userId, userId)
|
||||
const knownIds = new Set(mine.map(r => r.manga_id))
|
||||
const knownTitles = new Set(mine.map(r => norm(r.title).replace(/[^a-z0-9]/g, '')))
|
||||
// Puntuación: aparecer en varios de tus géneros y salir arriba en cada lista.
|
||||
const score = new Map()
|
||||
const page = refresh ? 1 + Math.floor(Math.random() * 3) : 1 // "otras recomendaciones": otra página
|
||||
const results = await Promise.all(picked.map(({ cat }) => searchManga('', lang, 'popular', cat.id, page).catch(() => [])))
|
||||
results.forEach((list, gi) => list.forEach((m, rank) => {
|
||||
if (!m.id || knownIds.has(m.id) || knownTitles.has(norm(m.title).replace(/[^a-z0-9]/g, ''))) return
|
||||
const s = score.get(m.id) || { m, pts: 0, why: [] }
|
||||
s.pts += (picked.length - gi) * 2 + Math.max(0, 30 - rank) / 10 + 3
|
||||
s.why.push(picked[gi].g.name)
|
||||
score.set(m.id, s)
|
||||
}))
|
||||
const items = [...score.values()].sort((a, b) => b.pts - a.pts).slice(0, 18)
|
||||
.map(({ m, why }) => ({ ...m, sub: `Por ${why.slice(0, 2).join(' y ')}` }))
|
||||
const data = { genres: picked.map(x => ({ name: x.g.name, id: x.cat.id, source: 'md' })), items }
|
||||
if (items.length) recCache.set(userId, { at: Date.now(), data }) // vacío (fuente caída): no guardarlo 6 h
|
||||
return data
|
||||
}
|
||||
|
||||
// ---------- Rutas ----------
|
||||
async function route(p, method, q, req, res, user) {
|
||||
let m
|
||||
if (p === '/api/lists' && method === 'GET') return json(res, 200, listsOf(user.id)), true
|
||||
if (p === '/api/lists' && method === 'POST') {
|
||||
const b = await readBody(req)
|
||||
const name = String(b.name || '').trim().slice(0, 60)
|
||||
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
|
||||
if (db.prepare('SELECT COUNT(*) n FROM lists WHERE user_id = ?').get(user.id).n >= 50) throw new HttpError(400, 'Demasiadas listas')
|
||||
const id = Number(db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)').run(user.id, name, Date.now()).lastInsertRowid)
|
||||
return json(res, 200, { id, name, count: 0 }), true
|
||||
}
|
||||
if ((m = /^\/api\/lists\/(\d+)$/.exec(p))) {
|
||||
const l = ownList(user.id, Number(m[1]))
|
||||
if (method === 'GET') {
|
||||
const items = db.prepare('SELECT manga_id, title, cover, added_at FROM list_items WHERE list_id = ? ORDER BY added_at DESC').all(l.id)
|
||||
return json(res, 200, { id: l.id, name: l.name, items }), true
|
||||
}
|
||||
if (method === 'PATCH') {
|
||||
const name = String((await readBody(req)).name || '').trim().slice(0, 60)
|
||||
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
|
||||
db.prepare('UPDATE lists SET name = ? WHERE id = ?').run(name, l.id)
|
||||
return json(res, 200, { ok: true }), true
|
||||
}
|
||||
if (method === 'DELETE') { db.prepare('DELETE FROM lists WHERE id = ?').run(l.id); return json(res, 200, { ok: true }), true }
|
||||
}
|
||||
if ((m = /^\/api\/lists\/(\d+)\/items$/.exec(p)) && method === 'POST') {
|
||||
const l = ownList(user.id, Number(m[1]))
|
||||
const b = await readBody(req)
|
||||
if (!MANGA_ID.test(b.mangaId || '')) throw new HttpError(400, 'Manga inválido')
|
||||
const cover = String(b.cover || '').slice(0, 1000)
|
||||
db.prepare(`INSERT INTO list_items (list_id, manga_id, title, cover, added_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (list_id, manga_id) DO UPDATE SET title = excluded.title, cover = excluded.cover`)
|
||||
.run(l.id, b.mangaId, String(b.title || '').slice(0, 300) || b.mangaId, cover.startsWith('/api/') ? cover : null, Date.now())
|
||||
return json(res, 200, { lists: listsWith(user.id, b.mangaId) }), true
|
||||
}
|
||||
if ((m = /^\/api\/lists\/(\d+)\/items\/([^/]+)$/.exec(p)) && method === 'DELETE') {
|
||||
const l = ownList(user.id, Number(m[1]))
|
||||
const mangaId = decodeURIComponent(m[2])
|
||||
db.prepare('DELETE FROM list_items WHERE list_id = ? AND manga_id = ?').run(l.id, mangaId)
|
||||
return json(res, 200, { lists: listsWith(user.id, mangaId) }), true
|
||||
}
|
||||
if (p === '/api/prefetch' && method === 'GET') return json(res, 200, prefetchCfg(user.id)), true
|
||||
if (p === '/api/prefetch' && method === 'POST') {
|
||||
const b = await readBody(req)
|
||||
const n = Math.min(10, Math.max(0, Number(b.n) | 0))
|
||||
db.prepare('UPDATE users SET prefetch = ? WHERE id = ?').run(JSON.stringify({ n, hd: !!b.hd }), user.id)
|
||||
return json(res, 200, prefetchCfg(user.id)), true
|
||||
}
|
||||
if (p === '/api/recommendations' && method === 'GET') return json(res, 200, await recommendations(user.id, q.get('refresh') === '1')), true
|
||||
return false
|
||||
}
|
||||
|
||||
return { route, onProgress, listsOf, listsWith }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Extrae la clave con la que M440 cifra su lista de capítulos. La clave está repartida en trozos dentro de
|
||||
// un script ofuscado (js/ads2.js) que llama a CryptoJS.AES.decrypt(datos, clave, …). En vez de
|
||||
// desofuscarlo, se ejecuta en un contexto vm vacío con un CryptoJS falso que solo anota la clave.
|
||||
//
|
||||
// Se lanza como proceso aparte con el modelo de permisos de Node (sin acceso a archivos salvo este, sin
|
||||
// procesos hijo ni workers) y con tiempo límite. Dentro del vm no se pasa ningún objeto del proceso
|
||||
// (todo se define con código dentro del contexto) y la generación de código desde strings está
|
||||
// desactivada, así que el script no puede alcanzar process ni require.
|
||||
// Entrada: el script por stdin. Salida: {"key": "..."} por stdout.
|
||||
import vm from 'node:vm'
|
||||
|
||||
let src = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
for await (const chunk of process.stdin) {
|
||||
src += chunk
|
||||
if (src.length > 2e6) process.exit(2)
|
||||
}
|
||||
|
||||
const ctx = vm.createContext({}, { codeGeneration: { strings: false, wasm: false } })
|
||||
vm.runInContext(`
|
||||
var __key = null;
|
||||
var window = globalThis, self = globalThis;
|
||||
var UsaPoncho = '{"ct":"AAAA","iv":"00","s":"00"}';
|
||||
function mk() {
|
||||
return new Proxy(function () {}, {
|
||||
get: function (t, k) {
|
||||
if (k === Symbol.toPrimitive) return function () { return '' };
|
||||
if (k === 'toString') return function () { return '[]' };
|
||||
return mk();
|
||||
},
|
||||
apply: function () { return mk() },
|
||||
construct: function () { return mk() },
|
||||
});
|
||||
}
|
||||
var document = mk(), $ = mk(), jQuery = $, localStorage = mk(), sessionStorage = mk();
|
||||
var navigator = { userAgent: 'Mozilla/5.0', language: 'es' }, location = { href: 'https://m440.in/', hostname: 'm440.in' };
|
||||
var setTimeout = function () {}, setInterval = function () {}, clearTimeout = function () {}, clearInterval = function () {};
|
||||
var console = { log: function () {}, warn: function () {}, error: function () {}, info: function () {}, debug: function () {}, trace: function () {}, table: function () {}, exception: function () {} };
|
||||
var id = function (s) { return s };
|
||||
var CryptoJS = {
|
||||
AES: {
|
||||
decrypt: function (c, k) { if (__key === null) __key = String(k); return { toString: function () { return '"[]"' } } },
|
||||
encrypt: mk(),
|
||||
},
|
||||
enc: { Utf8: { parse: id, stringify: id }, Hex: { parse: id, stringify: id }, Base64: { parse: id, stringify: id } },
|
||||
lib: { CipherParams: { create: id }, WordArray: { create: id } },
|
||||
format: { OpenSSL: {} },
|
||||
};
|
||||
`, ctx)
|
||||
try {
|
||||
vm.runInContext(src, ctx, { timeout: 5000 })
|
||||
} catch {}
|
||||
const key = vm.runInContext('typeof __key === "string" ? __key : ""', ctx)
|
||||
process.stdout.write(JSON.stringify({ key: typeof key === 'string' ? key.slice(0, 200) : '' }))
|
||||
process.exit(0)
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// Fuente M440.in (MangasIn, tema MMRCMS), en español.
|
||||
// IDs: manga "mi~<slug>", capítulo "mi~<slug>~<slug del capítulo>".
|
||||
// - Búsqueda (/search?q=) y últimas actualizaciones (/lasted?p=) son JSON; los populares salen de la portada.
|
||||
// - La lista de capítulos viene cifrada en la ficha (AES de CryptoJS con contraseña). La contraseña se
|
||||
// saca de un script ofuscado con m440-key.mjs, en un proceso aislado, y se guarda hasta que deje de servir.
|
||||
// - Las páginas del capítulo vienen en el HTML como <img data-src> (a veces en base64).
|
||||
// - El listado por categorías está detrás del desafío de Cloudflare: esta fuente no tiene categorías.
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const MI_BASE = 'https://m440.in'
|
||||
const KEY_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'm440-key.mjs')
|
||||
|
||||
// httpError(status, message): crea el error HTTP del servidor (se pasa como función porque la clase se
|
||||
// declara más abajo en server.js).
|
||||
export function setupM440({ cached, httpError, coverUrl, decode, BROWSER_UA, net }) {
|
||||
|
||||
// Como mucho una petición de HTML/JSON cada 700 ms (el sitio limita a ~1/s).
|
||||
let gate = Promise.resolve()
|
||||
const throttle = () => {
|
||||
const p = gate.then(() => new Promise(r => setTimeout(r, 700)))
|
||||
gate = p
|
||||
return gate
|
||||
}
|
||||
async function raw(pathname, asJson) {
|
||||
for (let i = 0; ; i++) {
|
||||
net.guard()
|
||||
await throttle()
|
||||
const res = await fetch(MI_BASE + pathname, {
|
||||
headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es', Referer: MI_BASE + '/', ...(asJson ? { 'X-Requested-With': 'XMLHttpRequest' } : {}) },
|
||||
signal: AbortSignal.timeout(30e3),
|
||||
}).catch(() => { net.failed(); return null })
|
||||
if (res?.ok) return asJson ? res.json() : res.text()
|
||||
if (res?.status === 404) throw httpError(404, 'M440 respondió 404')
|
||||
const down = !res || res.status >= 500
|
||||
if (!down || i >= 2) throw httpError(502, down ? `M440 no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `M440 respondió ${res.status}`)
|
||||
await new Promise(r => setTimeout(r, 1500 * (i + 1)))
|
||||
}
|
||||
}
|
||||
const get = (pathname, ttl = 10 * 60e3, asJson = false) => cached(`mi:${pathname}`, ttl, () => raw(pathname, asJson))
|
||||
|
||||
const coverOf = slug => `${MI_BASE}/uploads/manga/${slug}/cover/cover_250x350.jpg`
|
||||
const cover = (src, id) => coverUrl(new URL(src, MI_BASE).toString(), id)
|
||||
const slugOf = href => /\/manga\/([a-z0-9._-]+)\/?$/i.exec(href || '')?.[1]?.toLowerCase()
|
||||
|
||||
// ---------- Listados ----------
|
||||
async function search(q, mode, page = 1) {
|
||||
if (q) {
|
||||
if (Buffer.byteLength(q.trim()) < 3) throw httpError(400, 'En M440 la búsqueda necesita al menos 3 letras')
|
||||
const list = await get(`/search?q=${encodeURIComponent(q.trim())}`, 10 * 60e3, true)
|
||||
return (Array.isArray(list) ? list : []).filter(e => /^[a-z0-9._-]+$/i.test(e.data || '')).map(e => {
|
||||
const id = `mi~${e.data.toLowerCase()}`
|
||||
const img = /<img[^>]+src="([^"]+)"/.exec(e.label || '')?.[1]
|
||||
const status = /<span class="label[^"]*" title="([^"]+)"/.exec(e.label || '')?.[1]
|
||||
return { id, source: 'mi', title: decode(e.value), cover: cover(img ? img.replace('cover_thumb', 'cover_250x350') : coverOf(e.data), id), sub: status ? decode(status) : undefined }
|
||||
})
|
||||
}
|
||||
if (mode === 'latest') {
|
||||
const d = await get(`/lasted?p=${Math.max(1, page | 0)}`, 10 * 60e3, true)
|
||||
return (d?.data || []).filter(e => /^[a-z0-9._-]+$/i.test(e.manga_slug || '')).map(e => {
|
||||
const id = `mi~${e.manga_slug.toLowerCase()}`
|
||||
const ch = e.chapters?.[0]?.chapter_number
|
||||
return { id, source: 'mi', title: decode(e.manga_name), cover: cover(coverOf(e.manga_slug), id), sub: ch ? `Cap. ${ch}` : undefined }
|
||||
})
|
||||
}
|
||||
// Populares: "Actualizaciones populares" de la portada (no pagina).
|
||||
const html = await get('/')
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
const re = /class="manga-name">\s*<a class="label[^"]*" href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<img src='([^']+)'[\s\S]*?<i class="fa fa-book"><\/i>\s*([^<]*)/g
|
||||
for (const m of html.matchAll(re)) {
|
||||
const slug = slugOf(m[1])
|
||||
if (!slug || seen.has(slug)) continue
|
||||
seen.add(slug)
|
||||
const id = `mi~${slug}`
|
||||
out.push({ id, source: 'mi', title: decode(m[2]), cover: cover(m[3], id), sub: /^#\s*([0-9.]+)/.exec(decode(m[4]))?.[1] ? `Cap. ${/^#\s*([0-9.]+)/.exec(decode(m[4]))[1]}` : undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- Lista de capítulos cifrada ----------
|
||||
let key = null
|
||||
let keyPromise = null
|
||||
function fetchKey() {
|
||||
keyPromise ??= (async () => {
|
||||
const script = await raw('/js/ads2.js', false)
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--permission', `--allow-fs-read=${KEY_SCRIPT}`, KEY_SCRIPT], { stdio: ['pipe', 'pipe', 'ignore'], timeout: 15e3 })
|
||||
let buf = ''
|
||||
child.stdout.on('data', d => { if (buf.length < 10e3) buf += d })
|
||||
child.on('error', reject)
|
||||
child.on('close', () => resolve(buf))
|
||||
child.stdin.end(script)
|
||||
})
|
||||
const k = JSON.parse(out || '{}').key
|
||||
if (!k) throw httpError(502, 'M440 cambió su forma de cifrar los capítulos (no se encontró la clave)')
|
||||
console.log('M440: clave de capítulos actualizada')
|
||||
return k
|
||||
})().finally(() => { keyPromise = null })
|
||||
return keyPromise
|
||||
}
|
||||
// AES-256-CBC con clave/IV derivados como OpenSSL EVP_BytesToKey (MD5), igual que CryptoJS con contraseña.
|
||||
function decrypt({ ct, s }, pass) {
|
||||
const salt = Buffer.from(s, 'hex')
|
||||
let d = Buffer.alloc(0)
|
||||
let prev = Buffer.alloc(0)
|
||||
while (d.length < 48) {
|
||||
prev = crypto.createHash('md5').update(Buffer.concat([prev, Buffer.from(pass, 'utf8'), salt])).digest()
|
||||
d = Buffer.concat([d, prev])
|
||||
}
|
||||
const dc = crypto.createDecipheriv('aes-256-cbc', d.subarray(0, 32), d.subarray(32, 48))
|
||||
let v = JSON.parse(Buffer.concat([dc.update(Buffer.from(ct, 'base64')), dc.final()]).toString('utf8'))
|
||||
if (typeof v === 'string') v = JSON.parse(v)
|
||||
if (!Array.isArray(v)) throw new Error('formato inesperado')
|
||||
return v
|
||||
}
|
||||
async function chapterList(html) {
|
||||
const lit = /=\s*("\{\\"ct\\"(?:[^"\\]|\\.)*")\s*;/.exec(html)?.[1]
|
||||
if (!lit) return []
|
||||
const data = JSON.parse(JSON.parse(lit))
|
||||
if (key) {
|
||||
try { return decrypt(data, key) } catch {}
|
||||
}
|
||||
key = await fetchKey()
|
||||
try { return decrypt(data, key) } catch { throw httpError(502, 'No se pudo descifrar la lista de capítulos de M440') }
|
||||
}
|
||||
|
||||
// ---------- Ficha ----------
|
||||
async function manga(id) {
|
||||
const slug = id.slice(3)
|
||||
const html = await get(`/manga/${slug}`)
|
||||
const dd = label => {
|
||||
const m = new RegExp(`<dt>${label}</dt>\\s*<dd[^>]*>([\\s\\S]*?)</dd>`).exec(html)
|
||||
return m ? m[1] : ''
|
||||
}
|
||||
const links = s => [...s.matchAll(/<a [^>]*>([^<]+)<\/a>/g)].map(m => decode(m[1])).filter(Boolean)
|
||||
const well = /<div class="well">\s*<h5>[\s\S]*?Sinopsis[\s\S]*?<\/h5>([\s\S]*?)<\/div>/.exec(html)?.[1] || ''
|
||||
// Los saltos del código fuente no cuentan: solo <br> (línea) y </p> (párrafo).
|
||||
const description = decode(well.replace(/\s+/g, ' ').replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n\n'))
|
||||
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
const list = await chapterList(html)
|
||||
const chapters = list.filter(c => /^[a-z0-9._-]+$/i.test(c.slug || '')).map(c => {
|
||||
const num = String(c.number ?? '').trim() || null
|
||||
const name = decode(c.name)
|
||||
return {
|
||||
id: `${id}~${c.slug.toLowerCase()}`, num, volume: c.volume && c.volume !== '0' ? String(c.volume) : null,
|
||||
title: name.replace(/^Cap[ií]tulo\s*[0-9.]*\s*[:.-]?\s*/i, '').trim(),
|
||||
pages: null, group: (c.created_at || '').slice(0, 10).split('-').reverse().join('/'), publishAt: c.created_at || null,
|
||||
}
|
||||
})
|
||||
chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0))
|
||||
const status = decode(/manga-list\/tag\/[^"]*"[^>]*title="([^"]+)"/.exec(html)?.[1] || '')
|
||||
const year = parseInt(decode(dd('Fecha de Publicaci[oó]n')), 10)
|
||||
return {
|
||||
manga: {
|
||||
id,
|
||||
source: 'mi',
|
||||
title: decode(/<h2 class="widget-title"[^>]*id="title"[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
|
||||
description,
|
||||
status: status || null,
|
||||
type: decode(dd('Tipo')) || null,
|
||||
year: year > 1900 && year < 2100 ? year : null,
|
||||
tags: [...links(dd('Demogr[aá]fico')), ...links(dd('G[eé]nero'))].slice(0, 12).map(name => ({ id: null, name })),
|
||||
authors: [...new Set([...links(dd('Autor')), ...links(dd('Artista'))])],
|
||||
languages: ['es'],
|
||||
cover: cover(/class="boxed"[\s\S]*?<img class="img-responsive" src='([^']+)'/.exec(html)?.[1] || coverOf(slug), id),
|
||||
},
|
||||
chapters,
|
||||
}
|
||||
}
|
||||
|
||||
async function pageUrls(chapterId) {
|
||||
const [, slug, ch] = chapterId.split('~')
|
||||
const html = await get(`/manga/${slug}/${ch}`, 30 * 60e3)
|
||||
const main = html.slice(Math.max(0, html.indexOf('id="all"')))
|
||||
const pages = [...main.matchAll(/<img[^>]*class="img-responsive"[^>]*data-src=['"]\s*([^'"]+?)\s*['"]/g)].map(m => {
|
||||
let u = decode(m[1])
|
||||
if (!/^https?:\/\//.test(u)) {
|
||||
try { u = decodeURIComponent(Buffer.from(u.split('://').pop(), 'base64').toString('utf8')) } catch { return null }
|
||||
}
|
||||
return /^https?:\/\//.test(u) ? u : null
|
||||
}).filter(Boolean)
|
||||
if (!pages.length) throw httpError(502, 'No se encontraron páginas en M440')
|
||||
return pages
|
||||
}
|
||||
|
||||
return { search, manga, pageUrls }
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
// Información extra de un manga (ficha "Info") y fechas de publicación de sus capítulos, de fuentes
|
||||
// externas: MangaDex (enlaces entre sitios y fechas), AniList (sinopsis, autores, puntuación, adaptaciones,
|
||||
// recomendaciones) y MangaUpdates (revista, editoriales, fechas de traducciones).
|
||||
//
|
||||
// 1. Identificar la obra: si viene de MangaDex, sus enlaces ya dicen cuál es en AniList/MangaUpdates; si
|
||||
// viene de otra fuente se busca por título en MangaDex, luego en AniList y luego en MangaUpdates (solo
|
||||
// se acepta un título que coincida, para no mostrar datos de otra obra).
|
||||
// 2. Fechas: los sitios en español suelen poner la fecha en que ellos subieron el capítulo (a veces la
|
||||
// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier
|
||||
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
|
||||
// publicación en series en curso.
|
||||
// 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
|
||||
const ANILIST = 'https://graphql.anilist.co'
|
||||
const MU_API = 'https://api.mangaupdates.com/v1'
|
||||
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
|
||||
|
||||
// Títulos comparables: sin acentos, mayúsculas, signos ni espacios.
|
||||
const norm = s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/&/g, 'and').replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const bigrams = s => { const b = new Map(); for (let i = 0; i < s.length - 1; i++) { const k = s.slice(i, i + 2); b.set(k, (b.get(k) || 0) + 1) } return b }
|
||||
function similar(a, b) {
|
||||
a = norm(a); b = norm(b)
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
if (Math.min(a.length, b.length) < 6) return false
|
||||
const A = bigrams(a), B = bigrams(b)
|
||||
let hit = 0
|
||||
for (const [k, n] of A) hit += Math.min(n, B.get(k) || 0)
|
||||
return (2 * hit) / (a.length - 1 + b.length - 1) >= 0.9
|
||||
}
|
||||
// Número de capítulo como clave ("01" -> "1", "10.50" -> "10.5").
|
||||
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
|
||||
// HTML de AniList/MangaUpdates a texto (solo saltos de línea).
|
||||
const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ''))
|
||||
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
|
||||
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
|
||||
|
||||
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
|
||||
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
|
||||
const inflight = new Map()
|
||||
function stored(key, ttl, fn, wait = false) {
|
||||
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
|
||||
const old = row ? JSON.parse(row.value) : undefined
|
||||
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
|
||||
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
|
||||
if (!inflight.has(key)) {
|
||||
inflight.set(key, (async () => {
|
||||
try {
|
||||
const v = await fn(old)
|
||||
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
|
||||
return v
|
||||
} catch (e) {
|
||||
if (old !== undefined) return old
|
||||
throw e
|
||||
}
|
||||
})().finally(() => inflight.delete(key)))
|
||||
}
|
||||
return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
|
||||
}
|
||||
|
||||
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
|
||||
let gate = Promise.resolve()
|
||||
const turn = ms => { const p = gate.then(() => new Promise(r => setTimeout(r, ms))); gate = p; return p }
|
||||
async function getJson(url, opts = {}, name = 'la fuente') {
|
||||
for (let i = 0; ; i++) {
|
||||
net.guard()
|
||||
await turn(400)
|
||||
const res = await fetch(url, { ...opts, headers: { 'User-Agent': UA, Accept: 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}), ...opts.headers }, signal: AbortSignal.timeout(20e3) })
|
||||
.catch(() => { net.failed(); return null })
|
||||
if (res?.ok) return res.json()
|
||||
if (res?.status === 404) return null
|
||||
const retry = !res || res.status === 429 || res.status >= 500
|
||||
if (!retry || i >= 2) throw new HttpError(502, `No se pudo consultar ${name}${res ? ` (${res.status})` : ''}`)
|
||||
await new Promise(r => setTimeout(r, (Number(res?.headers.get('retry-after')) || 2 * (i + 1)) * 1000))
|
||||
}
|
||||
}
|
||||
const anilist = (query, variables) => getJson(ANILIST, { method: 'POST', body: JSON.stringify({ query, variables }) }, 'AniList').then(d => d?.data)
|
||||
const mu = (path, body) => getJson(MU_API + path, body ? { method: 'POST', body: JSON.stringify(body) } : {}, 'MangaUpdates')
|
||||
|
||||
// ---------- Identificar la obra ----------
|
||||
// MangaUpdates: los enlaces nuevos son el id en base 36; los antiguos (numéricos) no sirven para la API.
|
||||
const muIdOf = s => (/^[0-9a-z]+$/.test(s || '') && /[a-z]/.test(s) ? parseInt(s, 36) : null)
|
||||
const mdLinks = m => {
|
||||
const l = m.attributes.links || {}
|
||||
return { md: m.id, al: Number(l.al) || null, mu: muIdOf(l.mu), mal: Number(l.mal) || null }
|
||||
}
|
||||
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
|
||||
|
||||
async function links(mangaId, wait = false) {
|
||||
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
|
||||
if (sourceOf(mangaId) === 'md') {
|
||||
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
|
||||
return { ...mdLinks(d.data), via: 'MangaDex' }
|
||||
}
|
||||
const { manga } = await fetchMangaFull(mangaId, 'es')
|
||||
const title = manga.title
|
||||
// MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga.
|
||||
// Una fuente caída no significa "no encontrado": no se guarda y se reintenta.
|
||||
let failed = false
|
||||
const soft = p => p.catch(() => { failed = true; return null })
|
||||
const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3))
|
||||
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
|
||||
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
|
||||
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
|
||||
const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }))
|
||||
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
|
||||
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
|
||||
const s = await soft(mu('/series/search', { search: title, perpage: 8 }))
|
||||
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
|
||||
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
|
||||
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
|
||||
return {}
|
||||
}, 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
|
||||
averageScore meanScore popularity favourites genres
|
||||
tags{name rank isMediaSpoiler isGeneralSpoiler}
|
||||
rankings{rank type allTime context year}
|
||||
coverImage{extraLarge large} bannerImage
|
||||
staff(perPage:12,sort:RELEVANCE){edges{role node{name{full native}}}}
|
||||
externalLinks{site url type language}
|
||||
relations{edges{relationType(version:2) node{id type format status title{romaji english} startDate{year} coverImage{large}}}}
|
||||
recommendations(perPage:8,sort:RATING_DESC){nodes{rating mediaRecommendation{id type format title{romaji english} coverImage{large} averageScore}}}`
|
||||
const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null)
|
||||
const cover = u => (u ? imgUrl(u) : null)
|
||||
|
||||
async function info(mangaId, wait = false) {
|
||||
const l = await links(mangaId, wait)
|
||||
return stored(`info:${mangaId}`, INFO_TTL, async old => {
|
||||
let failed = false
|
||||
const [al, ms] = await Promise.all([
|
||||
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
|
||||
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
|
||||
l.mu ? mu(`/series/${l.mu}`) : null,
|
||||
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
|
||||
const a = al?.Media
|
||||
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
|
||||
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
|
||||
if (!a && !ms) return { found: false }
|
||||
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
|
||||
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
|
||||
return {
|
||||
found: true,
|
||||
titles: {
|
||||
romaji: a?.title?.romaji || decode(ms?.title || '') || null,
|
||||
english: a?.title?.english || null,
|
||||
native: a?.title?.native || null,
|
||||
other: [...new Set([...(a?.synonyms || []), ...(ms?.associated || []).map(x => decode(x.title))])].slice(0, 12),
|
||||
},
|
||||
description: a?.description ? toText(a.description, decode) : ms?.description ? toText(ms.description, decode) : '',
|
||||
format: a?.format || ms?.type || null,
|
||||
country: a?.countryOfOrigin || null,
|
||||
status: a?.status || null,
|
||||
statusDetail: ms?.status ? decode(ms.status) : null,
|
||||
start: date(a?.startDate) || (ms?.year ? String(ms.year) : null),
|
||||
end: date(a?.endDate),
|
||||
chapters: a?.chapters || null,
|
||||
volumes: a?.volumes || null,
|
||||
latestChapter: ms?.latest_chapter || null,
|
||||
score: a?.averageScore ?? a?.meanScore ?? null,
|
||||
muScore: ms?.bayesian_rating ?? null,
|
||||
muVotes: ms?.rating_votes ?? null,
|
||||
popularity: a?.popularity ?? null,
|
||||
favourites: a?.favourites ?? null,
|
||||
rankings: (a?.rankings || []).filter(r => r.allTime || r.year).slice(0, 4).map(r => ({ rank: r.rank, type: r.type, allTime: r.allTime, year: r.year })),
|
||||
genres: a?.genres?.length ? a.genres : (ms?.genres || []).map(g => g.genre),
|
||||
tags: (a?.tags || []).filter(t => !t.isMediaSpoiler && !t.isGeneralSpoiler && t.rank >= 60).slice(0, 14).map(t => t.name),
|
||||
staff: staff.length ? staff : muAuthors,
|
||||
magazines: (ms?.publications || []).map(p => ({ name: decode(p.publication_name), publisher: decode(p.publisher_name || '') })),
|
||||
publishers: (ms?.publishers || []).map(p => ({ name: decode(p.publisher_name), type: p.type, notes: decode(p.notes || '') })),
|
||||
licensed: ms?.licensed ?? null,
|
||||
anime: ms?.anime?.start ? { start: decode(ms.anime.start), end: decode(ms.anime.end || '') } : null,
|
||||
adaptations: (a?.relations?.edges || []).filter(e => e.node.type === 'ANIME').map(e => ({ title: e.node.title.romaji, format: e.node.format, year: e.node.startDate?.year || null, status: e.node.status })),
|
||||
related: (a?.relations?.edges || []).filter(e => e.node.type === 'MANGA').slice(0, 8).map(e => ({ relation: e.relationType, title: e.node.title.english || e.node.title.romaji, format: e.node.format, year: e.node.startDate?.year || null, cover: cover(e.node.coverImage?.large) })),
|
||||
recommendations: (a?.recommendations?.nodes || []).map(n => n.mediaRecommendation).filter(r => r && r.type === 'MANGA').slice(0, 8).map(r => ({ title: r.title.english || r.title.romaji, romaji: r.title.romaji, format: r.format, score: r.averageScore, cover: cover(r.coverImage?.large) })),
|
||||
cover: cover(a?.coverImage?.extraLarge || a?.coverImage?.large),
|
||||
banner: cover(a?.bannerImage),
|
||||
links: [
|
||||
a?.siteUrl ? { site: 'AniList', url: a.siteUrl } : null,
|
||||
ms?.url ? { site: 'MangaUpdates', url: ms.url } : null,
|
||||
a?.idMal || l.mal ? { site: 'MyAnimeList', url: `https://myanimelist.net/manga/${a?.idMal || l.mal}` } : null,
|
||||
l.md ? { site: 'MangaDex', url: `https://mangadex.org/title/${l.md}` } : null,
|
||||
...(a?.externalLinks || []).filter(x => x.type !== 'SOCIAL' || /official/i.test(x.site)).map(x => ({ site: x.site + (x.language ? ` (${x.language})` : ''), url: x.url })),
|
||||
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
|
||||
matched: { via: l.via || null, title: l.title || null },
|
||||
}
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
|
||||
function cachedInfo(mangaId) {
|
||||
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
|
||||
if (!row) return null
|
||||
info(mangaId).catch(() => {})
|
||||
return JSON.parse(row.value)
|
||||
}
|
||||
|
||||
// ---------- Fechas de los capítulos ----------
|
||||
async function dates(mangaId, wait = false) {
|
||||
const l = await links(mangaId, wait)
|
||||
if (!l.md && !l.mu) return { dates: {}, via: [] }
|
||||
return stored(`dates:${mangaId}`, DAY, async old => {
|
||||
let failed = false
|
||||
const soft = p => p.catch(() => { failed = true; return null })
|
||||
const out = {}
|
||||
const via = []
|
||||
const add = (n, d) => {
|
||||
const k = numKey(n)
|
||||
if (!k || !d) return
|
||||
d = d.slice(0, 10)
|
||||
if (!out[k] || d < out[k]) out[k] = d
|
||||
}
|
||||
if (l.md) {
|
||||
let n = 0
|
||||
for (let offset = 0; offset < 10000; offset += 500) {
|
||||
const d = await soft(md(`/manga/${l.md}/feed`, {
|
||||
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
|
||||
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
|
||||
}, 3600e3))
|
||||
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
|
||||
if (!d || offset + 500 >= d.total) break
|
||||
}
|
||||
if (n) via.push('MangaDex')
|
||||
}
|
||||
if (l.mu) {
|
||||
let n = 0
|
||||
for (let page = 1; page <= 40; page++) {
|
||||
const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }))
|
||||
for (const x of r?.results || []) {
|
||||
const ch = String(x.record.chapter || '')
|
||||
const d = x.record.release_date
|
||||
// "415-416" o "12-12.5": todos los enteros del tramo y los extremos.
|
||||
const m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(ch)
|
||||
if (m) {
|
||||
const [lo, hi] = [parseFloat(m[1]), parseFloat(m[2])]
|
||||
add(lo, d); add(hi, d)
|
||||
if (hi - lo < 50) for (let k = Math.ceil(lo); k <= hi; k++) add(k, d)
|
||||
} else if (/^\d+(\.\d+)?$/.test(ch)) add(ch, d)
|
||||
n++
|
||||
}
|
||||
if (!r || page * 100 >= r.total_hits) break
|
||||
}
|
||||
if (n) via.push('MangaUpdates')
|
||||
}
|
||||
if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
|
||||
return { dates: out, via }
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// ---------- Mangas seguidos: siempre con su información ----------
|
||||
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
|
||||
// y se reescalan los banners guardados que aún no lo estén.
|
||||
let syncing = false
|
||||
async function syncFollowed() {
|
||||
if (syncing || !net.online()) return
|
||||
syncing = true
|
||||
try {
|
||||
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
|
||||
if (!net.online()) break
|
||||
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
|
||||
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
|
||||
}
|
||||
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
|
||||
const { banner } = JSON.parse(value)
|
||||
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
|
||||
}
|
||||
} finally { syncing = false }
|
||||
}
|
||||
setTimeout(syncFollowed, 60e3).unref()
|
||||
setInterval(syncFollowed, SYNC_EVERY).unref()
|
||||
net.whenOnline(syncFollowed)
|
||||
|
||||
async function route(p, method, res) {
|
||||
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
|
||||
if (!m || method !== 'GET') return false
|
||||
const id = decodeURIComponent(m[1])
|
||||
json(res, 200, await (m[2] ? dates(id) : info(id)))
|
||||
return true
|
||||
}
|
||||
|
||||
return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
||||
// Detector de rostros de anime, en un Worker para no trabar el estante.
|
||||
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
|
||||
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
|
||||
const integral = (gray, w, h) => {
|
||||
const I = new Int32Array((w + 1) * (h + 1))
|
||||
for (let y = 0; y < h; y++) {
|
||||
let row = 0
|
||||
for (let x = 0; x < w; x++) {
|
||||
row += gray[y * w + x]
|
||||
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
|
||||
}
|
||||
}
|
||||
return I
|
||||
}
|
||||
function equalize(gray) {
|
||||
const hist = new Int32Array(256), n = gray.length
|
||||
for (let i = 0; i < n; i++) hist[gray[i]]++
|
||||
let i = 0
|
||||
while (i < 255 && !hist[i]) i++
|
||||
const out = new Uint8Array(n), first = hist[i]
|
||||
if (first === n) return out.fill(i)
|
||||
const scale = 255 / (n - first), lut = new Uint8Array(256)
|
||||
let sum = 0
|
||||
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
|
||||
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
|
||||
return out
|
||||
}
|
||||
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
|
||||
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
|
||||
for (let y = 0; y < H; y++) {
|
||||
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
|
||||
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
|
||||
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
function scan(cascade, gray, W, H, step, emit) {
|
||||
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
|
||||
for (let wy = 0; wy + ch <= H; wy += step) {
|
||||
for (let wx = 0; wx + cw <= W; wx += step) {
|
||||
let pass = true
|
||||
for (let s = 0; s < stages.length && pass; s++) {
|
||||
const [limit, weak] = stages[s]
|
||||
let sum = 0
|
||||
for (let q = 0; q < weak.length; q++) {
|
||||
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
|
||||
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
|
||||
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
|
||||
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
|
||||
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
|
||||
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
|
||||
const c = p10 - p9 - p6 + p5
|
||||
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
|
||||
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
|
||||
}
|
||||
if (sum < limit) pass = false
|
||||
}
|
||||
if (pass) emit(wx, wy)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
|
||||
function group(rects, minNeighbors, eps = 0.2) {
|
||||
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
|
||||
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
|
||||
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
|
||||
const by = new Map()
|
||||
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
|
||||
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
|
||||
// Quita los que quedan dentro de otro mayor con más apoyos.
|
||||
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
|
||||
}
|
||||
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
|
||||
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
|
||||
const raw = new Uint8Array(w * h)
|
||||
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
|
||||
const gray = equalize(raw), rects = []
|
||||
for (let f = 1; ; f *= scale) {
|
||||
const size = Math.round(cascade.w * f)
|
||||
const W = Math.round(w / f), H = Math.round(h / f)
|
||||
if (W < cascade.w || H < cascade.h) break
|
||||
if (size < minSize) continue
|
||||
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
|
||||
}
|
||||
return group(rects, minNeighbors)
|
||||
}
|
||||
|
||||
let cascade = null
|
||||
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
|
||||
try {
|
||||
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
|
||||
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
|
||||
} catch (e) { self.postMessage({ id, faces: null }) }
|
||||
}
|
||||
+4585
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
|
||||
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
|
||||
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#bf78fa"/><path d="M14 16h16v32H14zM34 16h16v32H34z" fill="#14121a"/><path d="M18 22h8M18 28h8M18 34h6M38 22h8M38 28h8M38 34h6" stroke="#bf78fa" stroke-width="2.5" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<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>
|
||||
<link rel="icon" href="/icon.svg">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Mangas">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
|
||||
<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>
|
||||
<a href="#/settings" data-icon="settings">Ajustes</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="app"></main>
|
||||
<div id="toast" hidden></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Manga Reader",
|
||||
"short_name": "Mangas",
|
||||
"description": "Lector de manga autoalojado",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#14121a",
|
||||
"theme_color": "#14121a",
|
||||
"lang": "es",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,513 @@
|
||||
[hidden] { display: none !important; }
|
||||
:root {
|
||||
--bg: #14121a;
|
||||
--surface: #1e1b26;
|
||||
--surface-2: #2a2635;
|
||||
--text: #ece8f4;
|
||||
--muted: #9a93ab;
|
||||
--accent: #bf78fa;
|
||||
--accent-ink: #14121a;
|
||||
--danger: #ff6b81;
|
||||
--radius: 10px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
img { display: block; }
|
||||
|
||||
#topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 10px 16px; background: rgba(20, 18, 26, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--surface-2); }
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
#topbar nav { display: flex; gap: 4px; }
|
||||
#topbar nav a { padding: 6px 10px; border-radius: 8px; color: var(--muted); }
|
||||
#topbar nav a.active, #topbar nav a:hover { color: var(--text); background: var(--surface-2); }
|
||||
body.reading #topbar { display: none; }
|
||||
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
body.reading main { max-width: none; padding: 0; }
|
||||
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
||||
h2 { font-size: 1.15rem; margin: 24px 0 12px; }
|
||||
.muted { color: var(--muted); }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--surface-2);
|
||||
border-radius: var(--radius); background: var(--surface); cursor: pointer; }
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.small { padding: 5px 10px; font-size: .88rem; }
|
||||
.btn.danger { color: var(--danger); }
|
||||
input[type=text], input[type=password], input[type=search], select {
|
||||
padding: 9px 12px; border-radius: var(--radius); border: 1px solid var(--surface-2); background: var(--surface); outline: none; }
|
||||
input:focus, select:focus { border-color: var(--accent); }
|
||||
|
||||
.auth { max-width: 360px; margin: 10vh auto; background: var(--surface); padding: 24px; border-radius: 14px; }
|
||||
.auth form { display: grid; gap: 12px; }
|
||||
.error { color: var(--danger); min-height: 1.2em; margin: 0; }
|
||||
|
||||
.search { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.search input { flex: 1; min-width: 0; }
|
||||
.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; }
|
||||
.tabs button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--surface-2); background: transparent; cursor: pointer; color: var(--muted); }
|
||||
.tabs button.on { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }
|
||||
.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); }
|
||||
.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); }
|
||||
/* Margen (no padding) para que el recorte de 2 líneas no deje asomar la 3.ª */
|
||||
.card .t { padding: 0; margin: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; }
|
||||
.bar { height: 4px; background: var(--surface-2); }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||
|
||||
.continue { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 320px); gap: 12px; overflow-x: auto; padding-bottom: 6px; }
|
||||
.cont { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius); background: var(--surface); align-items: center; }
|
||||
.cont img { width: 56px; height: 84px; object-fit: cover; border-radius: 6px; background: var(--surface-2); flex: none; }
|
||||
.cont .info { min-width: 0; flex: 1; }
|
||||
.cont .info b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cont .bar { margin-top: 6px; border-radius: 2px; }
|
||||
|
||||
.detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
|
||||
.detail .cover { width: 100%; border-radius: var(--radius); aspect-ratio: 2/3; object-fit: cover; background: var(--surface-2); }
|
||||
.desc { white-space: pre-line; color: #cfc8dc; max-height: 12em; overflow: auto; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
.tag { font-size: .78rem; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); }
|
||||
.chapters { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
|
||||
.chapters a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); align-items: center; }
|
||||
.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; }
|
||||
.history .cont { justify-content: space-between; }
|
||||
|
||||
.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); }
|
||||
|
||||
/* ---- Lector ---- */
|
||||
.reader { min-height: 100vh; background: #000; position: relative; }
|
||||
/* Filtro de luz azul: tono ámbar multiplicado sobre las páginas (bajo las barras del lector). */
|
||||
.reader.bluelight::after { content: ''; position: fixed; inset: 0; z-index: 25; pointer-events: none;
|
||||
background: #ff9a3c; mix-blend-mode: multiply; opacity: .42; }
|
||||
.rbar { position: fixed; left: 0; right: 0; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: rgba(10, 9, 14, .9); backdrop-filter: blur(6px); transition: transform .2s; flex-wrap: wrap; }
|
||||
.rbar.top { top: 0; padding-top: max(8px, env(safe-area-inset-top)); }
|
||||
.rbar.bottom { bottom: 0; justify-content: center; padding-bottom: max(8px, env(safe-area-inset-bottom)); }
|
||||
.rbar.bottom.rtl, .rbar.bottom.rtl a.btn { flex-direction: row-reverse; }
|
||||
/* Móvil: arriba la página y la barra de progreso; abajo cada botón en su extremo. */
|
||||
@media (max-width: 640px) {
|
||||
.rbar.bottom { justify-content: space-between; }
|
||||
.rbar.bottom #pnum { order: -2; }
|
||||
.rbar.bottom input[type=range] { order: -1; flex: 1 1 calc(100% - 80px); max-width: none; }
|
||||
.rbar.bottom .chnext { margin-left: auto; }
|
||||
.rbar.bottom.rtl .chnext { margin-left: 0; margin-right: auto; }
|
||||
}
|
||||
.rbar.bottom.rtl a.btn .ic, .paged[data-dir="rtl"] .chapter-end a.primary .ic { transform: scaleX(-1); }
|
||||
.reader.hide-ui .rbar.top { transform: translateY(-110%); }
|
||||
.reader.hide-ui .rbar.bottom { transform: translateY(110%); }
|
||||
.rbar .ttl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .9rem; }
|
||||
.rbar select { padding: 5px 8px; }
|
||||
.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); }
|
||||
|
||||
#pages img { filter: var(--page-filter, none); transition: filter .2s, opacity .25s; }
|
||||
/* Página que aún no ha llegado: indicador de carga y fundido al aparecer */
|
||||
#pages .stage img.pending { opacity: 0; }
|
||||
.paged.waiting::after, .paged .stage.loading::after { content: ''; position: absolute; left: 50%; top: 50%; width: 34px; height: 34px;
|
||||
margin: -17px 0 0 -17px; border-radius: 50%; border: 3px solid rgba(255,255,255,.18); border-top-color: var(--accent);
|
||||
animation: pgspin .8s linear infinite; z-index: 6; pointer-events: none; }
|
||||
.paged .stage.loading { min-height: 100%; }
|
||||
@keyframes pgspin { to { transform: rotate(360deg); } }
|
||||
.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; }
|
||||
.paged img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; }
|
||||
.paged.fit-width img { max-height: none; width: 100%; max-width: 900px; }
|
||||
.vertical { display: flex; flex-direction: column; align-items: center; }
|
||||
.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; }
|
||||
.vertical img[data-loaded] { min-height: 0; }
|
||||
/* Vertical para mangas: cada página entera en pantalla, pegadas */
|
||||
.vertical.fit-height img { width: auto; max-width: 100%; max-height: 100vh; max-height: 100dvh; }
|
||||
.vertical.fit-height img:not([data-loaded]) { width: min(100%, 640px); }
|
||||
.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; }
|
||||
|
||||
#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2);
|
||||
padding: 10px 16px; border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.4); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail { grid-template-columns: 1fr; }
|
||||
.detail .cover { max-width: 180px; }
|
||||
.grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; }
|
||||
#topbar .brand span { display: none; }
|
||||
#topbar nav a { padding: 6px 8px; }
|
||||
}
|
||||
|
||||
/* Categorías y descargas */
|
||||
.cats h3 { font-size: .95rem; color: var(--muted); margin: 18px 0 8px; }
|
||||
a.tag { cursor: pointer; }
|
||||
a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
|
||||
.tag.big { font-size: .88rem; padding: 5px 12px; }
|
||||
.dlbox { display: grid; gap: 6px; margin-top: 10px; max-width: 520px; }
|
||||
.dlbar { height: 8px; border-radius: 4px; overflow: hidden; }
|
||||
.dlbar > i { transition: width .6s ease; }
|
||||
.history .cont a.info { display: block; min-width: 0; flex: 1; }
|
||||
|
||||
/* Iconos */
|
||||
.ic { width: 1.15em; height: 1.15em; flex: none; vertical-align: -0.2em; }
|
||||
#topbar nav a { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.tabs button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.chapters .meta .ic { width: 1em; height: 1em; }
|
||||
@media (max-width: 560px) {
|
||||
#topbar nav a span { display: none; } /* en móvil solo iconos en el menú */
|
||||
#topbar nav a { padding: 8px 10px; }
|
||||
#topbar nav a .ic { width: 1.35em; height: 1.35em; }
|
||||
.search .btn .lbl { display: none; }
|
||||
}
|
||||
|
||||
/* Lector: escenario de páginas, doble página y zoom */
|
||||
.paged { overflow: hidden; touch-action: none; }
|
||||
.paged.fit-width:not(.zoomed) { overflow: visible; touch-action: pan-y; }
|
||||
.paged .stage { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; transform-origin: 0 0; will-change: transform; }
|
||||
.paged.fit-width .stage { height: auto; align-items: flex-start; }
|
||||
.paged[data-dir="rtl"] .stage { flex-direction: row-reverse; }
|
||||
.paged .stage.two img { max-width: 50%; }
|
||||
.paged.fit-width .stage.two img { width: 50%; max-width: 700px; }
|
||||
.paged img { -webkit-user-drag: none; user-select: none; }
|
||||
.paged.zoomed { cursor: grab; }
|
||||
|
||||
/* Insignias y botón Seguir */
|
||||
.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; }
|
||||
.chapters .meta .queued { opacity: .55; }
|
||||
.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); }
|
||||
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
h2 .ic { color: var(--accent); }
|
||||
|
||||
/* Estadísticas */
|
||||
:root { --viz-1: #5b3f8c; --viz-2: #7d4fc0; --viz-3: #a067e3; --viz-4: #c993ff; --viz-5: #ecd6ff; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
|
||||
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; }
|
||||
.tile .tv { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
|
||||
.tile .tl { color: var(--muted); font-size: .88rem; }
|
||||
.tile .ts { color: var(--muted); font-size: .78rem; margin-top: 4px; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
||||
.panel { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; min-width: 0; }
|
||||
.panel h3 { margin: 0 0 10px; font-size: .95rem; }
|
||||
.chart { width: 100%; height: auto; display: block; }
|
||||
.chart .grid { stroke: var(--surface-2); stroke-width: 1; }
|
||||
.chart .axis { fill: var(--muted); font-size: 11px; }
|
||||
.chart .mark { fill: var(--accent); }
|
||||
.chart .hit { fill: transparent; }
|
||||
.chart .col:hover .hit { fill: rgba(255, 255, 255, .04); }
|
||||
.heat-wrap { overflow-x: auto; }
|
||||
.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-auto-columns: 14px; gap: 3px; }
|
||||
.cell { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: var(--surface-2); }
|
||||
.cell.empty { background: transparent; }
|
||||
.cell.l1 { background: var(--viz-1); } .cell.l2 { background: var(--viz-2); } .cell.l3 { background: var(--viz-3); }
|
||||
.cell.l4 { background: var(--viz-4); } .cell.l5 { background: var(--viz-5); }
|
||||
.heat-legend { display: flex; align-items: center; gap: 3px; font-size: .8rem; margin-top: 8px; }
|
||||
.hbars { display: grid; gap: 8px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(90px, 140px) 1fr 3em; gap: 8px; align-items: center; font-size: .88rem; }
|
||||
.hbar .hl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hbar .ht { height: 10px; background: var(--surface-2); border-radius: 0 4px 4px 0; }
|
||||
.hbar .ht i { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
|
||||
.hbar .hv { text-align: right; color: var(--muted); }
|
||||
.toplist { display: grid; gap: 8px; }
|
||||
.toplist .cont { padding: 6px 8px; background: var(--bg); }
|
||||
.toplist .cont img { width: 36px; height: 54px; }
|
||||
.toplist .rank { width: 1.4em; text-align: center; color: var(--muted); font-weight: 700; }
|
||||
details.panel summary { cursor: pointer; color: var(--muted); }
|
||||
table.data { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: .88rem; }
|
||||
table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--surface-2); }
|
||||
table.data td:not(:first-child), table.data th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
#tip { position: fixed; z-index: 60; pointer-events: none; background: #000; color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: .82rem; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 280px; }
|
||||
.rpanel { position: fixed; top: 52px; right: 8px; left: auto; z-index: 31; display: grid; gap: 8px; padding: 12px; width: min(320px, calc(100vw - 16px));
|
||||
background: rgba(10, 9, 14, .96); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,.5); }
|
||||
.rpanel select, .rpanel label { width: 100%; }
|
||||
.rpanel p { margin: 0; font-size: .78rem; }
|
||||
.reader.hide-ui .rpanel { display: none; }
|
||||
.chart .axis { font-size: 12px; }
|
||||
|
||||
/* Ficha: acciones, menú ⋯ y panel de biblioteca local */
|
||||
.detail-body { min-width: 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
.facts { display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: .85rem; margin-top: 10px; }
|
||||
.menu { position: relative; }
|
||||
.menu > summary { list-style: none; padding-left: 12px; padding-right: 12px; }
|
||||
.menu > summary::-webkit-details-marker { display: none; }
|
||||
.menu[open] > summary { background: var(--surface-2); }
|
||||
.menu-list { position: absolute; left: 0; top: calc(100% + 6px); z-index: 15; min-width: 230px; display: grid; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 10px 28px rgba(0,0,0,.45); }
|
||||
.menu-list a, .menu-list button { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: 8px;
|
||||
background: none; text-align: left; cursor: pointer; white-space: nowrap; }
|
||||
.menu-list a:hover, .menu-list button:hover { background: var(--surface-2); }
|
||||
.menu-list .danger { color: var(--danger); }
|
||||
|
||||
.libpanel { margin-top: 20px; max-width: 640px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; }
|
||||
.psec, .psec-grid { display: grid; gap: 10px; }
|
||||
.psec { padding: 14px 16px; }
|
||||
.psec + .psec { border-top: 1px solid var(--surface-2); }
|
||||
.psec-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; }
|
||||
.psec-title { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||
.psec-title .ic { color: var(--accent); }
|
||||
.psec-status { font-size: .85rem; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.psec-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.psec-actions .push { margin-left: auto; }
|
||||
.note { font-size: .83rem; margin: 0; }
|
||||
.psec .dlbar { height: 6px; }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: .85rem; color: var(--muted); cursor: pointer; }
|
||||
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; flex: none; }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
||||
|
||||
.hdform { display: grid; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--surface-2); }
|
||||
.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; justify-self: start; border-radius: 10px; background: var(--surface); border: 1px solid var(--surface-2); }
|
||||
.seg button { border: 0; background: none; padding: 5px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-size: .88rem; }
|
||||
.seg button.on { background: var(--surface-2); color: var(--text); }
|
||||
.range label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; }
|
||||
.range select { padding: 6px 10px; }
|
||||
.sum { font-size: .9rem; font-weight: 600; }
|
||||
|
||||
.list-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 28px 0 12px; }
|
||||
.list-head h2 { margin: 0; }
|
||||
.list-head select { padding: 5px 10px; font-size: .88rem; }
|
||||
.chapters label.pick { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); cursor: pointer; user-select: none; }
|
||||
.chapters label.pick:hover { background: var(--surface-2); }
|
||||
.chapters label.pick .t { flex: 1; min-width: 0; }
|
||||
.chapters label.pick.off { opacity: .4; cursor: default; }
|
||||
.chapters label.pick:has(input:checked) { outline: 1px solid var(--accent); }
|
||||
#selbar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 25; display: flex; align-items: center; gap: 10px;
|
||||
max-width: calc(100% - 32px); padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--surface-2); border: 1px solid #3a3548;
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.5); font-size: .9rem; }
|
||||
#selbar span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapters.picking { padding-bottom: 72px; }
|
||||
@media (max-width: 640px) {
|
||||
.actions .btn.primary { flex: 1 1 100%; }
|
||||
.libpanel { max-width: none; }
|
||||
.psec-actions .push { margin-left: 0; }
|
||||
.seg { display: grid; grid-template-columns: repeat(3, 1fr); justify-self: stretch; }
|
||||
#selbar { left: 12px; right: 12px; transform: none; max-width: none; }
|
||||
#selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; }
|
||||
}
|
||||
|
||||
/* Recortes: herramienta del lector */
|
||||
.cropov { position: fixed; inset: 0; z-index: 40; cursor: crosshair; touch-action: none; background: rgba(0, 0, 0, .25); user-select: none; }
|
||||
.crop-bar { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1;
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: rgba(20, 18, 26, .94);
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); }
|
||||
.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; }
|
||||
.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; }
|
||||
.crop-h { position: absolute; width: 44px; height: 44px; pointer-events: auto; touch-action: none; }
|
||||
.crop-h::after { content: ''; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px; box-sizing: border-box; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.6); }
|
||||
.crop-h[data-h="nw"] { left: -23px; top: -23px; cursor: nwse-resize; }
|
||||
.crop-h[data-h="ne"] { right: -23px; top: -23px; cursor: nesw-resize; }
|
||||
.crop-h[data-h="sw"] { left: -23px; bottom: -23px; cursor: nesw-resize; }
|
||||
.crop-h[data-h="se"] { right: -23px; bottom: -23px; cursor: nwse-resize; }
|
||||
.crop-bar em { font-style: normal; }
|
||||
@media (max-width: 560px) { .crop-bar span { display: none; } }
|
||||
|
||||
/* Recortes: galería */
|
||||
.clips { columns: 220px; column-gap: 12px; }
|
||||
.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; }
|
||||
.clip img { display: block; width: 100%; height: auto; max-height: 520px; object-fit: cover; object-position: top; background: var(--surface-2); }
|
||||
.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; }
|
||||
.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; }
|
||||
.clip figcaption span { color: var(--muted); }
|
||||
.clip figcaption i { color: var(--text); opacity: .85; }
|
||||
.clip:hover { outline: 1px solid var(--accent); }
|
||||
@media (max-width: 560px) { .clips { columns: 2; column-gap: 8px; } .clip { margin-bottom: 8px; } }
|
||||
.lightbox { position: fixed; inset: 0; z-index: 45; background: rgba(8, 7, 12, .94); display: grid; grid-template-columns: 1fr 320px; }
|
||||
.lightbox[hidden] { display: none; }
|
||||
.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; }
|
||||
.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
|
||||
/* Recorte largo de lectura vertical: a su ancho y con desplazamiento; entero en pantalla sería una tira ilegible. */
|
||||
.lb-img.long { align-items: flex-start; }
|
||||
.lb-img.long img { max-height: none; height: auto; flex: none; }
|
||||
.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
|
||||
.lb-side h2 a { color: var(--text); }
|
||||
body.noscroll { overflow: hidden; }
|
||||
@media (max-width: 760px) {
|
||||
.lightbox { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.lb-img { padding: 10px; }
|
||||
.lb-side { max-height: 48vh; padding: 12px 14px max(12px, env(safe-area-inset-bottom)); }
|
||||
}
|
||||
|
||||
/* Historial: tarjetas verticales */
|
||||
.hcard .bar { margin: 0; height: 5px; }
|
||||
.hcard .t { margin-bottom: 4px; }
|
||||
.hcard .sub { margin-top: 0; }
|
||||
.hcard .corner { position: absolute; top: 8px; left: 8px; }
|
||||
.hdel { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; border: 0;
|
||||
background: rgba(20, 18, 26, .75); color: var(--muted); cursor: pointer; opacity: 0; transition: opacity .15s; }
|
||||
.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; }
|
||||
.hdel:hover { color: var(--danger); }
|
||||
@media (hover: none) { .hdel { opacity: 1; } }
|
||||
|
||||
/* Colas: tarjetas verticales como en Historial */
|
||||
.qsec { margin-bottom: 26px; }
|
||||
.qsec h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.qcard { display: flex; flex-direction: column; }
|
||||
.qcard > a { color: inherit; }
|
||||
.qcard .sub { display: grid; gap: 1px; padding-bottom: 8px; }
|
||||
.qcard .sub .error { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.qact { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; margin-top: auto; }
|
||||
.qact .btn { flex: 1; padding: 6px 8px; font-size: .8rem; }
|
||||
.qpos { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(20, 18, 26, .85); color: var(--text); font-size: .8rem; font-weight: 700; }
|
||||
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
|
||||
.small { font-size: .82rem; }
|
||||
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
|
||||
/* Librería */
|
||||
.libtabs button .muted { font-size: .78rem; }
|
||||
.lhead { gap: 8px; flex-wrap: wrap; margin: -4px 0 12px; }
|
||||
.lhead .small { flex: 1; min-width: 200px; }
|
||||
.lcheck { padding: 8px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.lcheck:hover { background: var(--surface-2); }
|
||||
.chapters .meta .dev .ic { color: var(--accent); }
|
||||
/* Recomendaciones: fila desplazable */
|
||||
.rhead { gap: 10px; flex-wrap: wrap; align-items: baseline; }
|
||||
.rhead h2 { margin-bottom: 4px; }
|
||||
.rhead > span.small { flex: 1; min-width: 180px; }
|
||||
.rhead .small a { color: var(--accent); }
|
||||
.recrow { display: grid; grid-auto-flow: column; grid-auto-columns: 140px; gap: 12px; overflow-x: auto; padding-bottom: 8px; scroll-snap-type: x mandatory; }
|
||||
.recrow .card { scroll-snap-align: start; }
|
||||
@media (max-width: 640px) { .recrow { grid-auto-columns: 115px; gap: 10px; } }
|
||||
.banlist { display: grid; gap: 6px; margin-top: 8px; } .banlist .row { gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* Aviso: el servidor no tiene internet */
|
||||
#netbanner { position: sticky; top: 53px; z-index: 19; display: flex; align-items: center; gap: 10px; padding: 8px 16px;
|
||||
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
|
||||
#netbanner svg { flex: none; width: 18px; height: 18px; }
|
||||
body.reading #netbanner { display: none; }
|
||||
|
||||
/* Animación al pasar página (modos por página) */
|
||||
.paged { position: relative; perspective: 2200px; }
|
||||
.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; }
|
||||
.paged.fit-width .stage.leaving { bottom: auto; }
|
||||
.paged .curlgl { position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; filter: var(--page-filter, none); }
|
||||
.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
|
||||
.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
|
||||
.paged .curl .sf { position: absolute; top: 0; bottom: 0; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
|
||||
.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
|
||||
.paged .curl .back { transform: rotateY(180deg); }
|
||||
.paged .curl .back.paper { background: #e9e5dc; }
|
||||
.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 */
|
||||
.chapters .meta .cdate { white-space: nowrap; }
|
||||
.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
|
||||
.banner[hidden] { display: none; }
|
||||
.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
|
||||
.detail-body .info-block h2:first-child { margin-top: 18px; }
|
||||
.detail-body .info-block + .tags { margin-top: 14px; }
|
||||
.drawer .kv { margin: 0; }
|
||||
.info-block .desc.long { max-height: none; }
|
||||
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
|
||||
.info-block > .note { margin-top: 16px; }
|
||||
.kv:empty, #mscores:empty { display: none; }
|
||||
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
|
||||
.list-head { scroll-margin-top: 64px; }
|
||||
/* Menú lateral de la ficha (botón ⋯) */
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
|
||||
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
|
||||
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
|
||||
.drawer.open { transform: none; visibility: visible; }
|
||||
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
|
||||
.drawer-opts { display: grid; gap: 8px; }
|
||||
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
|
||||
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
|
||||
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
|
||||
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
|
||||
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
|
||||
.drawer-back[hidden] { display: none; }
|
||||
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
|
||||
@keyframes libpulse { 50% { opacity: .25; } }
|
||||
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
|
||||
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
|
||||
.score b { font-size: 1.25rem; }
|
||||
.score span { font-size: .75rem; color: var(--muted); }
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 14px 0; font-size: .92rem; }
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
|
||||
.tag.hot { color: var(--accent); }
|
||||
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
|
||||
.chapters .meta .cdate.approx { opacity: .7; }
|
||||
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
|
||||
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
|
||||
#shelf3d canvas { display: block; width: 100%; height: 100%; }
|
||||
.shelftools { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); display: flex; gap: 8px; }
|
||||
.shelfnote:empty { display: none; } /* vacía no se ve; [hidden] dice que ya llegaron los tomos */
|
||||
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
|
||||
.libhead { justify-content: space-between; align-items: center; }
|
||||
#b3d { position: fixed; inset: 0; z-index: 0; }
|
||||
#b3d[hidden] { display: none; }
|
||||
#b3d canvas { display: block; width: 100%; height: 100%; }
|
||||
.reader.b3d { background: #4a2e19; }
|
||||
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
|
||||
.reader.b3d > #pages img { visibility: hidden; }
|
||||
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
|
||||
.reader.b3d > #pages > * { pointer-events: none; }
|
||||
/* El final del capítulo, sobre la contraportada y con sus colores. */
|
||||
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
|
||||
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
|
||||
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
|
||||
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
|
||||
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
|
||||
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
|
||||
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
|
||||
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
|
||||
/* Para recortar se ve la página plana. */
|
||||
.reader.b3d.b3dcrop { background: #000; }
|
||||
.reader.b3d.b3dcrop > #b3d { display: none; }
|
||||
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
|
||||
/* Vistazo del estante: la nota del tomo sacado, como un pósit pegado junto a él (a la derecha en apaisado, abajo en vertical).
|
||||
Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */
|
||||
.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg;
|
||||
position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
|
||||
padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
|
||||
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np);
|
||||
box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); }
|
||||
.shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); }
|
||||
.shelfpeek .ptitle { margin: 0 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
|
||||
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; }
|
||||
.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; }
|
||||
.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; }
|
||||
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
|
||||
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; }
|
||||
.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); }
|
||||
.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; }
|
||||
@media (orientation: portrait) {
|
||||
.shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); }
|
||||
.shelfpeek.on { transform: rotate(var(--nt)); }
|
||||
}
|
||||
body.handing .reader .loading { visibility: hidden; }
|
||||
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
|
||||
.handoff.out { opacity: 0; }
|
||||
@media (max-width: 420px) { #topbar nav a { padding: 8px 7px; } }
|
||||
@@ -0,0 +1,80 @@
|
||||
// Service worker: la app abre sin conexión (shell en caché) y los capítulos guardados "en este
|
||||
// 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']
|
||||
|
||||
// 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).
|
||||
async function installShell() {
|
||||
const c = await caches.open(SHELL)
|
||||
const html = await fetch('/', { cache: 'reload' })
|
||||
const text = await html.clone().text()
|
||||
const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1])
|
||||
const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))]
|
||||
for (const f of files) await c.delete(f, { ignoreSearch: true })
|
||||
await c.addAll(files.map(u => new Request(u, { cache: 'reload' })))
|
||||
await c.put('/', html)
|
||||
}
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(installShell().then(() => self.skipWaiting()))
|
||||
})
|
||||
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(() => self.clients.claim()))
|
||||
})
|
||||
|
||||
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. Si hay copia guardada
|
||||
// y el servidor no contesta en NET_WAIT ms (red que no llega, servidor caído o colgado), se usa la copia
|
||||
// sin esperar a que el navegador dé la conexión por perdida; la petición sigue y actualiza la caché.
|
||||
const NET_WAIT = 3000
|
||||
const DOWN_FOR = 20e3
|
||||
let downUntil = 0
|
||||
// La app distingue así una copia guardada de una respuesta real del servidor.
|
||||
async function fromCache(res) {
|
||||
const headers = new Headers(res.headers)
|
||||
headers.set('x-from-cache', '1')
|
||||
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
|
||||
}
|
||||
// single: guardar solo la última versión (app.js?v=… sustituye a la anterior).
|
||||
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) {
|
||||
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
|
||||
const net = fetch(req).then(async res => {
|
||||
if (store && res.ok) {
|
||||
const c = await caches.open(cacheName)
|
||||
if (single) await c.delete(req, { ignoreSearch: true })
|
||||
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
|
||||
}
|
||||
return res
|
||||
})
|
||||
net.then(() => { downUntil = 0 }, () => {})
|
||||
const hit = await cached()
|
||||
if (!hit) return net // sin copia: solo queda esperar a la red
|
||||
// Si hace poco que el servidor no contestaba, la copia directamente (la red sigue intentándolo).
|
||||
if (Date.now() < downUntil) return fromCache(hit)
|
||||
let timer
|
||||
const timeout = new Promise(r => { timer = setTimeout(() => r(null), NET_WAIT) })
|
||||
const res = await Promise.race([net.catch(() => null), timeout])
|
||||
clearTimeout(timer)
|
||||
if (!res) downUntil = Date.now() + DOWN_FOR
|
||||
return res || fromCache(hit)
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
const req = e.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
if (url.origin !== location.origin) return
|
||||
const p = url.pathname
|
||||
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
|
||||
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true }))
|
||||
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
|
||||
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
|
||||
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/')) {
|
||||
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
|
||||
}
|
||||
})
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// Seguridad para exponer el servidor a internet: IP real del cliente (detrás de un proxy de
|
||||
// confianza), baneo de IP tras varios intentos fallidos, cabeceras de seguridad y protección SSRF
|
||||
// del proxy de imágenes.
|
||||
import net from 'node:net'
|
||||
import dns from 'node:dns/promises'
|
||||
|
||||
const MAX_FAILS = Number(process.env.BAN_MAX_FAILS || 6) // intentos fallidos…
|
||||
const FAIL_WINDOW = Number(process.env.BAN_WINDOW_MIN || 60) * 60e3 // …dentro de esta ventana
|
||||
const BAN_MS = Number(process.env.BAN_HOURS || 24) * 3600e3 // duración del baneo
|
||||
// Cabecera con la IP real que pone el proxy (p. ej. x-forwarded-for, cf-connecting-ip, x-real-ip).
|
||||
// Solo se cree si la conexión viene de una IP privada (el proxy), nunca directamente de internet.
|
||||
const REAL_IP_HEADER = (process.env.REAL_IP_HEADER || '').toLowerCase().trim()
|
||||
|
||||
const PRIVATE_V4 = [['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['100.64.0.0', 10], ['0.0.0.0', 8]]
|
||||
const v4num = ip => ip.split('.').reduce((n, x) => n * 256 + Number(x), 0)
|
||||
export function isPrivateIp(ip) {
|
||||
if (!ip) return true
|
||||
ip = ip.replace(/^::ffff:/i, '')
|
||||
if (net.isIPv4(ip)) {
|
||||
const n = v4num(ip)
|
||||
return PRIVATE_V4.some(([base, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4num(base) / 2 ** (32 - bits)))
|
||||
}
|
||||
const l = ip.toLowerCase()
|
||||
return l === '::1' || l === '::' || /^f[cd]/.test(l) || /^fe[89ab]/.test(l)
|
||||
}
|
||||
// IPs que nunca se banean: loopback, red local, Tailscale y lo que se añada en BAN_IGNORE.
|
||||
const IGNORE = (process.env.BAN_IGNORE || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
const neverBan = ip => isPrivateIp(ip) || IGNORE.includes(ip)
|
||||
|
||||
export function setupSecurity({ db }) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ip_bans (
|
||||
ip TEXT PRIMARY KEY,
|
||||
until INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ip_fails (
|
||||
ip TEXT NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
what TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_fails ON ip_fails(ip, at);
|
||||
`)
|
||||
const prune = () => {
|
||||
db.prepare('DELETE FROM ip_bans WHERE until < ?').run(Date.now())
|
||||
db.prepare('DELETE FROM ip_fails WHERE at < ?').run(Date.now() - Math.max(FAIL_WINDOW, 24 * 3600e3))
|
||||
}
|
||||
prune()
|
||||
setInterval(prune, 3600e3).unref()
|
||||
|
||||
function clientIp(req) {
|
||||
const direct = (req.socket.remoteAddress || '').replace(/^::ffff:/i, '')
|
||||
if (REAL_IP_HEADER && isPrivateIp(direct)) {
|
||||
// x-forwarded-for: "cliente, proxy1, proxy2". Cada proxy añade al final la IP que le habló, así
|
||||
// que se recorre desde la derecha saltando los proxies propios (IPs privadas: VPS por Tailscale,
|
||||
// Nginx Proxy Manager en Docker…). La primera pública es el cliente real; lo que haya más a la
|
||||
// izquierda lo pudo escribir el propio cliente y no se cree.
|
||||
const chain = String(req.headers[REAL_IP_HEADER] || '').split(',').map(s => s.trim().replace(/^::ffff:/i, '')).filter(s => net.isIP(s))
|
||||
const ip = chain.findLast(s => !isPrivateIp(s)) ?? chain[0]
|
||||
if (ip) return ip
|
||||
}
|
||||
return direct
|
||||
}
|
||||
// HTTPS real (directo o por el proxy de confianza): cookie Secure y HSTS.
|
||||
const isHttps = req => !!req.socket.encrypted || (isPrivateIp(req.socket.remoteAddress) && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https')
|
||||
|
||||
const banned = ip => db.prepare('SELECT until FROM ip_bans WHERE ip = ? AND until > ?').get(ip, Date.now())
|
||||
// Intento fallido (contraseña incorrecta, registro cerrado…). Al llegar a MAX_FAILS, baneo.
|
||||
function fail(ip, what) {
|
||||
if (neverBan(ip)) return
|
||||
const now = Date.now()
|
||||
db.prepare('INSERT INTO ip_fails (ip, at, what) VALUES (?, ?, ?)').run(ip, now, what)
|
||||
const n = db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, now - FAIL_WINDOW).n
|
||||
if (n >= MAX_FAILS) {
|
||||
db.prepare(`INSERT INTO ip_bans (ip, until, reason, created_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (ip) DO UPDATE SET until = excluded.until, reason = excluded.reason, created_at = excluded.created_at`)
|
||||
.run(ip, now + BAN_MS, `${n} intentos fallidos (${what})`, now)
|
||||
db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
|
||||
console.warn(`Seguridad: IP ${ip} baneada ${BAN_MS / 3600e3} h tras ${n} intentos fallidos (${what})`)
|
||||
}
|
||||
}
|
||||
const success = ip => db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
|
||||
const remaining = ip => (neverBan(ip) ? null : Math.max(0, MAX_FAILS - db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, Date.now() - FAIL_WINDOW).n))
|
||||
|
||||
// Cabeceras para todas las respuestas.
|
||||
function headers(req, res) {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('X-Frame-Options', 'DENY')
|
||||
res.setHeader('Referrer-Policy', 'same-origin')
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:",
|
||||
"connect-src 'self'", "worker-src 'self'", "manifest-src 'self'", "font-src 'self'",
|
||||
"object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'",
|
||||
].join('; '))
|
||||
if (isHttps(req)) res.setHeader('Strict-Transport-Security', 'max-age=15552000')
|
||||
}
|
||||
|
||||
function route(p, method, req, res, json) {
|
||||
if (p === '/api/bans' && method === 'GET') {
|
||||
return json(res, 200, {
|
||||
bans: db.prepare('SELECT ip, until, reason, created_at FROM ip_bans WHERE until > ? ORDER BY created_at DESC').all(Date.now()),
|
||||
fails: db.prepare('SELECT ip, COUNT(*) n, MAX(at) last FROM ip_fails WHERE at > ? GROUP BY ip ORDER BY last DESC LIMIT 50').all(Date.now() - FAIL_WINDOW),
|
||||
maxFails: MAX_FAILS, banHours: BAN_MS / 3600e3, windowMin: FAIL_WINDOW / 60e3, trustedHeader: REAL_IP_HEADER || null,
|
||||
you: { ip: clientIp(req), protected: neverBan(clientIp(req)) },
|
||||
}), true
|
||||
}
|
||||
const m = /^\/api\/bans\/([^/]+)$/.exec(p)
|
||||
if (m && method === 'DELETE') {
|
||||
const ip = decodeURIComponent(m[1])
|
||||
db.prepare('DELETE FROM ip_bans WHERE ip = ?').run(ip)
|
||||
db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
|
||||
return json(res, 200, { ok: true }), true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { clientIp, isHttps, banned, fail, success, remaining, headers, route }
|
||||
}
|
||||
|
||||
// ---------- SSRF: el proxy de imágenes no debe alcanzar la red interna ----------
|
||||
const dnsCache = new Map()
|
||||
async function hostIsPublic(hostname) {
|
||||
if (net.isIP(hostname)) return !isPrivateIp(hostname)
|
||||
if (/^(localhost|.*\.local|.*\.internal|.*\.lan)$/i.test(hostname)) return false
|
||||
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))
|
||||
dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 })
|
||||
if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value)
|
||||
return ok
|
||||
}
|
||||
// fetch que solo va a hosts públicos y comprueba también cada redirección.
|
||||
export async function safeFetch(url, opts = {}, redirects = 4) {
|
||||
for (let i = 0; ; i++) {
|
||||
const u = new URL(url)
|
||||
if (!/^https?:$/.test(u.protocol) || !(await hostIsPublic(u.hostname))) throw new Error('Host no permitido')
|
||||
const r = await fetch(u, { ...opts, redirect: 'manual' })
|
||||
const loc = r.status >= 300 && r.status < 400 && r.headers.get('location')
|
||||
if (!loc) return r
|
||||
if (i >= redirects) throw new Error('Demasiadas redirecciones')
|
||||
url = new URL(loc, u).toString()
|
||||
}
|
||||
}
|
||||
+1222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
// Estante 3D: las obras de un usuario que pueden ir en el mueble. Los tomos de cada una se piden aparte (/api/volumes), porque pueden tardar.
|
||||
import { hasNoVolumes } from './volumes.js'
|
||||
|
||||
export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) {
|
||||
function works(userId) {
|
||||
const out = new Map()
|
||||
const add = (id, fields) => {
|
||||
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null }
|
||||
w.title ||= fields.title || ''
|
||||
w.cover ||= fields.cover || null
|
||||
w.last = Math.max(w.last, fields.at || 0)
|
||||
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null })
|
||||
out.set(id, w)
|
||||
}
|
||||
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
|
||||
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at })
|
||||
}
|
||||
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
|
||||
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })
|
||||
}
|
||||
for (const r of db.prepare(`SELECT manga_id, title, folder, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS}`).all(userId)) {
|
||||
add(r.manga_id, { title: r.title, cover: downloadCover(r.manga_id, r.folder, r.meta_cover), at: r.updated_at })
|
||||
}
|
||||
for (const r of db.prepare('SELECT i.manga_id, i.title, i.cover, i.added_at FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?').all(userId)) {
|
||||
add(r.manga_id, { title: r.title, cover: r.cover, at: r.added_at })
|
||||
}
|
||||
const typeOf = db.prepare('SELECT type FROM manga_meta WHERE manga_id = ?')
|
||||
return [...out.values()].filter(w => !isBookId(w.id) && !hasNoVolumes(typeOf.get(w.id)?.type)).sort((a, b) => b.last - a.last)
|
||||
}
|
||||
function route(p, method, res, user) {
|
||||
if (p !== '/api/shelf' || method !== 'GET') return false
|
||||
json(res, 200, { works: works(user.id) })
|
||||
return true
|
||||
}
|
||||
return { route, works }
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
// Reescalado (HD) de capítulos descargados con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri).
|
||||
// El original no se toca: la versión HD va a <carpeta>/.hd/<mismo nombre>.cbz (oculta para Komga/Kavita).
|
||||
// Una cola persistente en SQLite procesa un capítulo cada vez.
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js'
|
||||
|
||||
export const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan'
|
||||
export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models')
|
||||
export const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3'
|
||||
const SCALE = Number(process.env.UPSCALER_SCALE || 2)
|
||||
const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85)
|
||||
// WebP no admite más de 16383 px por lado: las tiras de webtoon muy altas se pasan al reescalarlas.
|
||||
const WEBP_MAX_SIDE = 16383
|
||||
// Ancho y alto de un PNG (cabecera IHDR).
|
||||
function pngSize(file) {
|
||||
const fd = fs.openSync(file, 'r')
|
||||
try {
|
||||
const b = Buffer.alloc(24)
|
||||
fs.readSync(fd, b, 0, 24, 0)
|
||||
return b.readUInt32BE(0) === 0x89504e47 ? { w: b.readUInt32BE(16), h: b.readUInt32BE(20) } : null
|
||||
} finally { fs.closeSync(fd) }
|
||||
}
|
||||
const HD_DIR = '.hd'
|
||||
const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual
|
||||
|
||||
export function run(cmd, args, onSpawn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
onSpawn?.(p)
|
||||
let err = ''
|
||||
p.stderr.on('data', d => { err = (err + d).slice(-2000) })
|
||||
p.on('error', reject)
|
||||
p.on('close', (code, signal) => (code === 0 ? resolve() : reject(new Error(signal ? `interrumpido (${signal})` : `${path.basename(cmd)} salió con ${code}: ${err.trim().split('\n').filter(l => !/^\d+(\.\d+)?%$/.test(l)).slice(-2).join(' ')}`))))
|
||||
})
|
||||
}
|
||||
|
||||
export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) {
|
||||
const cols = t => db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name)
|
||||
if (!cols('downloaded_chapters').includes('hd_file')) db.exec('ALTER TABLE downloaded_chapters ADD COLUMN hd_file TEXT')
|
||||
if (!cols('downloads').includes('hd_auto')) db.exec('ALTER TABLE downloads ADD COLUMN hd_auto INTEGER NOT NULL DEFAULT 0')
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS upscale_jobs (
|
||||
chapter_id TEXT PRIMARY KEY REFERENCES downloaded_chapters(chapter_id) ON DELETE CASCADE,
|
||||
manga_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- queued | running | error
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_upscale_manga ON upscale_jobs(manga_id);
|
||||
`)
|
||||
db.prepare("UPDATE upscale_jobs SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad
|
||||
// Capítulos aún sin descargar que deben reescalarse en cuanto lleguen (descarga anticipada con HD).
|
||||
db.exec('CREATE TABLE IF NOT EXISTS upscale_wanted (chapter_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)')
|
||||
db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)')
|
||||
// Segundos por página medidos (media móvil), para estimar cuánto tardará una cola.
|
||||
let secPerPage = Number(db.prepare("SELECT v FROM kv WHERE k = 'hd_sec_per_page'").get()?.v) || 13
|
||||
|
||||
let hasCwebp = false
|
||||
const available = () => fs.existsSync(BIN) && fs.existsSync(path.join(MODELS, `${MODEL}-x${SCALE}.bin`)) && fs.existsSync('/dev/dri') && hasCwebp
|
||||
run('cwebp', ['-version']).then(() => { hasCwebp = true; if (available()) pump() }, () => {})
|
||||
|
||||
// Capítulo en curso (solo en memoria).
|
||||
let current = null // { chapterId, mangaId, num, pages, total, proc }
|
||||
|
||||
const chapterRow = chapterId => db.prepare(`SELECT c.chapter_id, c.manga_id, c.num, c.file, c.hd_file, d.folder FROM downloaded_chapters c
|
||||
JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId)
|
||||
|
||||
async function upscaleChapter(job) {
|
||||
const row = chapterRow(job.chapter_id)
|
||||
if (!row) return
|
||||
const dir = path.join(MANGA_DIR, row.folder)
|
||||
const src = path.join(dir, row.file)
|
||||
const entries = readCbzIndex(src)
|
||||
current = { chapterId: row.chapter_id, mangaId: row.manga_id, num: row.num, pages: 0, total: entries.length, proc: null }
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hd-'))
|
||||
try {
|
||||
const inDir = path.join(work, 'in'), outDir = path.join(work, 'out')
|
||||
fs.mkdirSync(inDir); fs.mkdirSync(outDir)
|
||||
const names = entries.map((e, i) => {
|
||||
const ext = e.name.split('.').pop().toLowerCase()
|
||||
const name = `${String(i + 1).padStart(4, '0')}.${ext}`
|
||||
fs.writeFileSync(path.join(inDir, name), readCbzEntry(src, e))
|
||||
return name
|
||||
})
|
||||
// Real-ESRGAN procesa la carpeta entera; el progreso se mide contando las salidas.
|
||||
const poll = setInterval(() => { try { current.pages = fs.readdirSync(outDir).length } catch {} }, 1500)
|
||||
try {
|
||||
await run(BIN, ['-i', inDir, '-o', outDir, '-m', MODELS, '-n', MODEL, '-s', String(SCALE), '-f', 'png'], p => { current.proc = p })
|
||||
} finally { clearInterval(poll) }
|
||||
if (!db.prepare('SELECT 1 FROM upscale_jobs WHERE chapter_id = ?').get(row.chapter_id)) return // cancelado
|
||||
const out = []
|
||||
for (const name of names) {
|
||||
const base = name.replace(/\.[^.]+$/, '')
|
||||
const png = path.join(outDir, `${base}.png`)
|
||||
if (UPSCALABLE.test(name) && fs.existsSync(png)) {
|
||||
const webp = path.join(work, `${base}.webp`)
|
||||
// Demasiado grande para WebP: se reduce justo hasta el límite (sigue mejor que la original).
|
||||
const size = pngSize(png)
|
||||
const fit = size && Math.max(size.w, size.h) > WEBP_MAX_SIDE ? (size.h >= size.w ? ['-resize', '0', String(WEBP_MAX_SIDE)] : ['-resize', String(WEBP_MAX_SIDE), '0']) : []
|
||||
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', ...fit, png, '-o', webp])
|
||||
out.push({ name: `${base}.webp`, data: fs.readFileSync(webp) })
|
||||
fs.rmSync(webp); fs.rmSync(png)
|
||||
} else {
|
||||
out.push({ name, data: fs.readFileSync(path.join(inDir, name)) }) // sin reescalar
|
||||
}
|
||||
}
|
||||
if (!chapterRow(row.chapter_id)) return // la descarga se borró mientras tanto
|
||||
fs.mkdirSync(path.join(dir, HD_DIR), { recursive: true })
|
||||
const hdFile = path.join(HD_DIR, row.file)
|
||||
writeCbz(path.join(dir, hdFile), out)
|
||||
const ok = db.prepare('UPDATE downloaded_chapters SET hd_file = ? WHERE chapter_id = ?').run(hdFile, row.chapter_id).changes
|
||||
if (!ok) fs.rmSync(path.join(dir, hdFile), { force: true }) // la descarga se borró mientras tanto
|
||||
return entries.length
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true })
|
||||
current = null
|
||||
}
|
||||
}
|
||||
|
||||
let pumping = false
|
||||
async function pump() {
|
||||
if (pumping || !available()) return
|
||||
pumping = true
|
||||
try {
|
||||
for (;;) {
|
||||
const job = db.prepare("SELECT * FROM upscale_jobs WHERE status = 'queued' ORDER BY created_at, rowid LIMIT 1").get()
|
||||
if (!job) break
|
||||
db.prepare("UPDATE upscale_jobs SET status = 'running' WHERE chapter_id = ?").run(job.chapter_id)
|
||||
const t = Date.now()
|
||||
try {
|
||||
const pages = await upscaleChapter(job)
|
||||
db.prepare('DELETE FROM upscale_jobs WHERE chapter_id = ?').run(job.chapter_id)
|
||||
const secs = (Date.now() - t) / 1000
|
||||
if (pages) {
|
||||
secPerPage = secPerPage * 0.7 + (secs / pages) * 0.3
|
||||
db.prepare("INSERT INTO kv (k, v) VALUES ('hd_sec_per_page', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v").run(secPerPage.toFixed(2))
|
||||
}
|
||||
console.log(`HD: ${job.chapter_id} en ${Math.round(secs)} s (${pages || 0} págs.)`)
|
||||
} catch (e) {
|
||||
console.warn('HD', job.chapter_id, e.message)
|
||||
db.prepare("UPDATE upscale_jobs SET status = 'error', error = ? WHERE chapter_id = ?").run(e.message.slice(0, 500), job.chapter_id)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pumping = false
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(chapterIds) {
|
||||
const ins = db.prepare(`INSERT INTO upscale_jobs (chapter_id, manga_id, status, created_at)
|
||||
SELECT chapter_id, manga_id, 'queued', ? FROM downloaded_chapters WHERE chapter_id = ? AND hd_file IS NULL
|
||||
ON CONFLICT (chapter_id) DO UPDATE SET status = 'queued', error = NULL WHERE status = 'error'`)
|
||||
const now = Date.now()
|
||||
let n = 0
|
||||
for (const id of chapterIds) n += ins.run(now, id).changes
|
||||
pump()
|
||||
return n
|
||||
}
|
||||
|
||||
function cancel(mangaId) {
|
||||
db.prepare('DELETE FROM upscale_jobs WHERE manga_id = ?').run(mangaId)
|
||||
if (current?.mangaId === mangaId) current.proc?.kill('SIGTERM')
|
||||
}
|
||||
|
||||
// Ruta absoluta del CBZ HD de un capítulo, o null.
|
||||
function hdFileOf(chapterId) {
|
||||
const row = chapterRow(chapterId)
|
||||
if (!row?.hd_file) return null
|
||||
const file = path.join(MANGA_DIR, row.folder, row.hd_file)
|
||||
return fs.existsSync(file) ? file : null
|
||||
}
|
||||
|
||||
function info(mangaId) {
|
||||
const dl = db.prepare('SELECT folder, hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)
|
||||
if (!dl) return { available: available() }
|
||||
const rows = db.prepare('SELECT chapter_id, pages, hd_file FROM downloaded_chapters WHERE manga_id = ?').all(mangaId)
|
||||
const jobs = db.prepare('SELECT chapter_id, status, error FROM upscale_jobs WHERE manga_id = ?').all(mangaId)
|
||||
let size = 0
|
||||
try { for (const f of fs.readdirSync(path.join(MANGA_DIR, dl.folder, HD_DIR))) size += fs.statSync(path.join(MANGA_DIR, dl.folder, HD_DIR, f)).size } catch {}
|
||||
const cur = current?.mangaId === mangaId ? { chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total } : null
|
||||
return {
|
||||
available: available(), auto: !!dl.hd_auto, downloaded: rows.length, hd: rows.filter(r => r.hd_file).length, size, current: cur,
|
||||
chapters: rows.filter(r => r.hd_file).map(r => r.chapter_id),
|
||||
pages: Object.fromEntries(rows.map(r => [r.chapter_id, r.pages])), // para estimar tiempos
|
||||
secPerPage: Math.round(secPerPage * 10) / 10,
|
||||
jobs: Object.fromEntries(jobs.map(j => [j.chapter_id, j.status])),
|
||||
error: (j => j && `Cap. ${db.prepare('SELECT num FROM downloaded_chapters WHERE chapter_id = ?').get(j.chapter_id)?.num ?? '?'}: ${j.error}`)(jobs.find(j => j.status === 'error')) || null,
|
||||
}
|
||||
}
|
||||
|
||||
// Cola global (página "Colas"): por manga, en el orden en que se procesan.
|
||||
function queue() {
|
||||
const rows = db.prepare(`SELECT j.chapter_id, j.manga_id, j.status, j.error, j.created_at, c.num, c.pages, d.title
|
||||
FROM upscale_jobs j JOIN downloaded_chapters c ON c.chapter_id = j.chapter_id JOIN downloads d ON d.manga_id = j.manga_id
|
||||
ORDER BY j.created_at, j.rowid`).all()
|
||||
const groups = new Map()
|
||||
for (const r of rows) {
|
||||
let g = groups.get(r.manga_id)
|
||||
if (!g) groups.set(r.manga_id, g = { mangaId: r.manga_id, title: r.title, queued: 0, pages: 0, errors: [], next: [] })
|
||||
if (r.status === 'error') g.errors.push({ chapterId: r.chapter_id, num: r.num, error: r.error })
|
||||
else {
|
||||
g.queued++
|
||||
g.pages += r.pages || 0
|
||||
if (g.next.length < 5 && r.chapter_id !== current?.chapterId) g.next.push(r.num)
|
||||
}
|
||||
}
|
||||
const cur = current && { mangaId: current.mangaId, chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total }
|
||||
// Páginas que faltan (la del capítulo en curso cuenta solo lo que le queda).
|
||||
for (const g of groups.values()) {
|
||||
if (cur?.mangaId === g.mangaId) g.pages = Math.max(0, g.pages - (rows.find(r => r.chapter_id === cur.chapterId)?.pages || 0) + (cur.total - cur.pages))
|
||||
g.eta = Math.round(g.pages * secPerPage)
|
||||
}
|
||||
const list = [...groups.values()].sort((a, b) => (b.mangaId === cur?.mangaId) - (a.mangaId === cur?.mangaId))
|
||||
return { available: available(), current: cur, groups: list, secPerPage: Math.round(secPerPage * 10) / 10, eta: list.reduce((n, g) => n + g.eta, 0) }
|
||||
}
|
||||
|
||||
// Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo.
|
||||
function onChapterDownloaded(mangaId, chapterId) {
|
||||
const wanted = db.prepare('DELETE FROM upscale_wanted WHERE chapter_id = ?').run(chapterId).changes
|
||||
if (wanted || db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId])
|
||||
}
|
||||
|
||||
async function route(p, method, req, res) {
|
||||
const m = /^\/api\/upscale\/([^/]+)(\/cancel|\/auto)?$/.exec(p)
|
||||
if (!m || !MANGA_ID.test(m[1])) return false
|
||||
const mangaId = m[1]
|
||||
if (method === 'GET' && !m[2]) return json(res, 200, info(mangaId)), true
|
||||
if (method === 'POST' && m[2] === '/auto') {
|
||||
const b = await readBody(req)
|
||||
db.prepare('UPDATE downloads SET hd_auto = ? WHERE manga_id = ?').run(b.auto ? 1 : 0, mangaId)
|
||||
return json(res, 200, info(mangaId)), true
|
||||
}
|
||||
if (method === 'POST' && m[2]) {
|
||||
cancel(mangaId)
|
||||
db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId)
|
||||
return json(res, 200, info(mangaId)), true
|
||||
}
|
||||
if (method === 'POST') {
|
||||
if (!available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)')
|
||||
const b = await readBody(req)
|
||||
let ids
|
||||
if (b.chapterId) ids = [String(b.chapterId)]
|
||||
else if (Array.isArray(b.chapterIds)) ids = b.chapterIds.slice(0, 5000).map(String) // selección o rango
|
||||
else {
|
||||
// Todo el manga: lo descargado y, en adelante, lo que se descargue.
|
||||
db.prepare('UPDATE downloads SET hd_auto = 1 WHERE manga_id = ?').run(mangaId)
|
||||
ids = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ? ORDER BY CAST(num AS REAL), created_at').all(mangaId).map(r => r.chapter_id)
|
||||
}
|
||||
if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(mangaId)) throw new HttpError(404, 'Este manga no está descargado')
|
||||
enqueue(ids)
|
||||
return json(res, 200, info(mangaId)), true
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
// Quitar la versión HD (el original se queda).
|
||||
cancel(mangaId)
|
||||
db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId)
|
||||
const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId)
|
||||
if (dl) fs.rmSync(path.join(MANGA_DIR, dl.folder, HD_DIR), { recursive: true, force: true })
|
||||
db.prepare('UPDATE downloaded_chapters SET hd_file = NULL WHERE manga_id = ?').run(mangaId)
|
||||
return json(res, 200, info(mangaId)), true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado')
|
||||
else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado')
|
||||
// Reescalar estos capítulos: los descargados ya, el resto al terminar su descarga.
|
||||
function request(chapterIds) {
|
||||
const have = db.prepare('SELECT 1 FROM downloaded_chapters WHERE chapter_id = ?')
|
||||
const later = db.prepare('INSERT OR IGNORE INTO upscale_wanted (chapter_id, created_at) VALUES (?, ?)')
|
||||
const now = []
|
||||
for (const id of chapterIds) have.get(id) ? now.push(id) : later.run(id, Date.now())
|
||||
if (now.length) enqueue(now)
|
||||
}
|
||||
|
||||
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue, request }
|
||||
}
|
||||
+770
@@ -0,0 +1,770 @@
|
||||
// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
|
||||
// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
|
||||
// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
|
||||
// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
|
||||
// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
|
||||
// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
|
||||
// Lo descargado queda en <DATA_DIR>/tomes/<id de MangaDex>/<tomo>-<cara>.img.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const DAY = 24 * 3600e3
|
||||
const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
|
||||
const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
|
||||
const AMAZON = 'https://m.media-amazon.com/images/P'
|
||||
const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
|
||||
const MAX_IMAGE = 30 * 1024 * 1024
|
||||
const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
|
||||
const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
|
||||
const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
|
||||
// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
|
||||
export const volKey = v => {
|
||||
const s = String(v ?? '').trim()
|
||||
if (!s || s.toLowerCase() === 'none') return null
|
||||
return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
|
||||
}
|
||||
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
|
||||
const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
|
||||
|
||||
// Título comparable: sin signos, espacios ni diferencias de anchura.
|
||||
export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
export function ndlVolume(s) {
|
||||
const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
|
||||
return m ? String(parseInt(m[1], 10)) : null
|
||||
}
|
||||
export function isbn10(raw) {
|
||||
const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
|
||||
if (/^\d{9}[\dX]$/.test(s)) return s
|
||||
if (!/^978\d{10}$/.test(s)) return null
|
||||
const body = s.slice(3, 12)
|
||||
let sum = 0
|
||||
for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
|
||||
const c = (11 - (sum % 11)) % 11
|
||||
return body + (c === 10 ? 'X' : String(c))
|
||||
}
|
||||
const xmlText = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim()
|
||||
|
||||
// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
|
||||
// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
|
||||
export function pickIsbns(xmlPages, titles, year) {
|
||||
const keys = new Set(titles.map(titleKey).filter(Boolean))
|
||||
const groups = new Map()
|
||||
for (const xml of xmlPages) {
|
||||
for (const [, it] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
||||
if (!keys.has(titleKey(xmlText(/<dc:title>([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
|
||||
const vol = ndlVolume(xmlText(/<dcndl:volume>([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
|
||||
if (!vol) continue
|
||||
const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+)</g)].map(m => isbn10(m[1])).filter(Boolean)
|
||||
if (!isbns.length) continue
|
||||
const author = titleKey(xmlText(/<author>([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
|
||||
const when = parseInt(/<dc:date[^>]*>(\d{4})/.exec(it)?.[1], 10) || null
|
||||
if (!groups.has(author)) groups.set(author, new Map())
|
||||
const g = groups.get(author)
|
||||
if (!g.has(vol)) g.set(vol, { isbns: [], when })
|
||||
const e = g.get(vol)
|
||||
for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
|
||||
if (when && (!e.when || when < e.when)) e.when = when
|
||||
}
|
||||
}
|
||||
const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
|
||||
const best = [...groups.values()]
|
||||
.filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
|
||||
.sort((a, b) => b.size - a.size)[0]
|
||||
if (!best) return {}
|
||||
return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
|
||||
}
|
||||
|
||||
// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
|
||||
// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
|
||||
// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
|
||||
const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
|
||||
export function pickEnglishIsbns(docs, titles, vol) {
|
||||
const out = []
|
||||
for (const title of titles) {
|
||||
const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
|
||||
for (const d of docs) {
|
||||
if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
|
||||
for (const x of d.isbn || []) {
|
||||
const i = isbn10(x)
|
||||
if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
|
||||
export function pickCovers(records, lang) {
|
||||
const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
|
||||
const out = {}
|
||||
for (const r of records) {
|
||||
const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
|
||||
if (!vol || !file) continue
|
||||
if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function imageKind(b) {
|
||||
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
||||
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
||||
if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
|
||||
/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
|
||||
* Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
|
||||
export function strayNums(nums) {
|
||||
const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
|
||||
if (known.length < 3) return new Set()
|
||||
const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
|
||||
const center = middle(known)
|
||||
const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
|
||||
const limit = Math.max(4 * spread, 5)
|
||||
const far = known.filter(n => Math.abs(n - center) > limit)
|
||||
return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
|
||||
}
|
||||
|
||||
/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
|
||||
export function cleanVolMap(map) {
|
||||
const byVol = new Map()
|
||||
for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
|
||||
const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
|
||||
const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
|
||||
const usual = sizes[(sizes.length - 1) >> 1]
|
||||
const out = {}
|
||||
for (const [v, nums] of byVol) {
|
||||
const size = whole(nums)
|
||||
if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
|
||||
const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
|
||||
for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
|
||||
export function volMapsAgree(a, b) {
|
||||
const shared = Object.keys(a).filter(n => n in b)
|
||||
if (shared.length < 5) return true
|
||||
return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
|
||||
}
|
||||
|
||||
/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
|
||||
export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
|
||||
const merged = {}
|
||||
// M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
|
||||
const shown = new Set(Object.values(mi).map(Number))
|
||||
const inMu = new Set(Object.values(mu).map(Number))
|
||||
const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
|
||||
for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
|
||||
if (!volMapsAgree(src, merged)) continue
|
||||
for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
|
||||
export function parseMuVolumes(rows) {
|
||||
const votes = {}
|
||||
for (const { volume, chapter } of rows) {
|
||||
const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
|
||||
if (!vol) continue
|
||||
const ch = String(chapter ?? '').trim()
|
||||
const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
|
||||
const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
|
||||
const nums = one ? [parseFloat(one[1])] : []
|
||||
if (range) {
|
||||
const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
|
||||
if (hi >= lo && hi - lo < 50) {
|
||||
nums.push(lo, hi)
|
||||
for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
|
||||
}
|
||||
}
|
||||
for (const n of new Set(nums)) {
|
||||
const count = (votes[n] ||= {})
|
||||
count[vol] = (count[vol] || 0) + 1
|
||||
}
|
||||
}
|
||||
const map = {}
|
||||
for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
|
||||
return map
|
||||
}
|
||||
|
||||
/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
|
||||
* si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
|
||||
export function orderedMuMap(map, total = 0) {
|
||||
const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
const vol = nums.map(n => parseInt(map[n], 10))
|
||||
// La subsecuencia no decreciente más larga.
|
||||
const len = vol.map(() => 1), from = vol.map(() => -1)
|
||||
let end = 0
|
||||
vol.forEach((v, i) => {
|
||||
for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
|
||||
if (len[i] > len[end]) end = i
|
||||
})
|
||||
const out = {}
|
||||
for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
|
||||
const size = Object.keys(out).length
|
||||
return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
|
||||
}
|
||||
|
||||
/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
|
||||
* antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
|
||||
export function fillVolGaps(map, nums) {
|
||||
const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
|
||||
const out = { ...map }
|
||||
for (const key of nums) {
|
||||
if (key == null || key in map) continue
|
||||
const n = parseFloat(key)
|
||||
const prev = anchors.filter(a => a < n).at(-1)
|
||||
const next = anchors.find(a => a > n)
|
||||
if (next === undefined) continue
|
||||
const after = map[next]
|
||||
if (prev === undefined) {
|
||||
if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
|
||||
continue
|
||||
}
|
||||
const before = map[prev]
|
||||
if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const ownVolMap = chapters => {
|
||||
const map = {}
|
||||
for (const c of chapters) {
|
||||
const n = numKey(c.num), v = volKey(c.volume)
|
||||
if (n && v && !(n in map)) map[n] = v
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
|
||||
export function numbersRestart(chapters) {
|
||||
const seen = new Map()
|
||||
let repeated = 0
|
||||
for (const c of chapters) {
|
||||
const n = numKey(c.num), v = volKey(c.volume)
|
||||
if (!n || !v) continue
|
||||
if (seen.has(n) && seen.get(n) !== v) repeated++
|
||||
else seen.set(n, v)
|
||||
}
|
||||
return repeated >= 3
|
||||
}
|
||||
|
||||
/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
|
||||
* y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
|
||||
export function assignVolumes(chapters, map = {}) {
|
||||
const vols = chapters.map(c => map[numKey(c.num)] ?? null)
|
||||
vols.forEach((v, i) => {
|
||||
const n = parseFloat(chapters[i].num)
|
||||
if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
|
||||
})
|
||||
let last = -1
|
||||
vols.forEach((v, i) => {
|
||||
if (!v) return
|
||||
if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
|
||||
last = i
|
||||
})
|
||||
return vols
|
||||
}
|
||||
|
||||
/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
|
||||
* (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
|
||||
export function dropMisplaced(vols, maxRun = 3) {
|
||||
const out = [...vols]
|
||||
if (!vols.every(v => !v || /^\d+$/.test(v))) return out
|
||||
const runsOf = () => {
|
||||
const runs = []
|
||||
out.forEach((v, i) => {
|
||||
if (!v) return
|
||||
if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
|
||||
else runs.push({ v: parseInt(v, 10), at: [i] })
|
||||
})
|
||||
return runs
|
||||
}
|
||||
// De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
|
||||
for (;;) {
|
||||
const runs = runsOf()
|
||||
const wrong = runs.filter((r, j) => {
|
||||
if (r.at.length > maxRun) return false
|
||||
const [a, b] = [runs[j - 1], runs[j + 1]]
|
||||
if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
|
||||
const near = a || b
|
||||
return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
|
||||
})
|
||||
if (!wrong.length) return out
|
||||
const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
|
||||
for (const i of hit.at) out[i] = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
|
||||
* known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
|
||||
export function placeLoose(chapters, vols, known = []) {
|
||||
const out = dropMisplaced(vols)
|
||||
const isNum = v => /^\d+$/.test(v ?? '')
|
||||
const floorOf = i => Math.floor(parseFloat(chapters[i].num))
|
||||
// Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
|
||||
const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
|
||||
const spread = (from, to, names) => {
|
||||
let start = from
|
||||
names.forEach((name, j) => {
|
||||
const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
|
||||
for (let i = start; i < end; i++) out[i] = name
|
||||
start = Math.max(start, end)
|
||||
})
|
||||
}
|
||||
const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
|
||||
const assigned = out.flatMap((v, i) => (v ? [i] : []))
|
||||
if (!assigned.length) {
|
||||
if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
|
||||
return out
|
||||
}
|
||||
const count = {}
|
||||
for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
|
||||
const sizes = Object.values(count).sort((a, b) => a - b)
|
||||
const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
|
||||
const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
|
||||
const free = []
|
||||
for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
|
||||
return free
|
||||
}
|
||||
// Antes del primer tomo.
|
||||
const first = assigned[0]
|
||||
if (first) {
|
||||
const head = out[first]
|
||||
const free = isNum(head) ? between(0, parseInt(head, 10)) : []
|
||||
const n = Math.min(free.length, Math.round(first / usual))
|
||||
if (n) spread(0, first, free.slice(-n))
|
||||
else for (let i = 0; i < first; i++) out[i] = head
|
||||
}
|
||||
// Entre dos tomos.
|
||||
for (let a = 0; a < assigned.length - 1; a++) {
|
||||
const [l, r] = [assigned[a], assigned[a + 1]]
|
||||
if (r - l < 2) continue
|
||||
const [left, right] = [out[l], out[r]]
|
||||
const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
|
||||
if (free.length) {
|
||||
// Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
|
||||
const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
|
||||
const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
|
||||
const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
|
||||
for (let i = l + 1; i < from; i++) out[i] = left
|
||||
for (let i = to; i < r; i++) out[i] = right
|
||||
spread(from, to, free)
|
||||
count[left] += from - l - 1
|
||||
count[right] += r - to
|
||||
continue
|
||||
}
|
||||
if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
|
||||
for (let i = l + 1; i < r; i++) out[i] = left
|
||||
continue
|
||||
}
|
||||
// Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
|
||||
const whole = []
|
||||
for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
|
||||
if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
|
||||
const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
|
||||
const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
|
||||
const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
|
||||
for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
|
||||
count[left] += at - l - 1
|
||||
count[right] += r - at
|
||||
}
|
||||
// Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
|
||||
const end = assigned.at(-1)
|
||||
const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
|
||||
const rest = chapters.length - end - 1
|
||||
if (free.length && rest) {
|
||||
const fits = rest <= free.length * usual * 1.3
|
||||
spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
|
||||
function numberedListCount(text, from) {
|
||||
let depth = 0, bars = 0
|
||||
for (let i = from; i < text.length - 1; i++) {
|
||||
const two = text.slice(i, i + 2)
|
||||
if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
|
||||
if (--depth < 0) break
|
||||
i++
|
||||
} else if (text[i] === '|' && depth === 0) bars++
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
|
||||
* "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
|
||||
* empezar por el tomo 1, se ignoran. */
|
||||
export function parseWikiVolumes(text) {
|
||||
const map = {}, seen = new Set()
|
||||
let next = 1
|
||||
for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
|
||||
const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
|
||||
if (!vol) continue
|
||||
if (seen.has(vol)) break
|
||||
seen.add(vol)
|
||||
const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
|
||||
if (!fields.length) continue
|
||||
const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
|
||||
for (const f of fields) {
|
||||
const start = f.index + f[0].length
|
||||
const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
|
||||
const body = block.slice(start, end < 0 ? undefined : start + end)
|
||||
const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
|
||||
if (numbered.length) {
|
||||
for (const m of numbered) {
|
||||
const first = parseInt(m[1], 10)
|
||||
for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
|
||||
if (range) {
|
||||
for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
|
||||
continue
|
||||
}
|
||||
for (const line of body.split('\n')) {
|
||||
if (!/^\s*[*#]/.test(line)) continue
|
||||
const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
|
||||
add(own ? parseInt(own[1], 10) : next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
|
||||
export function workTitle(title) {
|
||||
const t = String(title || '').trim()
|
||||
const base = t
|
||||
.replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
|
||||
.replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
|
||||
.replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
|
||||
return base.length >= 3 ? base : t
|
||||
}
|
||||
|
||||
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
|
||||
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
|
||||
|
||||
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
|
||||
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
|
||||
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
|
||||
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
|
||||
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
|
||||
export function pickLogo(files, names) {
|
||||
let best = null, bestScore = 0
|
||||
files.forEach((file, i) => {
|
||||
if (!/\.(png|svg|webp)$/i.test(file)) return
|
||||
const fw = logoWords(file.replace(/^File:/, ''))
|
||||
if (!fw.includes('logo')) return
|
||||
names.forEach((name, k) => {
|
||||
const nw = logoWords(name)
|
||||
if (!nw.length || nw.join('').length < 4) return
|
||||
const hit = nw.filter(w => fw.includes(w)).length / nw.length
|
||||
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
|
||||
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
|
||||
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
|
||||
})
|
||||
})
|
||||
return best
|
||||
}
|
||||
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
|
||||
|
||||
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
|
||||
const DIR = path.join(DATA_DIR, 'tomes')
|
||||
|
||||
const fetchImage = async url => {
|
||||
net.guard()
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
|
||||
if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
|
||||
if (!r.ok) return null
|
||||
const b = Buffer.from(await r.arrayBuffer())
|
||||
return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
|
||||
}
|
||||
|
||||
// ---------- Lo que dice MangaDex de la obra ----------
|
||||
async function mdVolumes(mdId, lang) {
|
||||
return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
|
||||
const [agg, mangaData] = await Promise.all([
|
||||
md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
|
||||
md(`/manga/${mdId}`, {}, 30 * 60e3),
|
||||
])
|
||||
const byNum = {}
|
||||
const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
|
||||
for (const v of vols) {
|
||||
const vol = volKey(v.volume)
|
||||
if (!vol) continue
|
||||
const inVol = Object.values(v.chapters || {})
|
||||
const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
|
||||
for (const c of inVol) {
|
||||
const n = numKey(c.chapter)
|
||||
if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
|
||||
}
|
||||
}
|
||||
const records = []
|
||||
for (let offset = 0; offset < 1000; offset += 100) {
|
||||
const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
|
||||
records.push(...r.data)
|
||||
if (offset + 100 >= r.total || !r.data.length) break
|
||||
}
|
||||
const a = mangaData.data.attributes
|
||||
const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
|
||||
const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
|
||||
const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
|
||||
return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
|
||||
}, true)
|
||||
}
|
||||
|
||||
let gate = Promise.resolve()
|
||||
const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
|
||||
|
||||
// ---------- Otras fuentes del reparto ----------
|
||||
async function wikiPages(titles) {
|
||||
if (!titles.length) return []
|
||||
await turn() // Wikipedia corta a quien va demasiado rápido
|
||||
net.guard()
|
||||
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
|
||||
const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
let r = await get()
|
||||
if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
|
||||
await new Promise(res => setTimeout(res, 3000))
|
||||
r = await get()
|
||||
}
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
|
||||
return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
|
||||
}
|
||||
async function wikiSearch(names) {
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
|
||||
const wanted = new Set(names.map(wikiKey))
|
||||
return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
|
||||
}
|
||||
function wikiMap(mangaId, names) {
|
||||
return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
|
||||
const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
|
||||
let best = {}
|
||||
let pages = await wikiPages(titles)
|
||||
if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
|
||||
for (const text of pages) {
|
||||
// One Piece y otras largas reparten la lista en subpáginas.
|
||||
const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
|
||||
let found = parseWikiVolumes(text)
|
||||
for (let i = 0; i < subs.length; i += 10) {
|
||||
for (const sub of await wikiPages(subs.slice(i, i + 10))) {
|
||||
const part = parseWikiVolumes(sub)
|
||||
found = { ...part, ...found }
|
||||
}
|
||||
}
|
||||
if (Object.keys(found).length > Object.keys(best).length) best = found
|
||||
}
|
||||
return best
|
||||
}, true)
|
||||
}
|
||||
function m440Map(mangaId, names) {
|
||||
return stored(`mimap:${mangaId}`, DAY, async () => {
|
||||
for (const name of names.slice(0, 2)) {
|
||||
const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
|
||||
if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
|
||||
}
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- ISBN de cada tomo ----------
|
||||
async function ndlSearch(title) {
|
||||
const pages = []
|
||||
for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
|
||||
const xml = await r.text()
|
||||
pages.push(xml)
|
||||
if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
|
||||
}
|
||||
return pages
|
||||
}
|
||||
async function isbnsOf(mdId) {
|
||||
return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
|
||||
const d = await mdVolumes(mdId)
|
||||
if (d.original !== 'ja' || !d.jaTitles.length) return {}
|
||||
for (const t of d.jaTitles.slice(0, 3)) {
|
||||
const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
|
||||
if (Object.keys(found).length) return found
|
||||
}
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
async function englishIsbns(mdId, vol) {
|
||||
const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
|
||||
if (!titles.length) return []
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
|
||||
return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
|
||||
}
|
||||
|
||||
// ---------- Imágenes de un tomo ----------
|
||||
async function fetchFace(mdId, vol, side) {
|
||||
const volNum = /^\d+$/.test(vol) ? vol : null
|
||||
if (side === 'front') {
|
||||
const c = (await mdVolumes(mdId)).covers[vol]
|
||||
if (c) {
|
||||
const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
|
||||
if (b) return b
|
||||
}
|
||||
}
|
||||
if (!volNum) return null
|
||||
for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
|
||||
const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
|
||||
if (b) return b
|
||||
}
|
||||
if (side === 'back') {
|
||||
for (const isbn of await englishIsbns(mdId, volNum)) {
|
||||
const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
|
||||
if (b) return b
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const inflight = new Map()
|
||||
function face(mdId, vol, side) {
|
||||
const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
|
||||
const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
|
||||
if (fs.existsSync(file)) return Promise.resolve(file)
|
||||
const key = `tome:${mdId}:${vol}:${label}`
|
||||
if (!inflight.has(key)) {
|
||||
// Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
|
||||
inflight.set(key, stored(key, 14 * DAY, async () => {
|
||||
const b = await fetchFace(mdId, vol, side)
|
||||
if (!b) return { found: false }
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
fs.writeFileSync(tmp, b)
|
||||
fs.renameSync(tmp, file)
|
||||
return { found: true }
|
||||
}, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
|
||||
}
|
||||
return inflight.get(key)
|
||||
}
|
||||
|
||||
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
|
||||
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
|
||||
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
|
||||
async function commonsGet(params) {
|
||||
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
|
||||
await turn()
|
||||
net.guard()
|
||||
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
const text = r?.ok ? await r.text() : ''
|
||||
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
|
||||
const data = text.startsWith('{') ? JSON.parse(text) : null
|
||||
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
|
||||
return data
|
||||
}
|
||||
async function commonsLogo(names) {
|
||||
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
|
||||
for (const q of queries) {
|
||||
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
|
||||
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
|
||||
if (!best) continue
|
||||
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
|
||||
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
|
||||
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
|
||||
function logo(mangaId, lang) {
|
||||
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
|
||||
const { manga } = await fetchMangaFull(mangaId, lang)
|
||||
if (hasNoVolumes(manga.type)) return null
|
||||
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
|
||||
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
|
||||
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
|
||||
return commonsLogo(names)
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- API ----------
|
||||
async function list(mangaId, lang) {
|
||||
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
|
||||
if (hasNoVolumes(manga.type)) return []
|
||||
const source = sourceOf(mangaId)
|
||||
// Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
|
||||
const found = await mdMatches(mangaId).catch(() => [])
|
||||
const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
|
||||
const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
|
||||
const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
|
||||
const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
|
||||
if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
|
||||
const mdId = source === 'md' ? mangaId : best?.id || null
|
||||
const data = editions.find(e => e.id === mdId)?.data || null
|
||||
const own = cleanVolMap(ownVolMap(chapters))
|
||||
let vols
|
||||
if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
|
||||
else {
|
||||
const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
|
||||
const [wiki, mi] = await Promise.all([
|
||||
wikiMap(mangaId, names).catch(() => ({})),
|
||||
source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
|
||||
])
|
||||
const byNum = cleanVolMap(best?.data.byNum || {})
|
||||
const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
|
||||
// Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
|
||||
const known = { ...main.wiki, ...main.md }
|
||||
const missing = chapters.filter(c => !(numKey(c.num) in known)).length
|
||||
const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
|
||||
vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
|
||||
}
|
||||
vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
|
||||
const groups = new Map()
|
||||
vols.forEach((vol, i) => {
|
||||
if (!vol) return
|
||||
if (!groups.has(vol)) groups.set(vol, [])
|
||||
groups.get(vol).push(chapters[i].id)
|
||||
})
|
||||
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
|
||||
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
|
||||
return [...groups.keys()].sort(volOrder).map(vol => {
|
||||
const cover = data?.covers[vol]
|
||||
return {
|
||||
vol,
|
||||
chapters: groups.get(vol),
|
||||
pages: pagesOf(groups.get(vol)),
|
||||
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
|
||||
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
|
||||
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
|
||||
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
async function sendFace(res, mdId, vol, side) {
|
||||
if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
|
||||
const file = await face(mdId, vol, side)
|
||||
if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
|
||||
const head = Buffer.alloc(16)
|
||||
const fd = fs.openSync(file, 'r')
|
||||
fs.readSync(fd, head, 0, 16, 0)
|
||||
fs.closeSync(fd)
|
||||
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
|
||||
fs.createReadStream(file).pipe(res)
|
||||
}
|
||||
return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Todo lo que depende de la máquina va en .env (ver .env.example).
|
||||
# Sin .env también arranca: puerto 3352 en todas las interfaces, descargas en ./mangas.
|
||||
services:
|
||||
manga-reader:
|
||||
build: .
|
||||
image: manga-reader:local
|
||||
container_name: manga-reader
|
||||
restart: unless-stopped
|
||||
# Dueño de ./data y de la carpeta de mangas en el host (id -u / id -g).
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
environment:
|
||||
- TZ=${TZ:-America/El_Salvador}
|
||||
- PORT=3352
|
||||
- DATA_DIR=/app/data
|
||||
- MANGA_DIR=/mangas
|
||||
# Cada cuántas horas se buscan capítulos nuevos de los mangas seguidos.
|
||||
- CHECK_INTERVAL_HOURS=${CHECK_INTERVAL_HOURS:-3}
|
||||
# URL con la que se abre la web desde las notificaciones.
|
||||
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:3352}
|
||||
# Registro de cuentas nuevas. Con la base vacía el primer usuario siempre puede registrarse.
|
||||
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
|
||||
# Baneo de IP: tras BAN_MAX_FAILS intentos fallidos en BAN_WINDOW_MIN minutos, BAN_HOURS horas.
|
||||
- BAN_MAX_FAILS=${BAN_MAX_FAILS:-6}
|
||||
- BAN_WINDOW_MIN=${BAN_WINDOW_MIN:-60}
|
||||
- BAN_HOURS=${BAN_HOURS:-24}
|
||||
- BAN_IGNORE=${BAN_IGNORE:-}
|
||||
# Detrás de un proxy inverso (Caddy, Nginx, Cloudflare Tunnel…): cabecera con la IP real del
|
||||
# cliente, p. ej. x-forwarded-for o cf-connecting-ip. Vacío = conexión directa.
|
||||
- REAL_IP_HEADER=${REAL_IP_HEADER:-}
|
||||
volumes:
|
||||
# SQLite con usuarios, sesiones, historial, estadísticas y clave del proxy de imágenes.
|
||||
- ./data:/app/data
|
||||
# Mangas descargados: <Título>/Capitulo NNNN.cbz + cover.jpg
|
||||
- ${MANGA_DIR_HOST:-./mangas}:/mangas
|
||||
# GPU para el reescalado HD (Real-ESRGAN por Vulkan). Sin /dev/dri en el host, borra estas líneas
|
||||
# y el reescalado queda desactivado. RENDER_GID = grupo "render" del host (getent group render).
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
- "${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.
|
||||
- "${BIND_IP:-0.0.0.0}:${HOST_PORT:-3352}:3352"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
Reference in New Issue
Block a user