Manga Reader: lector web autoalojado con modo libro 3D y estante

This commit is contained in:
Nino
2026-11-06 09:42:34 -06:00
commit e40127265f
45 changed files with 12822 additions and 0 deletions
+190
View File
@@ -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 }
}