Fuente M440, modo sin conexión (servidor sin internet y app sin servidor), cola del dispositivo en Colas y arreglo del reescalado de tiras altas
- M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola. - Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión. - App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente. - Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor. - Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla. - Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo. - Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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`)
|
||||
|
||||
+5
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+228
-31
@@ -24,6 +24,7 @@ const ICONS = {
|
||||
flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>',
|
||||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
wifiOff: '<path d="M2 2l20 20"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 5.2-2.8"/><path d="M14.4 10.2A10 10 0 0 1 19 12.9"/><path d="M2 8.8a15 15 0 0 1 4.2-2.7"/><path d="M10.7 5A15 15 0 0 1 22 8.8"/><path d="M12 20h.01"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
|
||||
@@ -49,7 +50,8 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
|
||||
// Iconos del menú superior (index.html).
|
||||
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
|
||||
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', md: 'MangaDex (multi-idioma)' }
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
|
||||
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
|
||||
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
|
||||
const FILTERS = {
|
||||
none: ['Sin filtro', ''],
|
||||
@@ -112,17 +114,82 @@ function toast(msg) {
|
||||
clearTimeout(toast.t)
|
||||
toast.t = setTimeout(() => (t.hidden = true), 2500)
|
||||
}
|
||||
const NO_SERVER = 'Sin conexión con el servidor'
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
// Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un
|
||||
// servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red.
|
||||
const ctrl = new AbortController()
|
||||
const watchdog = setTimeout(async () => { if (!(await pingServer())) ctrl.abort() }, 2500)
|
||||
let res
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
...opts,
|
||||
signal: ctrl.signal,
|
||||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
} catch {
|
||||
setServerReachable(false)
|
||||
throw new Error(NO_SERVER)
|
||||
} finally {
|
||||
clearTimeout(watchdog)
|
||||
}
|
||||
// Respuesta de la copia del service worker: el servidor no contestó a tiempo; comprobar el estado.
|
||||
if (res.headers.get('x-from-cache')) refreshNet()
|
||||
else if (!serverReachable) setServerReachable(true)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
|
||||
// 503: el servidor ya sabe que no hay internet. 502: una fuente falló; el servidor tarda unos segundos en confirmarlo.
|
||||
if (res.status === 503) refreshNet()
|
||||
else if (res.status === 502) setTimeout(refreshNet, 7000)
|
||||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
// ---------- Estado de la conexión ----------
|
||||
// Dos casos distintos, con su aviso arriba:
|
||||
// - serverReachable = false: no se llega al servidor (sin red, servidor caído). Se lee lo guardado en
|
||||
// este dispositivo; la portada lo muestra. Al volver, se envía el progreso pendiente.
|
||||
// - serverOnline = false: el servidor contesta pero él no tiene internet. Se lee lo descargado en el
|
||||
// servidor; la portada lo muestra.
|
||||
// Se comprueba cada 20 s (cada 10 s sin servidor) y al volver a la pestaña.
|
||||
let serverOnline = true
|
||||
let serverReachable = true
|
||||
let netChecked = false
|
||||
const isHome = () => !(location.hash.slice(1).split('?')[0] || '/').split('/').filter(Boolean).length
|
||||
async function pingServer() {
|
||||
const r = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) }).catch(() => null)
|
||||
return r ? (r.ok ? await r.json().catch(() => ({ online: true })) : { online: true }) : null
|
||||
}
|
||||
function netBanner() {
|
||||
let b = $('#netbanner')
|
||||
const msg = !serverReachable
|
||||
? `${icon('wifiOff')}<span><b>${NO_SERVER}.</b> ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.</span>`
|
||||
: !serverOnline ? `${icon('wifiOff')}<span><b>El servidor no tiene internet.</b> Puedes leer lo descargado en el servidor; se reconecta solo.</span>` : ''
|
||||
if (!msg) return b?.remove()
|
||||
if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) }
|
||||
if (b.innerHTML !== msg) b.innerHTML = msg
|
||||
}
|
||||
function setServerReachable(ok) {
|
||||
if (ok === serverReachable) return
|
||||
serverReachable = ok
|
||||
netBanner()
|
||||
if (ok) { flushProgress(); refreshNet() }
|
||||
if (isHome()) router()
|
||||
}
|
||||
async function refreshNet() {
|
||||
const st = await pingServer()
|
||||
netChecked = true
|
||||
if (!st) return setServerReachable(false)
|
||||
const changed = !serverReachable || st.online !== serverOnline
|
||||
serverOnline = st.online !== false
|
||||
if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar
|
||||
netBanner()
|
||||
if (changed && isHome()) router()
|
||||
}
|
||||
setInterval(() => { if (serverReachable) refreshNet() }, 20e3)
|
||||
setInterval(() => { if (!serverReachable) refreshNet() }, 10e3)
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() })
|
||||
function timeAgo(ts) {
|
||||
const s = (Date.now() - ts) / 1000
|
||||
if (s < 60) return 'hace un momento'
|
||||
@@ -140,7 +207,7 @@ const OFF_CACHE = 'offline-v1'
|
||||
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {})
|
||||
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
|
||||
const offHas = id => !!offIdx()[id]
|
||||
const offState = { running: null, queue: [], listeners: new Set() }
|
||||
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
|
||||
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
|
||||
// Capítulos guardados agrupados por manga.
|
||||
function offByManga() {
|
||||
@@ -165,19 +232,40 @@ async function offRun() {
|
||||
const it = offState.queue.shift()
|
||||
offState.running = { ...it, done: 0, total: 0 }
|
||||
offEmit()
|
||||
try { await offSaveChapter(it) } catch (err) { toast(`No se pudo guardar ${it.label}: ${err.message}`) }
|
||||
try { await offSaveChapter(it) } catch (err) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) }
|
||||
offState.cancelled.delete(it.chapterId)
|
||||
}
|
||||
offState.running = null
|
||||
offEmit()
|
||||
}
|
||||
// Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en
|
||||
// curso se interrumpe y se borra lo que ya se había guardado de él.
|
||||
function offCancel(mangaId = null) {
|
||||
offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId)
|
||||
if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) {
|
||||
offState.cancelled.add(offState.running.chapterId)
|
||||
offState.abort?.abort() // corta las páginas que se estén bajando
|
||||
}
|
||||
offEmit()
|
||||
}
|
||||
async function offSaveChapter(it) {
|
||||
const cache = await caches.open(OFF_CACHE)
|
||||
const saved = []
|
||||
offState.abort = new AbortController()
|
||||
try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) {
|
||||
await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias
|
||||
throw err
|
||||
}
|
||||
}
|
||||
async function offSaveInto(cache, it, saved, signal) {
|
||||
const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') }
|
||||
const hd = store.get('readerSettings', {}).hd !== false
|
||||
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
|
||||
const res = await fetch(chapUrl)
|
||||
const res = await fetch(chapUrl, { signal })
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`)
|
||||
const data = await res.clone().json()
|
||||
await cache.put(chapUrl, res)
|
||||
saved.push(chapUrl)
|
||||
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
|
||||
const mres = await fetch(mUrl).catch(() => null)
|
||||
if (mres?.ok) await cache.put(mUrl, mres)
|
||||
@@ -186,17 +274,33 @@ async function offSaveChapter(it) {
|
||||
let bytes = 0, i = 0
|
||||
const worker = async () => {
|
||||
while (i < data.pages.length) {
|
||||
stop()
|
||||
const u = data.pages[i++]
|
||||
const r = await fetch(u)
|
||||
if (!r.ok) throw new Error(`página ${r.status}`)
|
||||
const blob = await r.blob()
|
||||
// Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite.
|
||||
let blob, type
|
||||
for (let t = 1; ; t++) {
|
||||
try {
|
||||
const r = await fetch(u, { signal: AbortSignal.any([signal, AbortSignal.timeout(90e3)]) })
|
||||
if (!r.ok) throw new Error(`página ${r.status}`)
|
||||
type = r.headers.get('Content-Type')
|
||||
blob = await r.blob()
|
||||
break
|
||||
} catch (err) {
|
||||
stop()
|
||||
if (t >= 3) throw err
|
||||
await new Promise(res => setTimeout(res, 2000 * t))
|
||||
}
|
||||
}
|
||||
bytes += blob.size
|
||||
await cache.put(u, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || 'image/jpeg' } }))
|
||||
stop()
|
||||
await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } }))
|
||||
saved.push(u)
|
||||
offState.running.done++
|
||||
offEmit()
|
||||
}
|
||||
}
|
||||
await Promise.all([worker(), worker(), worker()])
|
||||
stop()
|
||||
const idx = offIdx()
|
||||
idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() }
|
||||
store.set('offlineChapters', idx)
|
||||
@@ -246,7 +350,7 @@ async function router() {
|
||||
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
|
||||
|
||||
if (!me) {
|
||||
const r = await api('/api/me').catch(() => ({}))
|
||||
const [r] = await Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()])
|
||||
me = r.user
|
||||
me && (me.signup = r.signup)
|
||||
if (!me) return viewAuth(r.signup)
|
||||
@@ -301,14 +405,14 @@ function viewAuth(signup) {
|
||||
// ---------- Inicio ----------
|
||||
function mangaCard(m) {
|
||||
return `<a class="card" href="#/manga/${m.id}">
|
||||
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="">
|
||||
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="" data-fallback>
|
||||
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
|
||||
}
|
||||
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 `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}">
|
||||
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy">
|
||||
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy" data-fallback>
|
||||
<div class="info"><b>${esc(h.manga_title)}</b>
|
||||
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
|
||||
${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''}
|
||||
@@ -363,25 +467,61 @@ async function loadRecs(refresh = false) {
|
||||
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
|
||||
$('#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 = `
|
||||
<section id="cont"></section>
|
||||
<h2>${icon('download')} Descargado en el servidor</h2>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
|
||||
<div id="results" class="loading">Cargando…</div>`
|
||||
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 = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
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 = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
|
||||
? `<div class="grid">${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('')}</div>`
|
||||
: `<p class="muted">${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.'}</p>`)
|
||||
}
|
||||
|
||||
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 = `
|
||||
<section id="cont"></section>
|
||||
<section id="recs"></section>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
|
||||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||||
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
|
||||
<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button></div>`}
|
||||
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
|
||||
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
|
||||
<div id="results" class="loading">Cargando…</div>
|
||||
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>${icon('more')} Cargar más</button></div>`
|
||||
@@ -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 = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||||
|
||||
const menuItems = [
|
||||
isLc ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
@@ -489,7 +631,7 @@ async function viewManga(id, langOverride) {
|
||||
</div>
|
||||
</div>
|
||||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||||
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
Prueba a buscarlo en <a class="tag" href="${altSearch}">LeerCapitulo</a> o <a class="tag" href="${altSearchMi}">M440</a>.
|
||||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
||||
<div id="selbar" hidden></div>`
|
||||
|
||||
@@ -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) {
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||||
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
@@ -1581,7 +1723,9 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
|
||||
async function viewLibrary(params) {
|
||||
const tab = params.get('tab') || 'reading'
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
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 = '<div class="loading">Cargando…</div>'
|
||||
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() {
|
||||
<div class="sub">${lines}</div>
|
||||
${actions ? `<div class="qact">${actions}</div>` : ''}
|
||||
</div>`
|
||||
const html = `<h1>Colas</h1>
|
||||
// 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 `<h2>${icon('phone')} En este dispositivo ${items.length ? `<span class="badge">${items.length}</span>` : ''}</h2>
|
||||
<p class="muted small">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: <a href="#/library?tab=device">${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}</a>.` : ''}</p>
|
||||
${groups.size ? `<div class="grid qgrid">${[...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 ? `<div>${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}</div>` : ''}
|
||||
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
|
||||
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
|
||||
})
|
||||
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.</p>'}`
|
||||
}
|
||||
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
|
||||
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
|
||||
<section class="qsec">
|
||||
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
|
||||
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
|
||||
@@ -1744,6 +1925,7 @@ async function viewQueues() {
|
||||
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
|
||||
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
|
||||
</section>
|
||||
${OFFLINE_OK ? `<section class="qsec" id="devq">${devHtml()}</section>` : ''}
|
||||
<section class="qsec">
|
||||
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
|
||||
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+27
-9
@@ -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 => {
|
||||
|
||||
+99
-26
@@ -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~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
|
||||
// las páginas del capítulo vienen en el HTML como <img data-src>.
|
||||
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/<Título>/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()
|
||||
|
||||
+15
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user