Manga Reader: lector web autoalojado con modo libro 3D y estante
This commit is contained in:
+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 }
|
||||
}
|
||||
Reference in New Issue
Block a user