From 86999b572537798bbdbbba6582a79b076918081d Mon Sep 17 00:00:00 2001 From: Nino Date: Sun, 1 Nov 2026 09:52:19 -0600 Subject: [PATCH] =?UTF-8?q?Fuente=20M440,=20modo=20sin=20conexi=C3=B3n=20(?= =?UTF-8?q?servidor=20sin=20internet=20y=20app=20sin=20servidor),=20cola?= =?UTF-8?q?=20del=20dispositivo=20en=20Colas=20y=20arreglo=20del=20reescal?= =?UTF-8?q?ado=20de=20tiras=20altas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola. - Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión. - App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente. - Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor. - Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla. - Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo. - Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo. Co-Authored-By: Claude Opus 5.5 --- README.md | 29 ++++- app/connectivity.js | 53 +++++++++ app/features.js | 12 +- app/library.js | 9 +- app/m440-key.mjs | 55 +++++++++ app/m440.js | 190 +++++++++++++++++++++++++++++++ app/public/app.js | 259 +++++++++++++++++++++++++++++++++++++------ app/public/style.css | 6 + app/public/sw.js | 36 ++++-- app/server.js | 125 ++++++++++++++++----- app/upscale.js | 16 ++- 11 files changed, 712 insertions(+), 78 deletions(-) create mode 100644 app/connectivity.js create mode 100644 app/m440-key.mjs create mode 100644 app/m440.js diff --git a/README.md b/README.md index 5f1c5f3..69551c6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ # Manga Reader -Lector web de manga autoalojado, con las dos fuentes de -[Tanko](https://github.com/Alexandro521/Tanko) (`src/servers/`): +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. + 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 @@ -70,6 +75,24 @@ Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./ El primer usuario puede registrarse siempre; después el registro queda cerrado salvo `ALLOW_SIGNUP=true` en `.env`. +## 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 diff --git a/app/connectivity.js b/app/connectivity.js new file mode 100644 index 0000000..c82fa1f --- /dev/null +++ b/app/connectivity.js @@ -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), + } +} diff --git a/app/features.js b/app/features.js index 91adefa..aa780ca 100644 --- a/app/features.js +++ b/app/features.js @@ -41,7 +41,7 @@ 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 }) { +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, @@ -182,14 +182,17 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf 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(g.manga_id.startsWith('lc~') ? 1500 : 2500) + 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)`) @@ -199,6 +202,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf }) return state.checking } + net.whenOnline(() => { if (skippedOffline) { skippedOffline = false; checkFollows() } }) state.nextCheckAt = Date.now() + 90e3 setTimeout(() => { checkFollows() @@ -217,7 +221,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf // 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, - b.mangaId.startsWith('lc~') ? 'es' : b.lang || null, Date.now()) + /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang || null, Date.now()) } // Datos extra de la ficha; marca como vistos los capítulos nuevos. @@ -321,7 +325,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf } if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const b = await readBody(req) - const lang = m[1].startsWith('lc~') ? 'es' : /^[a-z]{2}(-[a-z]{2})?$/.test(b.lang || '') ? b.lang : user.lang + 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`) diff --git a/app/library.js b/app/library.js index f36561c..f510127 100644 --- a/app/library.js +++ b/app/library.js @@ -6,7 +6,7 @@ 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, upscale, stats, searchManga, mdCategories }) { +export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, upscale, stats, searchManga, mdCategories, net }) { db.exec(` CREATE TABLE IF NOT EXISTS lists ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -54,12 +54,12 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan // 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)) return + 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 = b.mangaId.startsWith('lc~') ? 'es' : b.lang + 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 @@ -82,6 +82,7 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan 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') @@ -105,7 +106,7 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan 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 } - recCache.set(userId, { at: Date.now(), data }) + if (items.length) recCache.set(userId, { at: Date.now(), data }) // vacío (fuente caída): no guardarlo 6 h return data } diff --git a/app/m440-key.mjs b/app/m440-key.mjs new file mode 100644 index 0000000..75050d3 --- /dev/null +++ b/app/m440-key.mjs @@ -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) diff --git a/app/m440.js b/app/m440.js new file mode 100644 index 0000000..9674b48 --- /dev/null +++ b/app/m440.js @@ -0,0 +1,190 @@ +// Fuente M440.in (MangasIn, tema MMRCMS), en español. +// IDs: manga "mi~", capítulo "mi~~". +// - 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 (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 = /]+src="([^"]+)"/.exec(e.label || '')?.[1] + const status = / /^[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>[\s\S]*?<\/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(`
${label}
\\s*]*>([\\s\\S]*?)`).exec(html) + return m ? m[1] : '' + } + const links = s => [...s.matchAll(/
]*>([^<]+)<\/a>/g)].map(m => decode(m[1])).filter(Boolean) + const well = /
\s*
[\s\S]*?Sinopsis[\s\S]*?<\/h5>([\s\S]*?)<\/div>/.exec(html)?.[1] || '' + // Los saltos del código fuente no cuentan: solo
(línea) y

(párrafo). + const description = decode(well.replace(/\s+/g, ' ').replace(//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(/

]*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]*?]*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 } +} diff --git a/app/public/app.js b/app/public/app.js index 7bda588..1887c63 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -24,6 +24,7 @@ const ICONS = { flame: '', clock: '', grid: '', + wifiOff: '', globe: '', key: '', bell: '', @@ -49,7 +50,8 @@ const icon = name => ` - +
${esc(m.title)}
${m.sub ? `
${esc(m.sub)}
` : ''}
` } function continueCard(h) { const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0 // Terminado con capítulos nuevos: a la ficha, para elegir el siguiente. return ` - +
${esc(h.manga_title)}
${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}
${h.finished ? '
Terminado
' : h.hasNew ? '
Caps. nuevos
' : ''} @@ -363,25 +467,61 @@ async function loadRecs(refresh = false) {
${r.items.map(mangaCard).join('')}
` $('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) }) } +// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local. +async function viewHomeOffline(params) { + app.innerHTML = ` +
+

${icon('download')} Descargado en el servidor

+ +
Cargando…
` + const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])]) + const have = new Set(list.map(m => m.id)) + const cont = hist.filter(h => !h.finished && have.has(h.manga_id)) + if (cont.length) $('#cont').innerHTML = `

Continuar leyendo

${cont.slice(0, 12).map(continueCard).join('')}
` + const res = $('#results') + const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + const render = () => { + const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean) + const shown = list.filter(m => words.every(w => fold(m.title).includes(w))) + res.className = shown.length ? 'grid' : 'loading' + res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.' + } + $('form.search').addEventListener('submit', e => e.preventDefault()) + $('[name=q]').addEventListener('input', render) + render() +} + +// Portada sin conexión con el servidor: lo guardado en este dispositivo. +function viewHomeDevice() { + const dev = OFFLINE_OK ? offByManga() : [] + app.innerHTML = `

${icon('phone')} En este dispositivo

` + (dev.length + ? `
${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}
` + : `

${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.' + : 'Leer sin conexión necesita abrir la app por HTTPS.'}

`) +} + async function viewHome(params) { + if (!serverReachable) return viewHomeDevice() + if (!serverOnline) return viewHomeOffline(params) const q = params.get('q') || '' const tag = params.get('tag') || '' const tagName = params.get('tagName') || tag - const mode = tag ? 'cats' : params.get('mode') || 'popular' const anyLang = params.get('any') === '1' const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc') + let mode = tag ? 'cats' : params.get('mode') || 'popular' + if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular' store.set('source', source) const showCats = mode === 'cats' && !tag app.innerHTML = `
-
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
+
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
${q && !tag ? `

Resultados para “${esc(q)}”

` : `
-
`} + ${NO_CATS.has(source) ? '' : ``}
`} ${tag ? `

Categoría: ${esc(tagName)} ✕${q ? ` · “${esc(q)}”` : ''}

` : ''}
Cargando…
` @@ -413,8 +553,8 @@ async function viewHome(params) { return } - // Paginación: MangaDex siempre; LeerCapitulo solo en categorías (sus otros listados no paginan). - const pageable = source === 'md' || !!tag + // Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan). + const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q) const seen = new Set() let page = 1 const load = async () => { @@ -446,9 +586,10 @@ async function viewManga(id, langOverride) { const newSet = new Set(newKeys.map(String)) let isFollowing = following const readMap = new Map(reads.map(r => [r.chapter_id, r])) - const isLc = manga.source === 'lc' + const isLc = manga.source !== 'md' // sitios en español sin selector de idioma const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}` + const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}` const first = chapters[0] let mainBtn = '' if (progress && chapters.some(c => c.id === progress.chapter_id)) { @@ -459,7 +600,8 @@ async function viewManga(id, langOverride) { if (first && !mainBtn) mainBtn = `${icon('book')} Empezar a leer` const menuItems = [ - isLc ? '' : `${icon('search')} Buscar en LeerCapitulo`, + manga.source === 'lc' ? '' : `${icon('search')} Buscar en LeerCapitulo`, + manga.source === 'mi' ? '' : `${icon('search')} Buscar en M440`, progress || reads.length ? `` : '', ].join('') @@ -489,7 +631,7 @@ async function viewManga(id, langOverride) {

${chapters.length ? '
    ' : isLc ? '

    Este manga no tiene capítulos.

    ' : `

    No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). - Prueba a buscarlo en LeerCapitulo. + Prueba a buscarlo en LeerCapitulo o M440. ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

    `} ` @@ -955,7 +1097,7 @@ async function viewReader(mangaId, chapterId, startPage) { let saveTimer = null const payload = () => ({ mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, - chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, + chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" }) const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } @@ -983,7 +1125,7 @@ async function viewReader(mangaId, chapterId, startPage) { - ${manga.source === 'lc' || chap.local ? '' : ''} + ${manga.source !== 'md' || chap.local ? '' : ''} ${chap.hdAvailable ? '' : chap.local && info.upscale?.available ? `` : ''} ${OFFLINE_OK ? `` : ''} @@ -1581,7 +1723,9 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `Cargando…' - const [hist, fol, lists] = await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])]) + const [hist, fol, lists] = serverReachable + ? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])]) + : [null, null, []] if (!location.hash.startsWith('#/library')) return const online = !!hist const dev = OFFLINE_OK ? offByManga() : [] @@ -1712,7 +1856,15 @@ async function viewQueues() { if (cleanup) { cleanup(); cleanup = null } const here = () => location.hash === '#/queues' if (!$('#qwrap')) app.innerHTML = '
    Cargando…
    ' - const [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')]) + let q, list + try { + [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')]) + } catch (err) { + // Sin servidor: al menos la cola de este dispositivo (que sigue por su cuenta si hay red a medias). + if (!OFFLINE_OK || !here()) throw err + q = { downloads: [], upscale: { available: true, groups: [], current: null }, clips: { queued: 0, running: 0, errors: 0 }, covers: 0, offline: err.message } + list = [] + } if (!here()) return const up = q.upscale const library = list.filter(d => !DL_ACTIVE.includes(d.status)) @@ -1733,7 +1885,36 @@ async function viewQueues() {
    ${lines}
    ${actions ? `
    ${actions}
    ` : ''} ` - const html = `

    Colas

    + // Guardado en este dispositivo: lo hace el navegador, no el servidor (offState). + const devHtml = () => { + if (!OFFLINE_OK) return '' + const items = [...(offState.running ? [offState.running] : []), ...offState.queue] + const groups = new Map() + for (const it of items) { + const g = groups.get(it.mangaId) || { ...it, chapters: [] } + g.chapters.push(it) + groups.set(it.mangaId, g) + } + const saved = Object.values(offIdx()) + const bytes = saved.reduce((n, c) => n + (c.bytes || 0), 0) + return `

    ${icon('phone')} En este dispositivo ${items.length ? `${items.length}` : ''}

    +

    Capítulos que se guardan en este ${/Mobi|Android|iPhone/i.test(navigator.userAgent) ? 'móvil' : 'navegador'} para leer sin conexión; la cola sigue mientras la app esté abierta. + ${saved.length ? `Guardados: ${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}.` : ''}

    + ${groups.size ? `
    ${[...groups.values()].map((g, i) => { + const cur = offState.running?.mangaId === g.mangaId ? offState.running : null + const rest = g.chapters.length - (cur ? 1 : 0) + return qcard({ + key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle, + pct: cur?.total ? (cur.done / cur.total) * 100 : null, + badge: cur ? icon('phone') : i + 1, + lines: `${cur ? `
    ${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}
    ` : ''} + ${rest ? `
    ${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola
    ` : ''}`, + actions: ``, + }) + }).join('')}
    ` : '

    No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.

    '}` + } + const html = q.offline ? `

    Colas

    ${esc(q.offline)}: no se pueden ver las colas del servidor.

    +
    ${devHtml()}
    ` : `

    Colas

    ${icon('download')} Descargas ${q.downloads.length ? `${q.downloads.length}` : ''}

    ${q.downloads.length ? `
    ${q.downloads.map((d, i) => qcard({ @@ -1744,6 +1925,7 @@ async function viewQueues() { actions: ``, })).join('')}
    ` : '

    No hay descargas en curso. Se añaden desde la ficha de un manga.

    '}
    + ${OFFLINE_OK ? `
    ${devHtml()}
    ` : ''}

    ${icon('sparkles')} Reescalado HD ${hdQueued ? `${hdQueued}` : ''}

    ${up.available ? '' : '

    El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).

    '} @@ -1788,6 +1970,7 @@ async function viewQueues() { if (!b) return if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada') else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido') + else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') } else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola') else if (b.dataset.del) { if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return @@ -1801,8 +1984,22 @@ async function viewQueues() { } // Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo). const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers - const t = setTimeout(() => here() && viewQueues(), busy ? 2000 : 15000) - cleanup = () => clearTimeout(t) + const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000) + // El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página. + let raf = 0 + const onDev = () => { + if (raf) return + raf = requestAnimationFrame(() => { + raf = 0 + const box = $('#devq') + if (!box || !here()) return + const tpl = document.createElement('section') + tpl.innerHTML = devHtml() + morph(box, tpl, true) + }) + } + offState.listeners.add(onDev) + cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) } } // ---------- Estadísticas ---------- diff --git a/app/public/style.css b/app/public/style.css index 330506f..2f9d7a8 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -346,3 +346,9 @@ body.noscroll { overflow: hidden; } .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; } diff --git a/app/public/sw.js b/app/public/sw.js index 18047f8..39ac123 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -13,20 +13,38 @@ self.addEventListener('activate', e => { .then(() => self.clients.claim())) }) -// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. +// 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 }) +} async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) { - try { - const res = await fetch(req) + 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 (store === true || (await c.match(req, { ignoreSearch }))) c.put(req, res.clone()) + if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone()) } return res - } catch (err) { - const hit = await caches.match(req, { ignoreSearch, cacheName }) || (fallback && await caches.match(fallback)) - if (hit) return hit - throw err - } + }) + 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 => { diff --git a/app/server.js b/app/server.js index f929a7a..72cff4b 100644 --- a/app/server.js +++ b/app/server.js @@ -1,4 +1,4 @@ -// Manga Reader: lector web con las mismas fuentes que Tanko (MangaDex y LeerCapitulo) +// Manga Reader: lector web con fuentes MangaDex, LeerCapitulo y M440 // con usuarios y historial de lectura persistente en SQLite. import http from 'node:http' import fs from 'node:fs' @@ -12,6 +12,8 @@ import { setupClips } from './clips.js' import { setupCovers } from './covers.js' import { setupLibrary } from './library.js' import { setupSecurity, safeFetch } from './security.js' +import { setupM440, MI_BASE } from './m440.js' +import { setupConnectivity } from './connectivity.js' const PORT = Number(process.env.PORT || 3352) const DATA_DIR = process.env.DATA_DIR || '/app/data' @@ -177,6 +179,9 @@ const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}` // historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual. const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}` +// Conexión a internet del servidor (app/connectivity.js). Sin ella, las fuentes fallan al instante. +const net = setupConnectivity({ httpError: (status, message) => new HttpError(status, message) }) + async function md(pathname, params = {}, ttl = 5 * 60e3) { const url = new URL(MD_API + pathname) for (const [k, v] of Object.entries(params)) { @@ -184,7 +189,8 @@ async function md(pathname, params = {}, ttl = 5 * 60e3) { else if (v !== undefined && v !== '') url.searchParams.set(k, v) } return cached(url.toString(), ttl, async () => { - const res = await fetch(url, { headers: { 'User-Agent': UA } }).catch(() => { throw new HttpError(502, 'No se pudo conectar con MangaDex') }) + net.guard() + const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30e3) }).catch(() => { net.failed(); throw new HttpError(502, 'No se pudo conectar con MangaDex') }) if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`) return res.json() }) @@ -313,17 +319,24 @@ const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver // ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ---------- // IDs: manga "lc~~", capítulo "lc~~~". Ya no hace falta navegador: // las páginas del capítulo vienen en el HTML como . -const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => { - const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()] - if (named) return named - return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)) +const ENTITIES = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›', laquo: '«', raquo: '»', iexcl: '¡', iquest: '¿', + hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', deg: '°', copy: '©', reg: '®', trade: '™', + aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', ccedil: 'ç', agrave: 'à', egrave: 'è', ograve: 'ò', + Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', Ccedil: 'Ç', +} +const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (all, e) => { + if (e[0] !== '#') return ENTITIES[e] ?? ENTITIES[e.toLowerCase()] ?? 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) : all }).trim() async function lc(pathname, ttl = 10 * 60e3, asJson = false) { return cached(`lc:${pathname}`, ttl, async () => { // Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos. for (let i = 0; ; i++) { + net.guard() const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) }) - .catch(() => null) + .catch(() => { net.failed(); return null }) if (res?.ok) return asJson ? res.json() : res.text() if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404') const down = !res || res.status >= 500 @@ -426,6 +439,13 @@ async function lcPageUrls(chapterId) { } const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl) +// ---------- M440 (app/m440.js) ---------- +const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net }) +const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl) +// Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex. +const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : 'md') +const spanishOnly = id => sourceOf(id) !== 'md' + // Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. // isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga). async function proxyImage(req, res, u, s, isCover = false, coverish = false) { @@ -435,7 +455,14 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) { if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida') if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido') if (isCover && covers.sendCached(res, u)) return - const r = await fetchImage(url) + // Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3 + // intentos con tiempo límite, para no dejar al navegador esperando para siempre. + net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos + const r = await withRetry(async () => { + const r = await fetchImage(url, AbortSignal.timeout(60e3)) + if (r.status >= 500) throw new HttpError(502, `Imagen ${r.status}`) + return r + }).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) }) if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`) if (isCover) { const buf = Buffer.from(await r.arrayBuffer()) @@ -452,8 +479,12 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) { } function fetchImage(url, signal) { - const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname) - return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } }) + net.guard() + const host = new URL(url).hostname + const isMd = /(^|\.)mangadex\.(org|network)$/.test(host) + const referer = isMd ? null : /(^|\.)m440\.in$/.test(host) ? MI_BASE + '/' : LC_BASE + '/' + return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(referer ? { Referer: referer } : {}) } }) + .catch(e => { if (e.message !== 'Host no permitido') net.failed(); throw e }) } // ---------- Descargas a MANGA_DIR//Capitulo NNNN.cbz ---------- @@ -481,8 +512,9 @@ async function downloadBuffer(url) { const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' } async function fetchMangaFull(mangaId, lang) { - return mangaId.startsWith('lc~') - ? lcManga(mangaId) + const src = sourceOf(mangaId) + return src === 'lc' ? lcManga(mangaId) + : src === 'mi' ? mi.manga(mangaId) : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } } @@ -491,7 +523,7 @@ const dlCurrent = new Map() async function downloadChapter(dl, manga, c, dir) { dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 }) - const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 0))) + const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : manga.source === 'mi' ? mi.pageUrls(c.id) : getPageUrls(c.id, false, 0))) dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length }) const entries = [] // 4 imágenes en paralelo. @@ -589,6 +621,12 @@ async function runDownload(dl) { await downloadChapter(dl, manga, c, dir) } catch (e) { if (isCancelled(dl.manga_id)) break + // Se cayó internet: no cuenta como fallo; la descarga queda en cola y sigue al volver la conexión. + if (!(await net.check())) { + dlCurrent.delete(dl.manga_id) + dlUpdate(dl.manga_id, { status: 'queued', error: null }) + return + } failed++ lastError = `Cap. ${c.num ?? '?'}: ${e.message}` console.warn('Descarga', manga.title, lastError) @@ -596,7 +634,7 @@ async function runDownload(dl) { const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n dlUpdate(dl.manga_id, { done, failed, error: lastError }) // MangaDex limita /at-home/server a 40 peticiones por minuto. - await sleep(manga.source === 'lc' ? 500 : 1600) + await sleep(manga.source === 'md' ? 1600 : manga.source === 'mi' ? 1000 : 500) } dlCurrent.delete(dl.manga_id) if (isCancelled(dl.manga_id)) { @@ -612,12 +650,15 @@ async function pumpDownloads() { pumping = true try { for (;;) { + if (!net.online()) break // se reanuda con net.whenOnline const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get() if (!dl) break requeue.delete(dl.manga_id) try { await runDownload(dl) + if (!net.online()) break } catch (e) { + if (!net.online()) { dlUpdate(dl.manga_id, { status: 'queued', error: null }); break } console.error('Descarga fallida', dl.manga_id, e) if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message }) } @@ -676,6 +717,15 @@ function downloadCover(mangaId, folder, metaCover) { const u = rawFromProxy(c) return u ? coverUrl(u, mangaId) : c || null } +// Sin conexión: la búsqueda va sobre lo descargado en el servidor. +const fold = s => String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') +function localSearch(text) { + const words = fold(text).split(/\s+/).filter(Boolean) + return db.prepare(`SELECT manga_id, title, folder, done, json_extract(meta, '$.manga.cover') meta_cover FROM downloads + WHERE done > 0 ORDER BY updated_at DESC`).all() + .filter(d => words.every(w => fold(d.title).includes(w))) + .map(d => ({ id: d.manga_id, source: sourceOf(d.manga_id), title: d.title, cover: downloadCover(d.manga_id, d.folder, d.meta_cover), sub: `${d.done} cap. descargado${d.done === 1 ? '' : 's'}` })) +} const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null) const downloadInfo = (mangaId, size = false) => { const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId)) @@ -702,8 +752,8 @@ async function readBody(req) { } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } } -const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/ -const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/ +const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+)$/ +const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+)$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/ // Manga en el historial o en seguidos del usuario (sus portadas se reescalan). const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ? @@ -809,16 +859,34 @@ async function route(req, res) { return json(res, 200, { ok: true }) } - if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'), MANGA_ID.test(q.get('m') || '') && tracked(user.id, q.get('m')), q.has('m')) + if (p === '/api/img' && method === 'GET') { + const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null + try { + return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m')) + } catch (e) { + // Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay. + if (mid && !res.headersSent && e.status !== 403) { + const dl = downloadInfo(mid) + const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg') + if (file && fs.existsSync(file)) { + res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=300' }) + return fs.createReadStream(file).pipe(res) + } + } + throw e + } + } const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang if (p === '/api/categories' && method === 'GET') { - return json(res, 200, q.get('source') === 'lc' ? await lcCategories() : await mdCategories()) + return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' ? [] : await mdCategories()) } if (p === '/api/search' && method === 'GET') { const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const tag = q.get('tag') || '' + if (!net.online()) return json(res, 200, localSearch(q.get('q') || '')) + if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) const anyLang = q.get('anyLang') === '1' const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null @@ -833,7 +901,7 @@ async function route(req, res) { } catch (e) { // Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados. const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1]) - if (!dl) throw e + if (!dl) throw net.online() ? e : new HttpError(503, 'El servidor no tiene conexión a internet y este manga no está descargado en el servidor.') const saved = JSON.parse(dl.meta) const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id)) manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` } @@ -843,16 +911,17 @@ async function route(req, res) { const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id) const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null - const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) + const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) } if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { const hdAvailable = !!upscale.hdFileOf(m[1]) const hd = hdAvailable && q.get('hd') !== '0' const local = localChapter(m[1], hd) + if (!local && !net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet y este capítulo no está descargado en el servidor.') const pages = local ? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`) - : m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1') + : m[1].startsWith('lc~') ? await lcPages(m[1]) : m[1].startsWith('mi~') ? await miPages(m[1]) : await getPages(m[1], q.get('saver') === '1') const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1]) return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable }) } @@ -879,6 +948,7 @@ async function route(req, res) { } // --- Descargas --- + if (p === '/api/status' && method === 'GET') return json(res, 200, net.status()) if (p === '/api/queues' && method === 'GET') { const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at`).all() @@ -901,7 +971,7 @@ async function route(req, res) { } if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const b = await readBody(req) - const dlLang = m[1].startsWith('lc~') ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang + const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido') @@ -930,7 +1000,7 @@ async function route(req, res) { } if (p === '/api/progress' && method === 'POST') { const b = await readBody(req) - if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(b.mangaId.startsWith('lc~') ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos') + if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(spanishOnly(b.mangaId) ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos') const page = Math.max(0, Number(b.page) | 0) const total = Math.max(0, Number(b.totalPages) | 0) const now = Date.now() @@ -963,10 +1033,12 @@ async function route(req, res) { } const security = setupSecurity({ db }) -const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) +const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const covers = setupCovers({ DATA_DIR, upscale }) -const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories }) +const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories, net }) +// Al volver internet: reanudar las descargas en cola. +net.whenOnline(() => pumpDownloads()) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) // Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex. @@ -987,7 +1059,8 @@ http.createServer(async (req, res) => { await route(req, res) } catch (err) { const status = err instanceof HttpError ? err.status : 500 - if (status >= 500) console.error(req.method, req.url, err) + // Sin internet los 503 son lo esperado: no llenar el log de trazas. + if (status >= 500 && !(status === 503 && !net.online())) console.error(req.method, req.url, err) // Los errores internos no se detallan al cliente (rutas, nombres de archivo…): solo en el log. if (!res.headersSent) json(res, status, { error: status >= 500 && status !== 502 && status !== 503 ? 'Error interno del servidor' : err.message || 'Error' }) else res.end() diff --git a/app/upscale.js b/app/upscale.js index 70d9cc8..7502a0b 100644 --- a/app/upscale.js +++ b/app/upscale.js @@ -12,6 +12,17 @@ export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN) 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 @@ -86,7 +97,10 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I const png = path.join(outDir, `${base}.png`) if (UPSCALABLE.test(name) && fs.existsSync(png)) { const webp = path.join(work, `${base}.webp`) - await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', png, '-o', 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 {