// Fuente M440.in (MangasIn, tema MMRCMS), en español. // IDs: manga "mi~", capítulo "mi~~". // - Búsqueda (/search?q=) y últimas actualizaciones (/lasted?p=) son JSON; los populares salen de la portada. // - La lista de capítulos viene cifrada en la ficha (AES de CryptoJS con contraseña). La contraseña se // saca de un script ofuscado con m440-key.mjs, en un proceso aislado, y se guarda hasta que deje de servir. // - Las páginas del capítulo vienen en el HTML como (a veces en base64). // - El listado por categorías está detrás del desafío de Cloudflare: esta fuente no tiene categorías. import crypto from 'node:crypto' import path from 'node:path' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' export const MI_BASE = 'https://m440.in' const KEY_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'm440-key.mjs') // httpError(status, message): crea el error HTTP del servidor (se pasa como función porque la clase se // declara más abajo en server.js). export function setupM440({ cached, httpError, coverUrl, decode, BROWSER_UA, net }) { // Como mucho una petición de HTML/JSON cada 700 ms (el sitio limita a ~1/s). let gate = Promise.resolve() const throttle = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 700))) gate = p return gate } async function raw(pathname, asJson) { for (let i = 0; ; i++) { net.guard() await throttle() const res = await fetch(MI_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es', Referer: MI_BASE + '/', ...(asJson ? { 'X-Requested-With': 'XMLHttpRequest' } : {}) }, signal: AbortSignal.timeout(30e3), }).catch(() => { net.failed(); return null }) if (res?.ok) return asJson ? res.json() : res.text() if (res?.status === 404) throw httpError(404, 'M440 respondió 404') const down = !res || res.status >= 500 if (!down || i >= 2) throw httpError(502, down ? `M440 no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `M440 respondió ${res.status}`) await new Promise(r => setTimeout(r, 1500 * (i + 1))) } } const get = (pathname, ttl = 10 * 60e3, asJson = false) => cached(`mi:${pathname}`, ttl, () => raw(pathname, asJson)) const coverOf = slug => `${MI_BASE}/uploads/manga/${slug}/cover/cover_250x350.jpg` const cover = (src, id) => coverUrl(new URL(src, MI_BASE).toString(), id) const slugOf = href => /\/manga\/([a-z0-9._-]+)\/?$/i.exec(href || '')?.[1]?.toLowerCase() // ---------- Listados ---------- async function search(q, mode, page = 1) { if (q) { if (Buffer.byteLength(q.trim()) < 3) throw httpError(400, 'En M440 la búsqueda necesita al menos 3 letras') const list = await get(`/search?q=${encodeURIComponent(q.trim())}`, 10 * 60e3, true) return (Array.isArray(list) ? list : []).filter(e => /^[a-z0-9._-]+$/i.test(e.data || '')).map(e => { const id = `mi~${e.data.toLowerCase()}` const img = /]+src="([^"]+)"/.exec(e.label || '')?.[1] const status = / /^[a-z0-9._-]+$/i.test(e.manga_slug || '')).map(e => { const id = `mi~${e.manga_slug.toLowerCase()}` const ch = e.chapters?.[0]?.chapter_number return { id, source: 'mi', title: decode(e.manga_name), cover: cover(coverOf(e.manga_slug), id), sub: ch ? `Cap. ${ch}` : undefined } }) } // Populares: "Actualizaciones populares" de la portada (no pagina). const html = await get('/') const out = [] const seen = new Set() const re = /class="manga-name">\s*]*>([^<]+)<\/a>[\s\S]*?<\/i>\s*([^<]*)/g for (const m of html.matchAll(re)) { const slug = slugOf(m[1]) if (!slug || seen.has(slug)) continue seen.add(slug) const id = `mi~${slug}` out.push({ id, source: 'mi', title: decode(m[2]), cover: cover(m[3], id), sub: /^#\s*([0-9.]+)/.exec(decode(m[4]))?.[1] ? `Cap. ${/^#\s*([0-9.]+)/.exec(decode(m[4]))[1]}` : undefined }) } return out } // ---------- Lista de capítulos cifrada ---------- let key = null let keyPromise = null function fetchKey() { keyPromise ??= (async () => { const script = await raw('/js/ads2.js', false) const out = await new Promise((resolve, reject) => { const child = spawn(process.execPath, ['--permission', `--allow-fs-read=${KEY_SCRIPT}`, KEY_SCRIPT], { stdio: ['pipe', 'pipe', 'ignore'], timeout: 15e3 }) let buf = '' child.stdout.on('data', d => { if (buf.length < 10e3) buf += d }) child.on('error', reject) child.on('close', () => resolve(buf)) child.stdin.end(script) }) const k = JSON.parse(out || '{}').key if (!k) throw httpError(502, 'M440 cambió su forma de cifrar los capítulos (no se encontró la clave)') console.log('M440: clave de capítulos actualizada') return k })().finally(() => { keyPromise = null }) return keyPromise } // AES-256-CBC con clave/IV derivados como OpenSSL EVP_BytesToKey (MD5), igual que CryptoJS con contraseña. function decrypt({ ct, s }, pass) { const salt = Buffer.from(s, 'hex') let d = Buffer.alloc(0) let prev = Buffer.alloc(0) while (d.length < 48) { prev = crypto.createHash('md5').update(Buffer.concat([prev, Buffer.from(pass, 'utf8'), salt])).digest() d = Buffer.concat([d, prev]) } const dc = crypto.createDecipheriv('aes-256-cbc', d.subarray(0, 32), d.subarray(32, 48)) let v = JSON.parse(Buffer.concat([dc.update(Buffer.from(ct, 'base64')), dc.final()]).toString('utf8')) if (typeof v === 'string') v = JSON.parse(v) if (!Array.isArray(v)) throw new Error('formato inesperado') return v } async function chapterList(html) { const lit = /=\s*("\{\\"ct\\"(?:[^"\\]|\\.)*")\s*;/.exec(html)?.[1] if (!lit) return [] const data = JSON.parse(JSON.parse(lit)) if (key) { try { return decrypt(data, key) } catch {} } key = await fetchKey() try { return decrypt(data, key) } catch { throw httpError(502, 'No se pudo descifrar la lista de capítulos de M440') } } // ---------- Ficha ---------- async function manga(id) { const slug = id.slice(3) const html = await get(`/manga/${slug}`) const dd = label => { const m = new RegExp(`
${label}
\\s*]*>([\\s\\S]*?)`).exec(html) return m ? m[1] : '' } const links = s => [...s.matchAll(/
]*>([^<]+)<\/a>/g)].map(m => decode(m[1])).filter(Boolean) const well = /
\s*
[\s\S]*?Sinopsis[\s\S]*?<\/h5>([\s\S]*?)<\/div>/.exec(html)?.[1] || '' // Los saltos del código fuente no cuentan: solo
(línea) y

(párrafo). const description = decode(well.replace(/\s+/g, ' ').replace(//gi, '\n').replace(/<\/p>/gi, '\n\n')) .split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim() const list = await chapterList(html) const chapters = list.filter(c => /^[a-z0-9._-]+$/i.test(c.slug || '')).map(c => { const num = String(c.number ?? '').trim() || null const name = decode(c.name) return { id: `${id}~${c.slug.toLowerCase()}`, num, volume: c.volume && c.volume !== '0' ? String(c.volume) : null, title: name.replace(/^Cap[ií]tulo\s*[0-9.]*\s*[:.-]?\s*/i, '').trim(), pages: null, group: (c.created_at || '').slice(0, 10).split('-').reverse().join('/'), publishAt: c.created_at || null, } }) chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) const status = decode(/manga-list\/tag\/[^"]*"[^>]*title="([^"]+)"/.exec(html)?.[1] || '') const year = parseInt(decode(dd('Fecha de Publicaci[oó]n')), 10) return { manga: { id, source: 'mi', title: decode(/

]*id="title"[^>]*>([^<]+)/.exec(html)?.[1]) || slug, description, status: status || null, type: decode(dd('Tipo')) || null, year: year > 1900 && year < 2100 ? year : null, tags: [...links(dd('Demogr[aá]fico')), ...links(dd('G[eé]nero'))].slice(0, 12).map(name => ({ id: null, name })), authors: [...new Set([...links(dd('Autor')), ...links(dd('Artista'))])], languages: ['es'], cover: cover(/class="boxed"[\s\S]*?]*class="img-responsive"[^>]*data-src=['"]\s*([^'"]+?)\s*['"]/g)].map(m => { let u = decode(m[1]) if (!/^https?:\/\//.test(u)) { try { u = decodeURIComponent(Buffer.from(u.split('://').pop(), 'base64').toString('utf8')) } catch { return null } } return /^https?:\/\//.test(u) ? u : null }).filter(Boolean) if (!pages.length) throw httpError(502, 'No se encontraron páginas en M440') return pages } return { search, manga, pageUrls } }