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
+603
View File
@@ -0,0 +1,603 @@
// Libros de texto (EPUB). El EPUB se convierte aquí en una lista cerrada de bloques: al navegador nunca llega
// HTML del libro, y es él quien maqueta los bloques en páginas según su pantalla (ver maquetado en app.js).
// bloque: { k: 'p' | 'q' | 'h' | 'hr' | 'img', l?: nivel de título, n?: imagen, c?: centrado, v?: verso,
// r?: [[texto, estilo]] } estilo: 1 cursiva, 2 negrita (sumables)
// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. Orígenes:
// - Project Gutenberg, con su catálogo OPDS. IDs: libro "gb~<n>", capítulo "gb~<n>~<i>".
// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~<título en base64url>", "ws~<título>~<i>".
// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~<i>".
// En <DATA_DIR>/books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido.
import fs from 'node:fs'
import zlib from 'node:zlib'
import crypto from 'node:crypto'
import path from 'node:path'
import { readZipIndex, readCbzEntry, imageType } from './cbz.js'
export const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
const GUTENBERG = 'https://www.gutenberg.org'
const WS_API = 'https://es.wikisource.org/w/api.php'
const WS_EXPORT = 'https://ws-export.wmcloud.org/'
const WS_MIN_WORDS = 2000 // sin subpáginas, lo que no llega a esto es un poema o un texto suelto, no un libro
const OPDS_PAGE = 25
const GB_NAME = 'Project Gutenberg'
const WS_NAME = 'Wikisource'
// El buscador de Gutenberg filtra por el nombre del idioma, no por el código.
const LANG_NAMES = { es: 'spanish', en: 'english', fr: 'french', pt: 'portuguese', it: 'italian', de: 'german', ru: 'russian', ja: 'japanese', zh: 'chinese', ko: 'korean', ca: 'catalan', nl: 'dutch', pl: 'polish' }
const PARSER_VERSION = 3
const MAX_EPUB_BYTES = 100 * 1024 * 1024
const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen
const MAX_UNZIPPED_TOTAL = 256 * 1024 * 1024 // todo lo que se lee del libro: lo que pase de aquí es una ZIP bomb
const MAX_UPLOADS_PER_USER = 500
const BOOK_IMAGE = /\.(jpe?g|png|gif|webp)$/i
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', iexcl: '¡', iquest: '¿', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', agrave: 'à', egrave: 'è', igrave: 'ì', ograve: 'ò', ugrave: 'ù', ccedil: 'ç', shy: '', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', copy: '©', deg: '°', ordm: 'º', ordf: 'ª', sect: '§', para: '¶', middot: '·', dagger: '†', Dagger: '‡', bull: '•', times: '×' }
const entities = s => s.replace(/&(#x?[0-9a-f]+|[a-z0-9]+);/gi, (all, e) => {
if (e[0] !== '#') return ENTITIES[e] ?? 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) : ''
})
const attr = (attrs, name) => {
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs)
return m ? entities(m[1] ?? m[2]) : null
}
// Ruta dentro del ZIP de un enlace relativo a un archivo del libro.
function resolveHref(base, href) {
let rel = href.split('#')[0]
try { rel = decodeURIComponent(rel) } catch {}
return path.posix.normalize(path.posix.join(path.posix.dirname(base), rel)).replace(/^\.\//, '')
}
// ---------- XHTML a bloques ----------
const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav'])
const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'])
const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr'])
const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g
// onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo.
function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) {
let skip = 0, skipTag = null
let italic = 0, bold = 0, quote = 0, center = 0, verse = 0
const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas
let runs = [], heading = 0
const flush = () => {
// Espacios del código fuente fuera; los saltos de línea reales (<br>) son \n.
const out = []
let atStart = true
for (const [text, style] of runs) {
let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un <br>
if (atStart) t = t.replace(/^ +/, '')
if (!t) continue
atStart = t.endsWith('\n')
const last = out.at(-1)
if (last && last[1] === style) last[0] += t
else out.push([t, style])
}
while (out.length) {
const last = out.at(-1)
last[0] = last[0].replace(/[ \n]+$/, '')
if (last[0]) break
out.pop()
}
runs = []
if (out.length && out.some(([t]) => t.trim())) {
const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out }
if (center || heading) b.c = 1
if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1
onBlock(b)
}
}
const body = xml.search(/<body[\s>]/i)
let pos = body >= 0 ? body : 0
TOKEN.lastIndex = pos
for (let m; (m = TOKEN.exec(xml));) {
if (!skip && m.index > pos) {
const text = entities(xml.slice(pos, m.index))
if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)])
}
pos = TOKEN.lastIndex
const tag = m[2]?.toLowerCase().replace(/^.*:/, '')
if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro)
continue
}
const closing = m[1] === '/'
const rawAttrs = m[3] || ''
const selfClosed = /\/\s*$/.test(rawAttrs)
if (skip) {
if (tag === skipTag) {
if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++
}
continue
}
if (closing) {
if (BLOCK.has(tag)) {
flush()
const top = stack.findLastIndex(s => s.tag === tag)
if (top >= 0) {
for (const s of stack.splice(top)) {
quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold
if (s.heading) heading = 0
}
}
} else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1)
else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1)
continue
}
const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null)
if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ }
const cls = (attr(rawAttrs, 'class') || '').toLowerCase()
const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase()
// Números de página de la edición impresa: ruido entre el texto.
if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) {
if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag }
continue
}
if (tag === 'br') { runs.push(['\n', 0]); continue }
if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue }
if (tag === 'img' || tag === 'image') {
const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href')
const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null
if (n != null) { flush(); onBlock({ k: 'img', n }) }
continue
}
if (BLOCK.has(tag)) {
flush()
const style = attr(rawAttrs, 'style') || ''
const s = {
tag,
quote: tag === 'blockquote' ? 1 : 0,
center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0,
verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0,
italic: 0, bold: 0, heading: 0,
}
if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 }
quote += s.quote; center += s.center; verse += s.verse
if (!selfClosed) stack.push(s)
else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 }
continue
}
if (selfClosed) continue
if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++
else if (tag === 'b' || tag === 'strong') bold++
else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) }
}
if (!skip && pos < xml.length) {
const text = entities(xml.slice(pos).replace(/<[^>]*$/, ''))
if (text.trim()) runs.push([text, 0])
}
flush()
}
// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera.
function imageSize(b) {
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
for (let p = 2; p + 9 < b.length;) {
if (b[p] !== 0xff) { p++; continue }
const m = b[p + 1]
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) }
if (m === 0xff) { p++; continue }
p += 2 + b.readUInt16BE(p + 2)
}
}
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
const kind = b.toString('latin1', 12, 16)
if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } }
}
return {}
}
// ---------- EPUB ----------
// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx).
function readToc(read, opf, manifest) {
const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props))
if (navItem) {
const xml = read(navItem.path)
const nav = /<nav\b[^>]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1]
if (nav) {
const out = []
for (const m of nav.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
const href = attr(m[1], 'href')
if (!href) continue
out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) })
}
if (out.length) return out
}
}
const spineToc = /<spine\b[^>]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type))
if (!ncx) return []
const xml = read(ncx.path)
const out = []
// Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan.
for (const m of xml.matchAll(/<navPoint\b[^>]*>\s*<navLabel\b[^>]*>\s*<text\b[^>]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*<content\b([^>]*)\/?>/gi)) {
const src = attr(m[2], 'src')
if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) })
}
return out
}
const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
export function parseEpub(file) {
const index = readZipIndex(file)
const entries = new Map(index.map(e => [e.name, e]))
if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
let unzipped = 0
const readBuf = name => {
const e = entries.get(name)
if (!e) throw new Error(`Falta ${name} en el EPUB`)
if (e.unzipped > MAX_UNZIPPED_ENTRY) throw new Error('Archivo demasiado grande dentro del EPUB')
const buf = readCbzEntry(file, e, MAX_UNZIPPED_ENTRY)
unzipped += buf.length // lo real: la cabecera del ZIP puede mentir
if (unzipped > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
return buf
}
const read = name => readBuf(name).toString('utf8').replace(/^/, '')
const container = read('META-INF/container.xml')
const opfPath = attr(/<rootfile\b([^>]*)>/i.exec(container)?.[1] || '', 'full-path')
if (!opfPath) throw new Error('EPUB sin paquete')
const opf = read(opfPath)
const dc = name => clean(new RegExp(`<dc:${name}\\b[^>]*>([\\s\\S]*?)</dc:${name}>`, 'i').exec(opf)?.[1] || '')
const manifest = new Map()
for (const m of opf.matchAll(/<item\b([^>]*)>/gi)) {
const id = attr(m[1], 'id'), href = attr(m[1], 'href')
if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' })
}
const spine = [...opf.matchAll(/<itemref\b([^>]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type))
if (!spine.length) throw new Error('EPUB sin contenido')
let toc = []
try { toc = readToc(read, opf, manifest) } catch {}
const images = []
const imageIdx = new Map()
const imageOf = name => {
const e = entries.get(name)
if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null
if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) }
return imageIdx.get(name)
}
// Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene.
const targets = new Map()
for (const t of toc) {
if (!targets.has(t.file)) targets.set(t.file, new Map())
targets.get(t.file).set(t.anchor, t.title)
}
const chapters = []
const start = title => chapters.push({ title, blocks: [] })
for (const item of spine) {
const here = targets.get(item.path)
if (here?.has(null) || (!toc.length)) start(here?.get(null) || '')
if (!chapters.length) start('')
xhtmlToBlocks(read(item.path), item.path, imageOf,
b => chapters.at(-1).blocks.push(b),
(id, flush) => {
if (!here?.has(id)) return false
flush()
start(here.get(id))
here.delete(id) // un id repetido no abre otro capítulo
return true
})
}
// Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente.
const merged = []
let carry = null
for (const c of chapters) {
if (!c.blocks.length) continue
const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks
const title = c.title || carry?.title || ''
carry = null
if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue }
merged.push({ title, blocks })
}
if (carry) merged.push(carry)
const out = merged
if (!out.length) throw new Error('EPUB sin texto')
// Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un <meta>, que a veces no apunta a una imagen.
const coverId = /<meta\b(?=[^>]*\bname\s*=\s*["']cover["'])[^>]*\bcontent\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
const coverItem = [...manifest.values()].find(it => /\bcover-image\b/.test(it.props)) || manifest.get(coverId)
const cover = coverItem ? imageOf(coverItem.path) : null
out.forEach((c, i) => { if (!c.title) c.title = `Sección ${i + 1}` })
// El navegador maqueta el libro entero sin cargar las imágenes: necesita saber su tamaño.
for (const c of out) {
for (const b of c.blocks) {
if (b.k !== 'img') continue
try { Object.assign(b, imageSize(readBuf(images[b.n]))) } catch {}
}
}
const year = parseInt(dc('date'), 10)
return {
version: PARSER_VERSION,
title: dc('title'), authors: [...opf.matchAll(/<dc:creator\b[^>]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean),
language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null,
images, cover, chapters: out,
}
}
// ---------- Fuente y caché ----------
// Portada generada para lo que no trae una: el título sobre un color que sale del propio título.
function titleCover(title, author) {
const esc = t => String(t).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]))
const wrap = (t, width, max) => {
const lines = []
let cur = ''
for (const w of t.split(/\s+/)) {
if (cur && cur.length + w.length + 1 > width) { lines.push(cur); cur = w } else cur = cur ? `${cur} ${w}` : w
}
if (cur) lines.push(cur)
if (lines.length > max) { lines.length = max; lines[max - 1] += '…' }
return lines.map(l => l.slice(0, width + 6))
}
const hue = [...String(title)].reduce((n, c) => (n * 31 + c.codePointAt(0)) % 360, 7)
const lines = wrap(title, 15, 6)
const by = author ? wrap(author, 22, 2) : []
const text = (t, y, size, weight, fill) => `<text x="200" y="${y}" font-family="Georgia,'Times New Roman',serif" font-size="${size}" font-weight="${weight}" text-anchor="middle" fill="${fill}">${esc(t)}</text>`
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600" width="400" height="600"><rect width="400" height="600" fill="hsl(${hue} 38% 24%)"/><rect x="18" y="18" width="364" height="564" fill="none" stroke="hsl(${hue} 45% 72%)" stroke-width="2"/>${lines.map((l, i) => text(l, 150 + i * 48, 38, 'bold', '#f6f1e6')).join('')}${by.map((l, i) => text(l, 500 + i * 30, 23, 'normal', `hsl(${hue} 45% 80%)`)).join('')}</svg>`
}
export function setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) {
const DIR = path.join(DATA_DIR, 'books')
fs.mkdirSync(DIR, { recursive: true })
db.exec(`CREATE TABLE IF NOT EXISTS user_books (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
authors TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_books_user ON user_books(user_id, created_at);`)
// Un título de Wikisource puede ser largo para nombre de archivo.
const fileKey = id => (id.startsWith('ws~') ? `ws-${crypto.createHash('sha256').update(id).digest('hex').slice(0, 32)}` : id.replace('~', '-'))
const epubFile = id => path.join(DIR, `${fileKey(id)}.epub`)
const jsonFile = id => path.join(DIR, `${fileKey(id)}.json`)
// Hasta 3 intentos: un fallo de red o un 5xx de Gutenberg suele durar poco. El motivo real queda en el log.
async function fetchSource(url, timeout, name) {
for (let i = 1; ; i++) {
net.guard()
let why
try {
const r = await safeFetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(timeout) })
if (r.ok) return r
if (r.status === 404) throw new HttpError(404, `Ese libro no está en ${name}`)
if (r.status < 500 && r.status !== 429) throw new HttpError(502, `${name} respondió ${r.status}`)
why = `HTTP ${r.status}`
} catch (e) {
if (e instanceof HttpError) throw e
why = e.cause?.code || e.cause?.message || e.message
net.failed()
}
console.warn(`${name} ${url}: intento ${i}: ${why}`)
if (i >= 3) throw new HttpError(502, `${name} no responde ahora mismo. Prueba en un rato.`)
await new Promise(r => setTimeout(r, 800 * i))
}
}
const coverOf = (n, id) => coverUrl(`${GUTENBERG}/cache/epub/${n}/pg${n}.cover.medium.jpg`, id)
// "Cervantes Saavedra, Miguel de" -> "Miguel de Cervantes Saavedra"
const authorName = a => a.split(', ').reverse().join(' ')
const entryText = (xml, tag) => clean(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] || '')
function parseFeed(xml) {
const out = []
for (const [entry] of xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)) {
const n = /<id>[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1]
if (!n) continue // "Ordenar por…" y otras entradas de navegación
const id = `gb~${n}`
// El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)".
const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '')
out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined })
}
return out
}
// lang del usuario ("es-la") a idioma del catálogo ("spanish").
async function search(q, mode, page, lang) {
const name = LANG_NAMES[String(lang || 'es').slice(0, 2)]
const text = q.trim().replace(/\s+/g, ' ')
const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' })
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1
if (start > 1) qs.set('start_index', String(start))
return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await fetchSource(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3, GB_NAME)).text()))
}
// ---------- Wikisource ----------
const wsId = title => `ws~${Buffer.from(title).toString('base64url')}`
const wsTitle = id => Buffer.from(id.slice(3), 'base64url').toString('utf8')
const wsCard = root => {
const id = wsId(root)
// "Realidad (Pérez Galdós)": la obra y, entre paréntesis, de quién es o de qué año.
const m = /^(.+?)\s*\(([^()]+)\)$/.exec(root)
return { id, source: 'ws', title: m ? m[1] : root, cover: `/api/book/${id}/cover`, sub: m ? m[2] : undefined }
}
const wsQuery = async params => {
const qs = new URLSearchParams({ action: 'query', format: 'json', ...params })
return (await fetchSource(`${WS_API}?${qs}`, 20e3, WS_NAME)).json()
}
async function wsList(q, mode) {
const text = q.trim()
if (text) {
const d = await wsQuery({ list: 'search', srsearch: text, srnamespace: '0', srlimit: '200', srprop: 'wordcount' })
// Una novela es una página índice corta con muchas subpáginas ("Obra/12"): cuenta la obra, una vez.
const works = new Map()
for (const r of d.query?.search || []) {
const root = r.title.split('/')[0]
const w = works.get(root) || { root, chapters: 0, words: 0 }
if (r.title.includes('/')) w.chapters++
else w.words = r.wordcount
works.set(root, w)
}
return [...works.values()].filter(w => w.chapters || w.words >= WS_MIN_WORDS).map(w => w.root)
}
const d = await wsQuery({ list: 'categorymembers', cmtitle: 'Categoría:Novelas', cmnamespace: '0', cmlimit: '500', ...(mode === 'latest' ? { cmsort: 'timestamp', cmdir: 'desc' } : {}) })
return (d.query?.categorymembers || []).map(m => m.title)
}
async function wsSearch(q, mode, page) {
const list = await cached(`ws:${mode === 'latest' ? 'latest' : 'popular'}:${q.trim().toLowerCase()}`, 30 * 60e3, () => wsList(q, mode))
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE
return list.filter(t => Buffer.byteLength(t) <= 280).slice(start, start + OPDS_PAGE).map(wsCard)
}
// ---------- Libros subidos ----------
const owns = (userId, id) => !!db.prepare('SELECT 1 FROM user_books WHERE id = ? AND user_id = ?').get(id, userId)
const ubCard = r => ({ id: r.id, source: 'ub', title: r.title, cover: `/api/book/${r.id}/cover`, sub: JSON.parse(r.authors).join(', ') || undefined })
function ubList(userId, q) {
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
const words = fold(q).split(/\s+/).filter(Boolean)
return db.prepare('SELECT * FROM user_books WHERE user_id = ? ORDER BY created_at DESC').all(userId)
.filter(r => words.every(w => fold(`${r.title} ${r.authors}`).includes(w))).map(ubCard)
}
async function upload(req, user) {
if (db.prepare('SELECT COUNT(*) n FROM user_books WHERE user_id = ?').get(user.id).n >= MAX_UPLOADS_PER_USER) throw new HttpError(400, `Se admiten hasta ${MAX_UPLOADS_PER_USER} libros por usuario`)
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > MAX_EPUB_BYTES) throw new HttpError(413, 'El libro es demasiado grande (máximo 100 MB)')
chunks.push(c)
}
if (!size) throw new HttpError(400, 'No se recibió ningún archivo')
const data = Buffer.concat(chunks)
// El mismo archivo subido dos veces por la misma persona es el mismo libro.
const id = `ub~${crypto.createHash('sha256').update(`${user.id}:`).update(data).digest('hex').slice(0, 32)}`
const tmp = path.join(DIR, `up-${crypto.randomBytes(8).toString('hex')}.part`)
fs.writeFileSync(tmp, data)
let book
try { book = parseEpub(tmp) } catch (e) {
fs.rmSync(tmp, { force: true })
throw new HttpError(400, `No se pudo leer ese archivo como EPUB: ${String(e.message).slice(0, 120)}`)
}
fs.renameSync(tmp, epubFile(id))
fs.writeFileSync(jsonFile(id), JSON.stringify(book))
remember(id, book)
db.prepare(`INSERT INTO user_books (id, user_id, title, authors, created_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET created_at = excluded.created_at`)
.run(id, user.id, (book.title || 'Sin título').slice(0, 300), JSON.stringify(book.authors.map(authorName).slice(0, 10)), Date.now())
return ubCard(db.prepare('SELECT * FROM user_books WHERE id = ?').get(id))
}
function removeUpload(id, userId) {
db.prepare('DELETE FROM user_books WHERE id = ? AND user_id = ?').run(id, userId)
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(userId, id)
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(userId, id)
db.prepare('DELETE FROM manga_prefs WHERE user_id = ? AND manga_id = ?').run(userId, id)
db.prepare('DELETE FROM list_items WHERE manga_id = ? AND list_id IN (SELECT id FROM lists WHERE user_id = ?)').run(id, userId)
memory.delete(id)
packed.delete(id)
fs.rmSync(epubFile(id), { force: true })
fs.rmSync(jsonFile(id), { force: true })
}
// Una sola descarga y conversión a la vez por libro.
const loading = new Map()
const memory = new Map() // últimos libros convertidos
function remember(id, book) {
memory.delete(id)
memory.set(id, book)
if (memory.size > 4) memory.delete(memory.keys().next().value)
return book
}
async function downloadEpub(id) {
if (id.startsWith('ub~')) throw new HttpError(404, 'Ese libro ya no está')
const r = id.startsWith('ws~')
? await fetchSource(`${WS_EXPORT}?${new URLSearchParams({ lang: 'es', page: wsTitle(id), format: 'epub-3' })}`, 120e3, WS_NAME)
: await fetchSource(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3, GB_NAME)
if (Number(r.headers.get('content-length')) > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
const buf = Buffer.from(await r.arrayBuffer())
if (buf.length > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
const tmp = `${epubFile(id)}.part`
fs.writeFileSync(tmp, buf)
fs.renameSync(tmp, epubFile(id))
}
function loadBook(id) {
const hit = memory.get(id)
if (hit) return Promise.resolve(remember(id, hit))
if (loading.has(id)) return loading.get(id)
const p = (async () => {
try {
const saved = JSON.parse(fs.readFileSync(jsonFile(id), 'utf8'))
if (saved.version === PARSER_VERSION) return remember(id, saved)
} catch {}
if (!fs.existsSync(epubFile(id))) await downloadEpub(id)
let book
try { book = parseEpub(epubFile(id)) } catch (e) {
console.warn(`EPUB ${id}: ${e.message}`)
fs.rmSync(epubFile(id), { force: true }) // descarga mala: que el siguiente intento la repita
throw new HttpError(502, 'No se pudo leer el EPUB de este libro')
}
const tmp = `${jsonFile(id)}.part`
fs.writeFileSync(tmp, JSON.stringify(book))
fs.renameSync(tmp, jsonFile(id))
return remember(id, book)
})().finally(() => loading.delete(id))
loading.set(id, p)
return p
}
async function manga(id) {
const book = await loadBook(id)
const source = id.slice(0, 2)
return {
manga: {
id, source, type: 'Libro',
title: book.title || (source === 'ws' ? wsCard(wsTitle(id)).title : `Libro ${id.slice(3)}`),
description: book.description || '',
status: null, year: null, // la fecha del EPUB es la de la edición digital, no la de la obra
tags: [],
authors: book.authors.map(authorName),
languages: book.language ? [book.language] : [],
cover: source === 'gb' ? coverOf(id.slice(3), id) : `/api/book/${id}/cover`,
},
chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })),
}
}
async function chapter(chapterId) {
const cut = chapterId.lastIndexOf('~')
const book = await loadBook(chapterId.slice(0, cut))
const c = book.chapters[Number(chapterId.slice(cut + 1))]
if (!c) throw new HttpError(404, 'Ese capítulo no existe')
return { title: c.title }
}
// El libro entero (lo maqueta el navegador), comprimido una sola vez: son megas de texto.
const packed = new Map()
async function sendText(req, res, id) {
const book = await loadBook(id)
let hit = packed.get(id)
if (hit?.book !== book) {
hit = { book, gz: zlib.gzipSync(JSON.stringify({ title: book.title, chapters: book.chapters })) }
packed.delete(id)
packed.set(id, hit)
if (packed.size > 4) packed.delete(packed.keys().next().value)
}
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'private, no-cache', Vary: 'Accept-Encoding' }
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) { res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' }); return res.end(hit.gz) }
res.writeHead(200, headers)
res.end(zlib.gunzipSync(hit.gz))
}
async function sendImage(res, id, n) {
const book = await loadBook(id)
const name = book.images[n]
const entry = name && readZipIndex(epubFile(id)).find(e => e.name === name)
if (!entry) throw new HttpError(404, 'Imagen no encontrada')
const data = readCbzEntry(epubFile(id), entry, MAX_UNZIPPED_ENTRY)
res.writeHead(200, { 'Content-Type': imageType(name), 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
res.end(data)
}
async function sendCover(res, id) {
if (id.startsWith('ub~')) {
const book = await loadBook(id)
if (book.cover != null) return sendImage(res, id, book.cover)
const r = db.prepare('SELECT title, authors FROM user_books WHERE id = ?').get(id)
return sendSvg(res, titleCover(r?.title || book.title || 'Sin título', book.authors.map(authorName)[0]))
}
if (!id.startsWith('ws~')) throw new HttpError(404, 'Sin portada')
const card = wsCard(wsTitle(id)) // en un listado: sin descargar el libro
sendSvg(res, titleCover(card.title, card.sub))
}
const sendSvg = (res, svg) => {
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
res.end(svg)
}
return { search, wsSearch, ubList, upload, removeUpload, owns, manga, chapter, sendText, sendImage, sendCover }
}
+129
View File
@@ -0,0 +1,129 @@
// Lectura/escritura mínima de CBZ (ZIP) sin dependencias.
// Escribe sin compresión (las imágenes ya vienen comprimidas); lee entradas "stored" y "deflate".
import fs from 'node:fs'
import zlib from 'node:zlib'
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i
// entries: [{ name, data: Buffer }]. Escribe a un temporal y renombra para no dejar CBZ a medias.
export function writeCbz(file, entries) {
const parts = []
const central = []
let offset = 0
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'utf8')
const crc = zlib.crc32(data)
const local = Buffer.alloc(30)
local.writeUInt32LE(0x04034b50, 0)
local.writeUInt16LE(20, 4) // versión necesaria
local.writeUInt16LE(0x0800, 6) // nombres en UTF-8
local.writeUInt16LE(0, 8) // sin compresión
local.writeUInt16LE(0, 10) // hora DOS
local.writeUInt16LE(0x21, 12) // fecha DOS (1980-01-01)
local.writeUInt32LE(crc, 14)
local.writeUInt32LE(data.length, 18)
local.writeUInt32LE(data.length, 22)
local.writeUInt16LE(nameBuf.length, 26)
local.writeUInt16LE(0, 28)
parts.push(local, nameBuf, data)
const cd = Buffer.alloc(46)
cd.writeUInt32LE(0x02014b50, 0)
cd.writeUInt16LE(20, 4)
cd.writeUInt16LE(20, 6)
cd.writeUInt16LE(0x0800, 8)
cd.writeUInt16LE(0, 10)
cd.writeUInt16LE(0, 12)
cd.writeUInt16LE(0x21, 14)
cd.writeUInt32LE(crc, 16)
cd.writeUInt32LE(data.length, 20)
cd.writeUInt32LE(data.length, 24)
cd.writeUInt16LE(nameBuf.length, 28)
cd.writeUInt32LE(offset, 42)
central.push(cd, nameBuf)
offset += 30 + nameBuf.length + data.length
}
const cdSize = central.reduce((n, b) => n + b.length, 0)
const end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50, 0)
end.writeUInt16LE(entries.length, 8)
end.writeUInt16LE(entries.length, 10)
end.writeUInt32LE(cdSize, 12)
end.writeUInt32LE(offset, 16)
const tmp = `${file}.part`
fs.writeFileSync(tmp, Buffer.concat([...parts, ...central, end]))
fs.renameSync(tmp, file)
}
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }]
export function readCbzIndex(file) {
return readZipIndex(file).filter(e => IMAGE_EXT.test(e.name) && !e.name.startsWith('__MACOSX'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
}
// Todas las entradas del ZIP, en el orden del archivo (también lo usan los EPUB).
export function readZipIndex(file) {
const fd = fs.openSync(file, 'r')
try {
const { size } = fs.fstatSync(fd)
const tailLen = Math.min(size, 65557)
const tail = Buffer.alloc(tailLen)
fs.readSync(fd, tail, 0, tailLen, size - tailLen)
const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
if (eocd < 0) throw new Error('ZIP inválido')
const count = tail.readUInt16LE(eocd + 10)
const cdSize = tail.readUInt32LE(eocd + 12)
const cdOffset = tail.readUInt32LE(eocd + 16)
if (cdSize > size) throw new Error('ZIP inválido')
const cd = Buffer.alloc(cdSize)
fs.readSync(fd, cd, 0, cdSize, cdOffset)
const entries = []
const lh = Buffer.alloc(30)
for (let p = 0, i = 0; i < count; i++) {
const method = cd.readUInt16LE(p + 10)
const compSize = cd.readUInt32LE(p + 20)
const unzipped = cd.readUInt32LE(p + 24)
const nameLen = cd.readUInt16LE(p + 28)
const extraLen = cd.readUInt16LE(p + 30)
const commentLen = cd.readUInt16LE(p + 32)
const localOffset = cd.readUInt32LE(p + 42)
if (compSize > size) throw new Error('ZIP inválido')
const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
p += 46 + nameLen + extraLen + commentLen
fs.readSync(fd, lh, 0, 30, localOffset)
const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28)
entries.push({ name, method, start, size: compSize, unzipped })
}
return entries
} finally {
fs.closeSync(fd)
}
}
export function imageType(name) {
const ext = name.split('.').pop().toLowerCase()
return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream'
}
// Lee una entrada completa del CBZ a memoria. maxBytes corta una entrada que al descomprimirse crezca de más.
export function readCbzEntry(file, entry, maxBytes) {
if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`)
const fd = fs.openSync(file, 'r')
const buf = Buffer.alloc(entry.size)
try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) }
return entry.method === 8 ? zlib.inflateRawSync(buf, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf
}
// Envía una entrada del CBZ por la respuesta HTTP.
export function sendCbzEntry(res, file, entry) {
const headers = { 'Content-Type': imageType(entry.name), 'Cache-Control': 'private, max-age=604800' }
if (entry.method === 0) {
res.writeHead(200, { ...headers, 'Content-Length': entry.size })
fs.createReadStream(file, { start: entry.start, end: entry.start + entry.size - 1 }).pipe(res)
return
}
const data = readCbzEntry(file, entry)
res.writeHead(200, headers)
res.end(data)
}
+192
View File
@@ -0,0 +1,192 @@
// Recortes: trozos de página (un panel, una viñeta) que el usuario guarda desde el lector, con la
// referencia al manga/capítulo/página de donde salen. Cada recorte se reescala solo (Real-ESRGAN ×4,
// o ×2 si ya es grande) en segundo plano. Los archivos van a <DATA_DIR>/clips/: <id>.png (original
// tal como se recortó) y <id>-hd.webp (reescalado).
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { BIN, MODELS, MODEL, run } from './upscale.js'
const MAX_BYTES = 64 * 1024 * 1024 // un recorte largo de lectura vertical en PNG
const MAX_SIDE = 8000 // a ×2 cabe en el máximo de WebP (16383 px)
const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle
const WEBP_QUALITY = 90
// Ancho y alto de un PNG (cabecera IHDR), o null si no es un PNG.
function pngSize(buf) {
if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return null
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }
}
export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) {
const DIR = path.join(DATA_DIR, 'clips')
fs.mkdirSync(DIR, { recursive: true })
db.exec(`
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
manga_title TEXT NOT NULL,
cover TEXT,
chapter_id TEXT NOT NULL,
chapter_label TEXT,
page INTEGER NOT NULL, -- 0-based
width INTEGER NOT NULL,
height INTEGER NOT NULL,
scale INTEGER, -- 2 o 4 cuando ya está reescalado
note TEXT,
status TEXT NOT NULL, -- queued | running | done | error | off (sin GPU)
error TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at);
`)
db.prepare("UPDATE clips SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad
const file = (id, hd) => path.join(DIR, hd ? `${id}-hd.webp` : `${id}.png`)
const hasX4 = () => fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`))
async function upscaleClip(clip) {
const scale = Math.max(clip.width, clip.height) <= X4_MAX_SIDE && hasX4() ? 4 : 2
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-'))
try {
const out = path.join(work, 'out.png')
await run(BIN, ['-i', file(clip.id), '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
const tmp = path.join(DIR, `${clip.id}-hd.tmp.webp`)
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
if (!db.prepare('SELECT 1 FROM clips WHERE id = ?').get(clip.id)) return fs.rmSync(tmp, { force: true }) // borrado mientras tanto
fs.renameSync(tmp, file(clip.id, true))
db.prepare("UPDATE clips SET status = 'done', scale = ?, error = NULL WHERE id = ?").run(scale, clip.id)
} finally {
fs.rmSync(work, { recursive: true, force: true })
}
}
let pumping = false
async function pump() {
if (pumping || !upscale.available()) return
pumping = true
try {
for (;;) {
const clip = db.prepare("SELECT * FROM clips WHERE status = 'queued' ORDER BY id LIMIT 1").get()
if (!clip) break
db.prepare("UPDATE clips SET status = 'running' WHERE id = ?").run(clip.id)
const t = Date.now()
try {
await upscaleClip(clip)
console.log(`Recorte ${clip.id} (${clip.width}×${clip.height}) reescalado en ${((Date.now() - t) / 1000).toFixed(1)} s`)
} catch (e) {
console.warn('Recorte', clip.id, e.message)
db.prepare("UPDATE clips SET status = 'error', error = ? WHERE id = ?").run(e.message.slice(0, 500), clip.id)
}
}
} finally {
pumping = false
}
}
setTimeout(pump, 3000) // tras arrancar (cwebp se comprueba de forma asíncrona)
const view = c => ({
id: c.id, mangaId: c.manga_id, mangaTitle: c.manga_title, cover: c.cover, chapterId: c.chapter_id, chapterLabel: c.chapter_label,
page: c.page, width: c.width, height: c.height, scale: c.scale, note: c.note, status: c.status, error: c.error, createdAt: c.created_at,
img: `/api/clips/${c.id}/img`, hd: c.status === 'done' ? `/api/clips/${c.id}/img?hd=1` : null,
})
const own = (userId, id) => {
const c = db.prepare('SELECT * FROM clips WHERE id = ? AND user_id = ?').get(id, userId)
if (!c) throw new HttpError(404, 'Recorte no encontrado')
return c
}
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
// llena la franja) y, si los hay, de entre los ya reescalados.
function bannerFor(userId, mangaId) {
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
if (!c) return null
const v = view(c)
return v.hd || v.img
}
async function readImage(req) {
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > MAX_BYTES) throw new HttpError(413, 'El recorte es demasiado grande')
chunks.push(c)
}
return Buffer.concat(chunks)
}
async function route(p, method, q, req, res, user) {
if (p === '/api/clips' && method === 'GET') {
const mangaId = q.get('manga')
const rows = mangaId
? db.prepare('SELECT * FROM clips WHERE user_id = ? AND manga_id = ? ORDER BY id DESC').all(user.id, mangaId)
: db.prepare('SELECT * FROM clips WHERE user_id = ? ORDER BY id DESC').all(user.id)
const mangas = db.prepare(`SELECT manga_id id, manga_title title, COUNT(*) n, MAX(id) last FROM clips WHERE user_id = ?
GROUP BY manga_id ORDER BY last DESC`).all(user.id)
return json(res, 200, { clips: rows.map(view), mangas, available: upscale.available() }), true
}
if (p === '/api/clips' && method === 'POST') {
// Imagen PNG en el cuerpo; la referencia va en la query.
const mangaId = q.get('mangaId') || '', chapterId = q.get('chapterId') || '', page = Number(q.get('page'))
if (!MANGA_ID.test(mangaId) || !CHAPTER_ID.test(chapterId) || !Number.isInteger(page) || page < 0 || page > 5000) throw new HttpError(400, 'Referencia inválida')
const buf = await readImage(req)
const dim = pngSize(buf)
if (!dim) throw new HttpError(400, 'El recorte debe ser PNG')
if (dim.w < 16 || dim.h < 16 || dim.w > MAX_SIDE || dim.h > MAX_SIDE) throw new HttpError(400, `El recorte debe medir entre 16 y ${MAX_SIDE} px por lado`)
const cover = (q.get('cover') || '').slice(0, 2000)
const r = db.prepare(`INSERT INTO clips (user_id, manga_id, manga_title, cover, chapter_id, chapter_label, page, width, height, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
user.id, mangaId, (q.get('mangaTitle') || '').slice(0, 300) || 'Sin título', cover.startsWith('/api/') ? cover : null, chapterId,
(q.get('chapterLabel') || '').slice(0, 300), page, dim.w, dim.h, upscale.available() ? 'queued' : 'off', Date.now())
const id = Number(r.lastInsertRowid)
fs.writeFileSync(file(id), buf)
pump()
return json(res, 200, view(own(user.id, id))), true
}
let m
if ((m = /^\/api\/clips\/(\d+)\/img$/.exec(p)) && method === 'GET') {
const c = own(user.id, Number(m[1]))
const hd = q.get('hd') === '1' && c.status === 'done'
const f = file(c.id, hd)
if (!fs.existsSync(f)) throw new HttpError(404, 'Imagen no encontrada')
const name = `${c.manga_title} - ${c.chapter_label || 'cap'} - p${c.page + 1}${hd ? ' HD' : ''}`.replace(/[\\/:*?"<>|]+/g, '_')
res.writeHead(200, {
'Content-Type': hd ? 'image/webp' : 'image/png', 'Cache-Control': 'private, max-age=31536000, immutable',
...(q.get('dl') === '1' ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}.${hd ? 'webp' : 'png'}` } : {}),
})
fs.createReadStream(f).pipe(res)
return true
}
if ((m = /^\/api\/clips\/(\d+)$/.exec(p))) {
const c = own(user.id, Number(m[1]))
if (method === 'GET') return json(res, 200, view(c)), true
if (method === 'PATCH') {
const b = await readBody(req)
if (b.note !== undefined) db.prepare('UPDATE clips SET note = ? WHERE id = ?').run(String(b.note).slice(0, 1000) || null, c.id)
return json(res, 200, view(own(user.id, c.id))), true
}
if (method === 'DELETE') {
db.prepare('DELETE FROM clips WHERE id = ?').run(c.id)
fs.rmSync(file(c.id), { force: true })
fs.rmSync(file(c.id, true), { force: true })
return json(res, 200, { ok: true }), true
}
}
if ((m = /^\/api\/clips\/(\d+)\/retry$/.exec(p)) && method === 'POST') {
const c = own(user.id, Number(m[1]))
if (!upscale.available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)')
if (c.status === 'error' || c.status === 'off') db.prepare("UPDATE clips SET status = 'queued', error = NULL WHERE id = ?").run(c.id)
pump()
return json(res, 200, view(own(user.id, c.id))), true
}
return false
}
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
FROM clips WHERE user_id = ?`).get(userId)
return { route, count, bannerFor, pump, queueInfo }
}
+53
View File
@@ -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),
}
}
+115
View File
@@ -0,0 +1,115 @@
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import crypto from 'node:crypto'
import { BIN, MODELS, MODEL, run } from './upscale.js'
const MAX_QUEUE = 300
const X4_MAX_SIDE = 400
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
const WEBP_QUALITY = 88
const BANNER_X4_MAX_WIDTH = 1000
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
export function imageSize(b) {
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
const f = b.toString('latin1', 12, 16)
if (f === 'VP8 ') return { type: 'webp', w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
if (f === 'VP8L') { const n = b.readUInt32LE(21); return { type: 'webp', w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1 } }
if (f === 'VP8X') return { type: 'webp', w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
return null
}
if (b[0] === 0xff && b[1] === 0xd8) {
for (let i = 2; i + 9 < b.length;) {
if (b[i] !== 0xff) { i++; continue }
const marker = b[i + 1]
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { i += marker === 0xff ? 1 : 2; continue }
const len = b.readUInt16BE(i + 2)
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpg', w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) }
i += 2 + len
}
}
return null
}
export function setupCovers({ DATA_DIR, upscale }) {
const DIR = path.join(DATA_DIR, 'covers')
fs.mkdirSync(DIR, { recursive: true })
const fileOf = key => path.join(DIR, `${crypto.createHash('sha256').update(key).digest('hex').slice(0, 32)}.webp`)
const queue = [] // { key, buf }
const pending = new Set()
const failed = new Set() // no reintentar hasta reiniciar
// Si ya hay versión HD, la envía y devuelve true.
function sendCached(res, key) {
if (!upscale.available()) return false
const f = fileOf(key)
if (!fs.existsSync(f)) return false
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=2592000, immutable' })
fs.createReadStream(f).pipe(res)
return true
}
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
// Un banner no se guarda: en la siguiente visita ya se ve el reescalado.
const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300')
// ¿Falta generar esta imagen? (para no descargarla si no hace falta)
const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
function enqueue(key, buf, banner = false) {
if (!wants(key)) return
pending.add(key)
queue.push({ key, buf, banner })
pump()
}
async function processOne({ key, buf, banner }) {
const size = imageSize(buf)
if (!size) throw new Error('formato no reconocido')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
try {
const out = path.join(work, 'out.png')
const tmp = `${fileOf(key)}.tmp`
if (!banner && Math.max(size.w, size.h) > MAX_SIDE) {
// Ya es grande: solo se recomprime para no volver a intentarlo.
const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf)
if (size.type === 'webp') fs.copyFileSync(inp, tmp)
else await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), inp, '-o', tmp])
} else {
const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf)
const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE
const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
}
fs.renameSync(tmp, fileOf(key))
} finally {
fs.rmSync(work, { recursive: true, force: true })
}
}
let pumping = false
async function pump() {
if (pumping) return
pumping = true
try {
let n = 0
const t = Date.now()
while (queue.length) {
const job = queue.shift()
try { await processOne(job); n++ } catch (e) { failed.add(job.key); console.warn('Portada HD:', e.message) } finally { pending.delete(job.key) }
}
if (n) console.log(`Portadas HD: ${n} en ${Math.round((Date.now() - t) / 1000)} s`)
} finally {
pumping = false
}
}
return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size }
}
+92
View File
@@ -0,0 +1,92 @@
// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten.
// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se
// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada.
const SIDE = 16
const SIG_BYTES = SIDE * SIDE
const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima
const SAME_SHAPE = 0.03
const MIN_PAGES = 5
const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra
const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan
const decode = s => Buffer.from(String(s || ''), 'base64')
const spread = b => {
const mean = b.reduce((a, x) => a + x, 0) / b.length
return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255
}
const difference = (a, b) => {
let sum = 0
for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i])
return sum / SIG_BYTES / 255
}
/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */
export function repeatedPages(rows, ids) {
const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) })))
.filter(p => p.sig.length === SIG_BYTES)
const out = {}
for (const r of rows) {
if (!ids.includes(r.id) || r.n < MIN_PAGES) continue
const hidden = []
for (const p of all) {
if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue
if (![0, r.n - 2, r.n - 1].includes(p.i)) continue
if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i)
}
if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b)
}
return out
}
export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) {
db.exec(`
CREATE TABLE IF NOT EXISTS page_sigs (
manga_id TEXT NOT NULL,
chapter_id TEXT NOT NULL,
n INTEGER NOT NULL,
pages TEXT NOT NULL,
PRIMARY KEY (manga_id, chapter_id)
);
CREATE TABLE IF NOT EXISTS hide_credits (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
PRIMARY KEY (user_id, manga_id)
);`)
const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId)
function validPages(pages, n) {
const entries = Object.entries(pages || {})
if (!entries.length || entries.length > 3) return false
return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES)
}
async function route(p, method, req, res, user) {
let m
if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const { on } = await readBody(req)
if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1])
else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
json(res, 200, { hideCredits: !!on })
return true
}
if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const body = await readBody(req)
const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : []
if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos')
const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)')
for (const c of wanted) {
const pages = body.sigs?.[c.id]
if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages))
}
const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) }))
const known = new Map(rows.map(r => [r.id, r.n]))
// Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale.
const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id)
const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n))
json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) })
return true
}
return false
}
return { route, enabled }
}
+383
View File
@@ -0,0 +1,383 @@
// Seguimiento de capítulos nuevos (+ notificaciones ntfy/Telegram y autodescarga),
// preferencias del lector por manga y estadísticas de lectura.
const CHECK_HOURS = Number(process.env.CHECK_INTERVAL_HOURS || 3)
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://localhost:3352').replace(/\/+$/, '')
// Día local (TZ del contenedor) en formato YYYY-MM-DD.
export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE')
const sleep = ms => new Promise(r => setTimeout(r, ms))
const chapterKey = c => c.num ?? c.id
// Nombres de género unificados para las estadísticas (LeerCapitulo mezcla inglés y español).
const GENRE_ES = {
action: 'Acción', accion: 'Acción', adventure: 'Aventura', comedy: 'Comedia', drama: 'Drama', fantasy: 'Fantasía',
fantasia: 'Fantasía', romance: 'Romance', horror: 'Terror', terror: 'Terror', mystery: 'Misterio', misterio: 'Misterio',
psychological: 'Psicológico', psicologico: 'Psicológico', 'sci-fi': 'Ciencia ficción', 'science fiction': 'Ciencia ficción',
'ciencia ficcion': 'Ciencia ficción', 'slice of life': 'Recuentos de la vida', 'recuentos de la vida': 'Recuentos de la vida',
sports: 'Deportes', deportes: 'Deportes', supernatural: 'Sobrenatural', sobrenatural: 'Sobrenatural', thriller: 'Thriller',
tragedy: 'Tragedia', tragedia: 'Tragedia', historical: 'Histórico', historico: 'Histórico', mecha: 'Mecha',
'martial arts': 'Artes marciales', 'artes marciales': 'Artes marciales', 'school life': 'Vida escolar', 'vida escolar': 'Vida escolar',
shounen: 'Shounen', shoujo: 'Shoujo', seinen: 'Seinen', josei: 'Josei', isekai: 'Isekai', magic: 'Magia', magia: 'Magia',
harem: 'Harem', ecchi: 'Ecchi', music: 'Música', musica: 'Música', military: 'Militar', militar: 'Militar', medical: 'Médico',
crime: 'Crimen', crimen: 'Crimen', philosophical: 'Filosófico', reincarnation: 'Reencarnación', reencarnacion: 'Reencarnación',
monsters: 'Monstruos', monstruos: 'Monstruos', vampires: 'Vampiros', vampiros: 'Vampiros', zombies: 'Zombis', zombis: 'Zombis',
'video games': 'Videojuegos', videojuegos: 'Videojuegos', 'time travel': 'Viajes en el tiempo', survival: 'Supervivencia',
supervivencia: 'Supervivencia', cooking: 'Cocina', cocina: 'Cocina', 'office workers': 'Oficinistas', villainess: 'Villana',
demons: 'Demonios', demonios: 'Demonios', gore: 'Gore', superhero: 'Superhéroes', 'virtual reality': 'Realidad virtual',
'post-apocalyptic': 'Postapocalíptico', apocaliptico: 'Postapocalíptico', animals: 'Animales', 'gender bender': 'Gender bender',
'boys\' love': 'Boys\' love', yaoi: 'Boys\' love', "girls' love": "Girls' love", yuri: "Girls' love", wuxia: 'Wuxia',
aliens: 'Alienígenas', ninja: 'Ninjas', samurai: 'Samuráis', police: 'Policial', mafia: 'Mafia', delinquents: 'Delincuentes',
'reverse harem': 'Harem inverso', 'traditional games': 'Juegos tradicionales', ghosts: 'Fantasmas', 'monster girls': 'Monster girls',
}
// Etiquetas de formato que no son géneros.
const NOT_GENRE = new Set(['long strip', 'full color', 'web comic', 'official colored', 'fan colored', 'adaptation', 'anthology',
'award winning', 'doujinshi', 'oneshot', '4-koma', 'self-published', 'user created', 'manga', 'manhwa', 'manhua', 'webtoon'])
export const genreName = raw => {
const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (NOT_GENRE.has(k)) return null
return GENRE_ES[k] || String(raw).trim()
}
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, net }) {
db.exec(`
CREATE TABLE IF NOT EXISTS follows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
title TEXT NOT NULL,
cover TEXT,
lang TEXT,
active INTEGER NOT NULL DEFAULT 1,
known TEXT, -- JSON: números de capítulo ya vistos (null = sin inicializar)
new_keys TEXT NOT NULL DEFAULT '[]', -- JSON: números de capítulo nuevos aún no vistos por el usuario
last_checked INTEGER,
last_new_at INTEGER,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, manga_id)
);
CREATE TABLE IF NOT EXISTS manga_prefs (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
prefs TEXT NOT NULL,
PRIMARY KEY (user_id, manga_id)
);
CREATE TABLE IF NOT EXISTS manga_meta (
manga_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source TEXT,
type TEXT,
tags TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS page_reads (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL,
page INTEGER NOT NULL,
manga_id TEXT NOT NULL,
day TEXT NOT NULL,
PRIMARY KEY (user_id, chapter_id, page)
);
CREATE INDEX IF NOT EXISTS idx_page_reads_day ON page_reads(user_id, day);
CREATE TABLE IF NOT EXISTS chapter_done (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL,
manga_id TEXT NOT NULL,
day TEXT NOT NULL,
PRIMARY KEY (user_id, chapter_id)
);
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT);
`)
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'notify')) db.exec('ALTER TABLE users ADD COLUMN notify TEXT')
// Migración única: estadísticas e historial previos a esta versión.
if (!db.prepare("SELECT 1 FROM kv WHERE k = 'migr_features_v1'").get()) {
const insPage = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)')
const insDone = db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)')
for (const r of db.prepare('SELECT * FROM chapter_reads').all()) {
const day = dayOf(r.updated_at)
for (let p = 0; p <= Math.min(r.page, 500); p++) insPage.run(r.user_id, r.chapter_id, p, r.manga_id, day)
if (r.total_pages && r.page >= r.total_pages - 1) insDone.run(r.user_id, r.chapter_id, r.manga_id, day)
}
db.prepare(`INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at)
SELECT user_id, manga_id, manga_title, cover, lang, updated_at FROM progress`).run()
db.prepare("INSERT INTO kv (k, v) VALUES ('migr_features_v1', ?)").run(String(Date.now()))
}
function saveMeta(manga) {
db.prepare(`INSERT INTO manga_meta (manga_id, title, source, type, tags, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (manga_id) DO UPDATE SET title = excluded.title, source = excluded.source, type = excluded.type,
tags = excluded.tags, updated_at = excluded.updated_at`)
.run(manga.id, manga.title, manga.source, manga.type || null, JSON.stringify((manga.tags || []).map(t => t.name ?? t)), Date.now())
}
// ---------- Notificaciones ----------
const getNotify = userId => {
try { return JSON.parse(db.prepare('SELECT notify FROM users WHERE id = ?').get(userId)?.notify || '{}') } catch { return {} }
}
async function sendNotification(cfg, { title, message, click }) {
const errors = []
if (cfg.ntfyUrl && cfg.ntfyTopic) {
try {
const headers = { 'Content-Type': 'application/json' }
if (cfg.ntfyToken) headers.Authorization = cfg.ntfyToken.includes(':') ? `Basic ${Buffer.from(cfg.ntfyToken).toString('base64')}` : `Bearer ${cfg.ntfyToken}`
// Publicación JSON: admite UTF-8 en el título (en cabeceras no).
const r = await fetch(cfg.ntfyUrl, {
method: 'POST', headers, signal: AbortSignal.timeout(15e3),
body: JSON.stringify({ topic: cfg.ntfyTopic, title, message, click, tags: ['books'] }),
})
if (!r.ok) errors.push(`ntfy: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`)
} catch (e) { errors.push(`ntfy: ${e.message}`) }
}
if (cfg.tgToken && cfg.tgChat) {
try {
const r = await fetch(`https://api.telegram.org/bot${cfg.tgToken}/sendMessage`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15e3),
body: JSON.stringify({ chat_id: cfg.tgChat, text: `${title}\n${message}\n${click}`, disable_web_page_preview: true }),
})
if (!r.ok) errors.push(`Telegram: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`)
} catch (e) { errors.push(`Telegram: ${e.message}`) }
}
return errors
}
const hasNotify = cfg => !!((cfg.ntfyUrl && cfg.ntfyTopic) || (cfg.tgToken && cfg.tgChat))
// ---------- Comprobación de capítulos nuevos ----------
const state = { checking: null, lastCheckAt: null, nextCheckAt: null }
async function checkManga(mangaId, lang) {
const { manga, chapters } = await fetchMangaFull(mangaId, lang || 'es-la')
saveMeta(manga)
if (!chapters.length) return 0 // respuesta vacía: no tocar lo conocido
const keys = chapters.map(chapterKey)
const byKey = new Map(chapters.map(c => [chapterKey(c), c]))
let anyNew = 0
const now = Date.now()
for (const f of db.prepare('SELECT * FROM follows WHERE active = 1 AND manga_id = ? AND lang IS ?').all(mangaId, lang ?? null)) {
if (f.known == null) {
db.prepare('UPDATE follows SET known = ?, last_checked = ? WHERE user_id = ? AND manga_id = ?').run(JSON.stringify(keys), now, f.user_id, mangaId)
continue
}
const known = new Set(JSON.parse(f.known))
const fresh = keys.filter(k => !known.has(k))
const pending = [...new Set([...JSON.parse(f.new_keys), ...fresh])]
db.prepare(`UPDATE follows SET known = ?, new_keys = ?, last_checked = ?, last_new_at = COALESCE(?, last_new_at), title = ?
WHERE user_id = ? AND manga_id = ?`)
.run(JSON.stringify([...known, ...fresh]), JSON.stringify(pending), now, fresh.length ? now : null, manga.title, f.user_id, mangaId)
if (!fresh.length) continue
anyNew += fresh.length
const cfg = getNotify(f.user_id)
if (hasNotify(cfg)) {
const nums = fresh.map(k => byKey.get(k)?.num).filter(Boolean)
const message = `${fresh.length === 1 ? '1 capítulo nuevo' : `${fresh.length} capítulos nuevos`}${nums.length ? `: ${nums.slice(0, 8).join(', ')}${nums.length > 8 ? '…' : ''}` : ''}`
const errs = await sendNotification(cfg, { title: manga.title, message, click: `${PUBLIC_URL}/#/manga/${mangaId}` })
if (errs.length) console.warn('Notificación', f.user_id, errs.join('; '))
}
}
// Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo.
const dl = anyNew && downloadInfo(mangaId)
// Solo si se baja el manga entero; con capítulos sueltos, lo nuevo no se descarga solo.
if (dl && dl.scope !== 'some' && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang)
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(/^(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)`)
})().finally(() => {
state.checking = null
state.nextCheckAt = Date.now() + CHECK_HOURS * 3600e3
})
return state.checking
}
net.whenOnline(() => { if (skippedOffline) { skippedOffline = false; checkFollows() } })
state.nextCheckAt = Date.now() + 90e3
setTimeout(() => {
checkFollows()
setInterval(checkFollows, CHECK_HOURS * 3600e3)
}, 90e3)
// ---------- Hooks llamados desde server.js ----------
function onProgress(userId, b, page, total, prevPage) {
const day = dayOf(Date.now())
const from = prevPage != null && page > prevPage && page - prevPage <= 300 ? prevPage + 1 : page
const ins = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)')
for (let p = from; p <= page; p++) ins.run(userId, b.chapterId, p, b.mangaId, day)
if (total && page >= total - 1) {
db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day)
}
if (/^(gb|ws|ub)~/.test(b.mangaId)) return // un libro no tiene capítulos nuevos que seguir
// 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,
/^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang || null, Date.now())
}
// Datos extra de la ficha; marca como vistos los capítulos nuevos.
function mangaExtras(userId, manga) {
saveMeta(manga)
const f = db.prepare('SELECT active, new_keys FROM follows WHERE user_id = ? AND manga_id = ?').get(userId, manga.id)
const newKeys = f?.active ? JSON.parse(f.new_keys) : []
if (newKeys.length) db.prepare("UPDATE follows SET new_keys = '[]' WHERE user_id = ? AND manga_id = ?").run(userId, manga.id)
const prefs = db.prepare('SELECT prefs FROM manga_prefs WHERE user_id = ? AND manga_id = ?').get(userId, manga.id)
return { following: !!f?.active, newKeys, prefs: prefs ? JSON.parse(prefs.prefs) : null }
}
function unfollow(userId, mangaId) {
db.prepare('UPDATE follows SET active = 0 WHERE user_id = ? AND manga_id = ?').run(userId, mangaId)
}
// ---------- Estadísticas ----------
function stats(userId) {
const pagesByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM page_reads WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n]))
const chapsByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n]))
const today = dayOf(Date.now())
const addDays = (d, n) => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() + n); return x.toLocaleDateString('sv-SE') }
// Rachas: días consecutivos con alguna página leída.
const days = [...pagesByDay.keys()].sort()
let best = 0, run = 0, prev = null
for (const d of days) {
run = prev && addDays(prev, 1) === d ? run + 1 : 1
best = Math.max(best, run)
prev = d
}
let current = 0
for (let d = pagesByDay.has(today) ? today : addDays(today, -1); pagesByDay.has(d); d = addDays(d, -1)) current++
// Semanas (lunes a domingo), las últimas 12.
const monday = d => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x.toLocaleDateString('sv-SE') }
const weeks = []
for (let i = 11, start = monday(today); i >= 0; i--) {
const ws = addDays(start, -7 * i)
let pages = 0, chapters = 0
for (let k = 0; k < 7; k++) { const d = addDays(ws, k); pages += pagesByDay.get(d) || 0; chapters += chapsByDay.get(d) || 0 }
weeks.push({ start: ws, pages, chapters })
}
// Calendario: últimas 18 semanas día a día.
const calStart = addDays(monday(today), -7 * 17)
const calendar = []
for (let d = calStart; d <= today; d = addDays(d, 1)) calendar.push({ day: d, pages: pagesByDay.get(d) || 0, chapters: chapsByDay.get(d) || 0 })
// Géneros ponderados por páginas leídas de cada manga.
const perManga = db.prepare(`SELECT r.manga_id, COUNT(*) pages, m.tags, m.title, m.type FROM page_reads r
LEFT JOIN manga_meta m ON m.manga_id = r.manga_id WHERE r.user_id = ? GROUP BY r.manga_id`).all(userId)
const genreMap = new Map()
let withMeta = 0
for (const m of perManga) {
if (!m.tags) continue
withMeta += m.pages
const names = [...new Set(JSON.parse(m.tags).map(genreName).filter(Boolean))]
for (const g of names) genreMap.set(g, (genreMap.get(g) || 0) + m.pages)
}
const genres = [...genreMap].sort((a, b) => b[1] - a[1]).slice(0, 10)
.map(([name, pages]) => ({ name, pages, pct: withMeta ? Math.round((pages / withMeta) * 100) : 0 }))
const chapByManga = new Map(db.prepare('SELECT manga_id, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY manga_id').all(userId).map(r => [r.manga_id, r.n]))
const progress = new Map(db.prepare('SELECT manga_id, manga_title, cover FROM progress WHERE user_id = ?').all(userId).map(r => [r.manga_id, r]))
const topMangas = perManga.sort((a, b) => b.pages - a.pages).slice(0, 8).map(m => ({
id: m.manga_id, title: m.title || progress.get(m.manga_id)?.manga_title || m.manga_id, cover: progress.get(m.manga_id)?.cover || null,
pages: m.pages, chapters: chapByManga.get(m.manga_id) || 0,
}))
const types = new Map()
for (const m of perManga) if (m.type) types.set(m.type, (types.get(m.type) || 0) + m.pages)
return {
totals: {
pages: [...pagesByDay.values()].reduce((a, b) => a + b, 0),
chapters: [...chapsByDay.values()].reduce((a, b) => a + b, 0),
mangas: perManga.length,
activeDays: days.length,
thisWeekPages: weeks.at(-1).pages,
thisWeekChapters: weeks.at(-1).chapters,
},
streak: { current, best, readToday: pagesByDay.has(today) },
weeks, calendar, genres, topMangas,
types: [...types].sort((a, b) => b[1] - a[1]).map(([name, pages]) => ({ name, pages })),
}
}
// ---------- Rutas ----------
async function route(p, method, q, req, res, user) {
let m
if (p === '/api/follows' && method === 'GET') {
const list = db.prepare('SELECT manga_id, title, cover, lang, new_keys, last_checked, last_new_at FROM follows WHERE user_id = ? AND active = 1 ORDER BY COALESCE(last_new_at, 0) DESC, created_at DESC').all(user.id)
return json(res, 200, {
follows: list.map(f => ({ ...f, newCount: JSON.parse(f.new_keys).length, new_keys: undefined })),
checking: !!state.checking, lastCheckAt: state.lastCheckAt, nextCheckAt: state.nextCheckAt, intervalHours: CHECK_HOURS,
})
}
if (p === '/api/follows/check' && method === 'POST') {
checkFollows()
return json(res, 200, { checking: true })
}
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
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`)
.run(user.id, m[1], String(b.title || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, lang, Date.now())
checkManga(m[1], lang).catch(e => console.warn('Seguir', m[1], e.message)) // inicializa lo conocido
return json(res, 200, { following: true })
}
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
unfollow(user.id, m[1])
return json(res, 200, { following: false })
}
if ((m = /^\/api\/prefs\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir) || (b.gap != null && typeof b.gap !== 'boolean')) throw new HttpError(400, 'Preferencias inválidas')
db.prepare('INSERT OR REPLACE INTO manga_prefs (user_id, manga_id, prefs) VALUES (?, ?, ?)')
.run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir, ...(b.gap != null ? { gap: b.gap } : {}) }))
return json(res, 200, { ok: true })
}
if (p === '/api/stats' && method === 'GET') return json(res, 200, stats(user.id))
if (p === '/api/notify' && method === 'GET') {
const cfg = getNotify(user.id)
return json(res, 200, { ntfyUrl: cfg.ntfyUrl || '', ntfyTopic: cfg.ntfyTopic || '', hasNtfyToken: !!cfg.ntfyToken, tgChat: cfg.tgChat || '', hasTgToken: !!cfg.tgToken })
}
if (p === '/api/notify' && method === 'POST') {
const b = await readBody(req)
const old = getNotify(user.id)
const str = (v, max) => String(v ?? '').trim().slice(0, max)
const cfg = {
ntfyUrl: str(b.ntfyUrl, 300).replace(/\/+$/, ''),
ntfyTopic: str(b.ntfyTopic, 64),
// Campo de token vacío = conservar el guardado; "-" = borrarlo.
ntfyToken: b.ntfyToken === '-' ? '' : str(b.ntfyToken, 300) || old.ntfyToken || '',
tgToken: b.tgToken === '-' ? '' : str(b.tgToken, 100) || old.tgToken || '',
tgChat: str(b.tgChat, 40),
}
if (cfg.ntfyUrl && !/^https?:\/\/[^\s/]+/.test(cfg.ntfyUrl)) throw new HttpError(400, 'URL de ntfy inválida')
if (cfg.ntfyTopic && !/^[\w-]{1,64}$/.test(cfg.ntfyTopic)) throw new HttpError(400, 'Tema de ntfy inválido (letras, números, - y _)')
if (cfg.tgToken && !/^\d+:[\w-]{20,}$/.test(cfg.tgToken)) throw new HttpError(400, 'Token de Telegram inválido')
if (cfg.tgChat && !/^-?\d+$|^@\w+$/.test(cfg.tgChat)) throw new HttpError(400, 'Chat de Telegram inválido')
db.prepare('UPDATE users SET notify = ? WHERE id = ?').run(JSON.stringify(cfg), user.id)
return json(res, 200, { ok: true })
}
if (p === '/api/notify/test' && method === 'POST') {
const cfg = getNotify(user.id)
if (!hasNotify(cfg)) throw new HttpError(400, 'No hay ningún canal de notificación configurado')
const errors = await sendNotification(cfg, { title: 'Manga Reader', message: 'Notificación de prueba: los avisos de capítulos nuevos llegarán aquí.', click: PUBLIC_URL })
if (errors.length) throw new HttpError(502, errors.join(' · '))
return json(res, 200, { ok: true })
}
return false
}
return { route, onProgress, mangaExtras, unfollow, checkFollows, stats }
}
+168
View File
@@ -0,0 +1,168 @@
// Librería: listas propias del usuario, recomendaciones según sus géneros y descarga anticipada
// de los capítulos siguientes a lo que se está leyendo.
const PREFETCH_DEFAULT = { n: 2, hd: false }
const DEFAULT_LISTS = ['Favoritos', 'Pendientes', 'Abandonados']
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, linkDownload, upscale, stats, searchManga, mdCategories, net }) {
db.exec(`
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS list_items (
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
title TEXT NOT NULL,
cover TEXT,
added_at INTEGER NOT NULL,
PRIMARY KEY (list_id, manga_id)
);
`)
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'prefetch')) db.exec('ALTER TABLE users ADD COLUMN prefetch TEXT')
// ---------- Listas ----------
function listsOf(userId) {
let rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
if (!rows.length && !db.prepare('SELECT 1 FROM kv WHERE k = ?').get(`lists_init_${userId}`)) {
const ins = db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)')
for (const n of DEFAULT_LISTS) ins.run(userId, n, Date.now())
db.prepare('INSERT INTO kv (k, v) VALUES (?, ?)').run(`lists_init_${userId}`, '1')
rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
}
const count = db.prepare('SELECT COUNT(*) n FROM list_items WHERE list_id = ?')
return rows.map(l => ({ ...l, count: count.get(l.id).n }))
}
const ownList = (userId, id) => {
const l = db.prepare('SELECT * FROM lists WHERE id = ? AND user_id = ?').get(id, userId)
if (!l) throw new HttpError(404, 'Lista no encontrada')
return l
}
// Listas que contienen un manga (para la ficha).
const listsWith = (userId, mangaId) => db.prepare(`SELECT l.id FROM lists l JOIN list_items i ON i.list_id = l.id
WHERE l.user_id = ? AND i.manga_id = ?`).all(userId, mangaId).map(r => r.id)
// ---------- Descarga anticipada ----------
function prefetchCfg(userId) {
try { return { ...PREFETCH_DEFAULT, ...JSON.parse(db.prepare('SELECT prefetch FROM users WHERE id = ?').get(userId)?.prefetch || '{}') } } catch { return { ...PREFETCH_DEFAULT } }
}
const recent = new Map() // `${manga}|${chapter}` -> ts, para no repetir en cada guardado de progreso
// 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) || !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 = /^(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
const next = chapters.slice(i + 1, i + 1 + cfg.n).map(c => c.id)
if (!next.length) return
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(b.mangaId).map(r => r.chapter_id))
const missing = next.filter(id => !have.has(id))
if (missing.length) {
enqueueDownload(b.mangaId, manga.title, lang, missing)
linkDownload(userId, b.mangaId)
console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`)
}
if (cfg.hd && upscale.available()) upscale.request(next)
}).catch(e => console.warn('Descarga anticipada:', e.message))
}
// ---------- Recomendaciones ----------
const recCache = new Map() // userId -> { at, data }
// Populares de MangaDex (por seguidores) en tus 3 géneros principales, con capítulos en tu idioma.
// LeerCapitulo no ordena sus géneros por popularidad, por eso la fuente es MangaDex.
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')
const byName = new Map(cats.map(c => [norm(genreName(c.name) || c.name), c]))
const picked = genres.map(g => ({ g, cat: byName.get(norm(g.name)) })).filter(x => x.cat).slice(0, 3)
const mine = db.prepare(`SELECT manga_id, manga_title title FROM progress WHERE user_id = ? UNION SELECT manga_id, title FROM follows WHERE user_id = ?
UNION SELECT i.manga_id, i.title FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?`).all(userId, userId, userId)
const knownIds = new Set(mine.map(r => r.manga_id))
const knownTitles = new Set(mine.map(r => norm(r.title).replace(/[^a-z0-9]/g, '')))
// Puntuación: aparecer en varios de tus géneros y salir arriba en cada lista.
const score = new Map()
const page = refresh ? 1 + Math.floor(Math.random() * 3) : 1 // "otras recomendaciones": otra página
const results = await Promise.all(picked.map(({ cat }) => searchManga('', lang, 'popular', cat.id, page).catch(() => [])))
results.forEach((list, gi) => list.forEach((m, rank) => {
if (!m.id || knownIds.has(m.id) || knownTitles.has(norm(m.title).replace(/[^a-z0-9]/g, ''))) return
const s = score.get(m.id) || { m, pts: 0, why: [] }
s.pts += (picked.length - gi) * 2 + Math.max(0, 30 - rank) / 10 + 3
s.why.push(picked[gi].g.name)
score.set(m.id, s)
}))
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 }
if (items.length) recCache.set(userId, { at: Date.now(), data }) // vacío (fuente caída): no guardarlo 6 h
return data
}
// ---------- Rutas ----------
async function route(p, method, q, req, res, user) {
let m
if (p === '/api/lists' && method === 'GET') return json(res, 200, listsOf(user.id)), true
if (p === '/api/lists' && method === 'POST') {
const b = await readBody(req)
const name = String(b.name || '').trim().slice(0, 60)
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
if (db.prepare('SELECT COUNT(*) n FROM lists WHERE user_id = ?').get(user.id).n >= 50) throw new HttpError(400, 'Demasiadas listas')
const id = Number(db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)').run(user.id, name, Date.now()).lastInsertRowid)
return json(res, 200, { id, name, count: 0 }), true
}
if ((m = /^\/api\/lists\/(\d+)$/.exec(p))) {
const l = ownList(user.id, Number(m[1]))
if (method === 'GET') {
const items = db.prepare('SELECT manga_id, title, cover, added_at FROM list_items WHERE list_id = ? ORDER BY added_at DESC').all(l.id)
return json(res, 200, { id: l.id, name: l.name, items }), true
}
if (method === 'PATCH') {
const name = String((await readBody(req)).name || '').trim().slice(0, 60)
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
db.prepare('UPDATE lists SET name = ? WHERE id = ?').run(name, l.id)
return json(res, 200, { ok: true }), true
}
if (method === 'DELETE') { db.prepare('DELETE FROM lists WHERE id = ?').run(l.id); return json(res, 200, { ok: true }), true }
}
if ((m = /^\/api\/lists\/(\d+)\/items$/.exec(p)) && method === 'POST') {
const l = ownList(user.id, Number(m[1]))
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '')) throw new HttpError(400, 'Manga inválido')
const cover = String(b.cover || '').slice(0, 1000)
db.prepare(`INSERT INTO list_items (list_id, manga_id, title, cover, added_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (list_id, manga_id) DO UPDATE SET title = excluded.title, cover = excluded.cover`)
.run(l.id, b.mangaId, String(b.title || '').slice(0, 300) || b.mangaId, cover.startsWith('/api/') ? cover : null, Date.now())
return json(res, 200, { lists: listsWith(user.id, b.mangaId) }), true
}
if ((m = /^\/api\/lists\/(\d+)\/items\/([^/]+)$/.exec(p)) && method === 'DELETE') {
const l = ownList(user.id, Number(m[1]))
const mangaId = decodeURIComponent(m[2])
db.prepare('DELETE FROM list_items WHERE list_id = ? AND manga_id = ?').run(l.id, mangaId)
return json(res, 200, { lists: listsWith(user.id, mangaId) }), true
}
if (p === '/api/prefetch' && method === 'GET') return json(res, 200, prefetchCfg(user.id)), true
if (p === '/api/prefetch' && method === 'POST') {
const b = await readBody(req)
const n = Math.min(10, Math.max(0, Number(b.n) | 0))
db.prepare('UPDATE users SET prefetch = ? WHERE id = ?').run(JSON.stringify({ n, hd: !!b.hd }), user.id)
return json(res, 200, prefetchCfg(user.id)), true
}
if (p === '/api/recommendations' && method === 'GET') return json(res, 200, await recommendations(user.id, q.get('refresh') === '1')), true
return false
}
return { route, onProgress, listsOf, listsWith }
}
+55
View File
@@ -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
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 }
}
+317
View File
@@ -0,0 +1,317 @@
// Información extra de un manga (ficha "Info") y fechas de publicación de sus capítulos, de fuentes
// externas: MangaDex (enlaces entre sitios y fechas), AniList (sinopsis, autores, puntuación, adaptaciones,
// recomendaciones) y MangaUpdates (revista, editoriales, fechas de traducciones).
//
// 1. Identificar la obra: si viene de MangaDex, sus enlaces ya dicen cuál es en AniList/MangaUpdates; si
// viene de otra fuente se busca por título en MangaDex, luego en AniList y luego en MangaUpdates (solo
// se acepta un título que coincida, para no mostrar datos de otra obra).
// 2. Fechas: los sitios en español suelen poner la fecha en que ellos subieron el capítulo (a veces la
// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
// publicación en series en curso.
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
import { parseMuVolumes, workTitle } from './volumes.js'
const DAY = 24 * 3600e3
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
const SYNC_EVERY = 3600e3
const ANILIST = 'https://graphql.anilist.co'
const MU_API = 'https://api.mangaupdates.com/v1'
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
// Títulos comparables: sin acentos, mayúsculas, signos ni espacios.
const norm = s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/&/g, 'and').replace(/[^\p{L}\p{N}]+/gu, '')
const bigrams = s => { const b = new Map(); for (let i = 0; i < s.length - 1; i++) { const k = s.slice(i, i + 2); b.set(k, (b.get(k) || 0) + 1) } return b }
function similar(a, b) {
a = norm(a); b = norm(b)
if (!a || !b) return false
if (a === b) return true
if (Math.min(a.length, b.length) < 6) return false
const A = bigrams(a), B = bigrams(b)
let hit = 0
for (const [k, n] of A) hit += Math.min(n, B.get(k) || 0)
return (2 * hit) / (a.length - 1 + b.length - 1) >= 0.9
}
// Número de capítulo como clave ("01" -> "1", "10.50" -> "10.5").
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
// HTML de AniList/MangaUpdates a texto (solo saltos de línea).
const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ''))
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) {
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
const inflight = new Map()
function stored(key, ttl, fn, wait = false) {
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
const old = row ? JSON.parse(row.value) : undefined
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
if (!inflight.has(key)) {
inflight.set(key, (async () => {
try {
const v = await fn(old)
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
return v
} catch (e) {
if (old !== undefined) return old
throw e
}
})().finally(() => inflight.delete(key)))
}
return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
}
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
let gate = Promise.resolve()
const turn = ms => { const p = gate.then(() => new Promise(r => setTimeout(r, ms))); gate = p; return p }
async function getJson(url, opts = {}, name = 'la fuente') {
for (let i = 0; ; i++) {
net.guard()
await turn(400)
const res = await fetch(url, { ...opts, headers: { 'User-Agent': UA, Accept: 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}), ...opts.headers }, signal: AbortSignal.timeout(20e3) })
.catch(() => { net.failed(); return null })
if (res?.ok) return res.json()
if (res?.status === 404) return null
const retry = !res || res.status === 429 || res.status >= 500
if (!retry || i >= 2) throw new HttpError(502, `No se pudo consultar ${name}${res ? ` (${res.status})` : ''}`)
await new Promise(r => setTimeout(r, (Number(res?.headers.get('retry-after')) || 2 * (i + 1)) * 1000))
}
}
const anilist = (query, variables) => getJson(ANILIST, { method: 'POST', body: JSON.stringify({ query, variables }) }, 'AniList').then(d => d?.data)
const mu = (path, body) => getJson(MU_API + path, body ? { method: 'POST', body: JSON.stringify(body) } : {}, 'MangaUpdates')
// ---------- Identificar la obra ----------
// MangaUpdates: los enlaces nuevos son el id en base 36; los antiguos (numéricos) no sirven para la API.
const muIdOf = s => (/^[0-9a-z]+$/.test(s || '') && /[a-z]/.test(s) ? parseInt(s, 36) : null)
const mdLinks = m => {
const l = m.attributes.links || {}
return { md: m.id, al: Number(l.al) || null, mu: muIdOf(l.mu), mal: Number(l.mal) || null }
}
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
async function links(mangaId, wait = false) {
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
if (sourceOf(mangaId) === 'md') {
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
return { ...mdLinks(d.data), via: 'MangaDex' }
}
const { manga } = await fetchMangaFull(mangaId, 'es')
const title = manga.title
// MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga.
// Una fuente caída no significa "no encontrado": no se guarda y se reintenta.
let failed = false
const soft = p => p.catch(() => { failed = true; return null })
const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3))
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }))
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
const s = await soft(mu('/series/search', { search: title, perpage: 8 }))
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
return {}
}, wait)
}
// Las entradas de MangaDex con el título de la obra, en el orden en que las devuelve (primero la más parecida).
function mdMatches(mangaId) {
return stored(`mdworks:${mangaId}`, v => (v.length ? 30 * DAY : 3 * DAY), async () => {
const { manga } = await fetchMangaFull(mangaId, 'es')
const title = workTitle(manga.title)
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3)
return (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))).map(m => m.id)
}, true)
}
// Capítulo -> tomo según las publicaciones de MangaUpdates. Sirve para las obras licenciadas, que MangaDex deja sin capítulos.
async function muVolumes(mangaId) {
const l = await links(mangaId, true)
if (!l.mu) return {}
return stored(`mumap:${mangaId}`, v => (Object.keys(v).length ? 7 * DAY : 3 * DAY), async () => {
const rows = []
for (let page = 1; page <= 20; page++) {
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page })
rows.push(...(r?.results || []).map(x => x.record))
if (!r || page * 100 >= r.total_hits) break
}
return parseMuVolumes(rows)
}, true)
}
// ---------- Ficha ----------
const AL_FIELDS = `id idMal siteUrl title{romaji english native} synonyms description(asHtml:true) format status
startDate{year month day} endDate{year month day} chapters volumes countryOfOrigin isAdult
averageScore meanScore popularity favourites genres
tags{name rank isMediaSpoiler isGeneralSpoiler}
rankings{rank type allTime context year}
coverImage{extraLarge large} bannerImage
staff(perPage:12,sort:RELEVANCE){edges{role node{name{full native}}}}
externalLinks{site url type language}
relations{edges{relationType(version:2) node{id type format status title{romaji english} startDate{year} coverImage{large}}}}
recommendations(perPage:8,sort:RATING_DESC){nodes{rating mediaRecommendation{id type format title{romaji english} coverImage{large} averageScore}}}`
const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null)
const cover = u => (u ? imgUrl(u) : null)
async function info(mangaId, wait = false) {
const l = await links(mangaId, wait)
return stored(`info:${mangaId}`, INFO_TTL, async old => {
let failed = false
const [al, ms] = await Promise.all([
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
l.mu ? mu(`/series/${l.mu}`) : null,
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
const a = al?.Media
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
if (!a && !ms) return { found: false }
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
return {
found: true,
titles: {
romaji: a?.title?.romaji || decode(ms?.title || '') || null,
english: a?.title?.english || null,
native: a?.title?.native || null,
other: [...new Set([...(a?.synonyms || []), ...(ms?.associated || []).map(x => decode(x.title))])].slice(0, 12),
},
description: a?.description ? toText(a.description, decode) : ms?.description ? toText(ms.description, decode) : '',
format: a?.format || ms?.type || null,
country: a?.countryOfOrigin || null,
status: a?.status || null,
statusDetail: ms?.status ? decode(ms.status) : null,
start: date(a?.startDate) || (ms?.year ? String(ms.year) : null),
end: date(a?.endDate),
chapters: a?.chapters || null,
volumes: a?.volumes || null,
latestChapter: ms?.latest_chapter || null,
score: a?.averageScore ?? a?.meanScore ?? null,
muScore: ms?.bayesian_rating ?? null,
muVotes: ms?.rating_votes ?? null,
popularity: a?.popularity ?? null,
favourites: a?.favourites ?? null,
rankings: (a?.rankings || []).filter(r => r.allTime || r.year).slice(0, 4).map(r => ({ rank: r.rank, type: r.type, allTime: r.allTime, year: r.year })),
genres: a?.genres?.length ? a.genres : (ms?.genres || []).map(g => g.genre),
tags: (a?.tags || []).filter(t => !t.isMediaSpoiler && !t.isGeneralSpoiler && t.rank >= 60).slice(0, 14).map(t => t.name),
staff: staff.length ? staff : muAuthors,
magazines: (ms?.publications || []).map(p => ({ name: decode(p.publication_name), publisher: decode(p.publisher_name || '') })),
publishers: (ms?.publishers || []).map(p => ({ name: decode(p.publisher_name), type: p.type, notes: decode(p.notes || '') })),
licensed: ms?.licensed ?? null,
anime: ms?.anime?.start ? { start: decode(ms.anime.start), end: decode(ms.anime.end || '') } : null,
adaptations: (a?.relations?.edges || []).filter(e => e.node.type === 'ANIME').map(e => ({ title: e.node.title.romaji, format: e.node.format, year: e.node.startDate?.year || null, status: e.node.status })),
related: (a?.relations?.edges || []).filter(e => e.node.type === 'MANGA').slice(0, 8).map(e => ({ relation: e.relationType, title: e.node.title.english || e.node.title.romaji, format: e.node.format, year: e.node.startDate?.year || null, cover: cover(e.node.coverImage?.large) })),
recommendations: (a?.recommendations?.nodes || []).map(n => n.mediaRecommendation).filter(r => r && r.type === 'MANGA').slice(0, 8).map(r => ({ title: r.title.english || r.title.romaji, romaji: r.title.romaji, format: r.format, score: r.averageScore, cover: cover(r.coverImage?.large) })),
cover: cover(a?.coverImage?.extraLarge || a?.coverImage?.large),
banner: cover(a?.bannerImage),
links: [
a?.siteUrl ? { site: 'AniList', url: a.siteUrl } : null,
ms?.url ? { site: 'MangaUpdates', url: ms.url } : null,
a?.idMal || l.mal ? { site: 'MyAnimeList', url: `https://myanimelist.net/manga/${a?.idMal || l.mal}` } : null,
l.md ? { site: 'MangaDex', url: `https://mangadex.org/title/${l.md}` } : null,
...(a?.externalLinks || []).filter(x => x.type !== 'SOCIAL' || /official/i.test(x.site)).map(x => ({ site: x.site + (x.language ? ` (${x.language})` : ''), url: x.url })),
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
matched: { via: l.via || null, title: l.title || null },
}
}, wait)
}
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
function cachedInfo(mangaId) {
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
if (!row) return null
info(mangaId).catch(() => {})
return JSON.parse(row.value)
}
// ---------- Fechas de los capítulos ----------
async function dates(mangaId, wait = false) {
const l = await links(mangaId, wait)
if (!l.md && !l.mu) return { dates: {}, via: [] }
return stored(`dates:${mangaId}`, DAY, async old => {
let failed = false
const soft = p => p.catch(() => { failed = true; return null })
const out = {}
const via = []
const add = (n, d) => {
const k = numKey(n)
if (!k || !d) return
d = d.slice(0, 10)
if (!out[k] || d < out[k]) out[k] = d
}
if (l.md) {
let n = 0
for (let offset = 0; offset < 10000; offset += 500) {
const d = await soft(md(`/manga/${l.md}/feed`, {
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
}, 3600e3))
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
if (!d || offset + 500 >= d.total) break
}
if (n) via.push('MangaDex')
}
if (l.mu) {
let n = 0
for (let page = 1; page <= 40; page++) {
const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }))
for (const x of r?.results || []) {
const ch = String(x.record.chapter || '')
const d = x.record.release_date
// "415-416" o "12-12.5": todos los enteros del tramo y los extremos.
const m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(ch)
if (m) {
const [lo, hi] = [parseFloat(m[1]), parseFloat(m[2])]
add(lo, d); add(hi, d)
if (hi - lo < 50) for (let k = Math.ceil(lo); k <= hi; k++) add(k, d)
} else if (/^\d+(\.\d+)?$/.test(ch)) add(ch, d)
n++
}
if (!r || page * 100 >= r.total_hits) break
}
if (n) via.push('MangaUpdates')
}
if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
return { dates: out, via }
}, wait)
}
// ---------- Mangas seguidos: siempre con su información ----------
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
// y se reescalan los banners guardados que aún no lo estén.
let syncing = false
async function syncFollowed() {
if (syncing || !net.online()) return
syncing = true
try {
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
if (!net.online()) break
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
}
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
const { banner } = JSON.parse(value)
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
}
} finally { syncing = false }
}
setTimeout(syncFollowed, 60e3).unref()
setInterval(syncFollowed, SYNC_EVERY).unref()
net.whenOnline(syncFollowed)
async function route(p, method, res) {
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
if (!m || method !== 'GET') return false
const id = decodeURIComponent(m[1])
json(res, 200, await (m[2] ? dates(id) : info(id)))
return true
}
return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
}
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// Detector de rostros de anime, en un Worker para no trabar el estante.
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
const integral = (gray, w, h) => {
const I = new Int32Array((w + 1) * (h + 1))
for (let y = 0; y < h; y++) {
let row = 0
for (let x = 0; x < w; x++) {
row += gray[y * w + x]
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
}
}
return I
}
function equalize(gray) {
const hist = new Int32Array(256), n = gray.length
for (let i = 0; i < n; i++) hist[gray[i]]++
let i = 0
while (i < 255 && !hist[i]) i++
const out = new Uint8Array(n), first = hist[i]
if (first === n) return out.fill(i)
const scale = 255 / (n - first), lut = new Uint8Array(256)
let sum = 0
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
return out
}
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
for (let y = 0; y < H; y++) {
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
for (let x = 0; x < W; x++) {
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
}
}
return out
}
function scan(cascade, gray, W, H, step, emit) {
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
for (let wy = 0; wy + ch <= H; wy += step) {
for (let wx = 0; wx + cw <= W; wx += step) {
let pass = true
for (let s = 0; s < stages.length && pass; s++) {
const [limit, weak] = stages[s]
let sum = 0
for (let q = 0; q < weak.length; q++) {
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
const c = p10 - p9 - p6 + p5
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
}
if (sum < limit) pass = false
}
if (pass) emit(wx, wy)
}
}
}
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
function group(rects, minNeighbors, eps = 0.2) {
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
const by = new Map()
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
// Quita los que quedan dentro de otro mayor con más apoyos.
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
}
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
const raw = new Uint8Array(w * h)
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
const gray = equalize(raw), rects = []
for (let f = 1; ; f *= scale) {
const size = Math.round(cascade.w * f)
const W = Math.round(w / f), H = Math.round(h / f)
if (W < cascade.w || H < cascade.h) break
if (size < minSize) continue
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
}
return group(rects, minNeighbors)
}
let cascade = null
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
try {
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
} catch (e) { self.postMessage({ id, faces: null }) }
}
+4585
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+1066
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#bf78fa"/><path d="M14 16h16v32H14zM34 16h16v32H34z" fill="#14121a"/><path d="M18 22h8M18 28h8M18 34h6M38 22h8M38 28h8M38 34h6" stroke="#bf78fa" stroke-width="2.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 298 B

+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="sw-version" content="">
<meta name="b3d-version" content="">
<meta name="shelf-version" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a">
<title>Manga Reader</title>
<link rel="icon" href="/icon.svg">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mangas">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header id="topbar">
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
<nav>
<a href="#/" data-icon="home">Inicio</a>
<a href="#/library" data-icon="library">Librería</a>
<a href="#/shelf" data-icon="shelf">Estante</a>
<a href="#/queues" data-icon="queue">Colas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a>
<a href="#/settings" data-icon="settings">Ajustes</a>
</nav>
</header>
<main id="app"></main>
<div id="toast" hidden></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Manga Reader",
"short_name": "Mangas",
"description": "Lector de manga autoalojado",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#14121a",
"theme_color": "#14121a",
"lang": "es",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
File diff suppressed because it is too large Load Diff
+513
View File
@@ -0,0 +1,513 @@
[hidden] { display: none !important; }
:root {
--bg: #14121a;
--surface: #1e1b26;
--surface-2: #2a2635;
--text: #ece8f4;
--muted: #9a93ab;
--accent: #bf78fa;
--accent-ink: #14121a;
--danger: #ff6b81;
--radius: 10px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; color: inherit; }
img { display: block; }
#topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 16px; background: rgba(20, 18, 26, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--surface-2); }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
#topbar nav { display: flex; gap: 4px; }
#topbar nav a { padding: 6px 10px; border-radius: 8px; color: var(--muted); }
#topbar nav a.active, #topbar nav a:hover { color: var(--text); background: var(--surface-2); }
body.reading #topbar { display: none; }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
body.reading main { max-width: none; padding: 0; }
h1 { font-size: 1.6rem; margin: 0 0 8px; }
h2 { font-size: 1.15rem; margin: 24px 0 12px; }
.muted { color: var(--muted); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--surface-2);
border-radius: var(--radius); background: var(--surface); cursor: pointer; }
.btn:hover { background: var(--surface-2); }
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.small { padding: 5px 10px; font-size: .88rem; }
.btn.danger { color: var(--danger); }
input[type=text], input[type=password], input[type=search], select {
padding: 9px 12px; border-radius: var(--radius); border: 1px solid var(--surface-2); background: var(--surface); outline: none; }
input:focus, select:focus { border-color: var(--accent); }
.auth { max-width: 360px; margin: 10vh auto; background: var(--surface); padding: 24px; border-radius: 14px; }
.auth form { display: grid; gap: 12px; }
.error { color: var(--danger); min-height: 1.2em; margin: 0; }
.search { display: flex; gap: 8px; margin-bottom: 8px; }
.search input { flex: 1; min-width: 0; }
.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; }
.tabs button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--surface-2); background: transparent; cursor: pointer; color: var(--muted); }
.tabs button.on { background: var(--surface-2); color: var(--text); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }
.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); }
.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); }
/* Margen (no padding) para que el recorte de 2 líneas no deje asomar la 3.ª */
.card .t { padding: 0; margin: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; }
.bar { height: 4px; background: var(--surface-2); }
.bar > i { display: block; height: 100%; background: var(--accent); }
.continue { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 320px); gap: 12px; overflow-x: auto; padding-bottom: 6px; }
.cont { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius); background: var(--surface); align-items: center; }
.cont img { width: 56px; height: 84px; object-fit: cover; border-radius: 6px; background: var(--surface-2); flex: none; }
.cont .info { min-width: 0; flex: 1; }
.cont .info b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cont .bar { margin-top: 6px; border-radius: 2px; }
.detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
.detail .cover { width: 100%; border-radius: var(--radius); aspect-ratio: 2/3; object-fit: cover; background: var(--surface-2); }
.desc { white-space: pre-line; color: #cfc8dc; max-height: 12em; overflow: auto; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.tag { font-size: .78rem; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); }
.chapters { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.chapters a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); align-items: center; }
.chapters a:hover { background: var(--surface-2); }
.chapters a.read { opacity: .5; }
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
.chapters .voltoggle > span { display: grid; gap: 2px; }
.chapters .voltoggle .meta { text-align: left; }
.chapters .volhead > .btn { flex: none; }
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
.history { display: grid; gap: 10px; }
.history .cont { justify-content: space-between; }
.settings { max-width: 480px; display: grid; gap: 16px; }
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
.tableprev[hidden] { display: none; }
.loading { padding: 40px; text-align: center; color: var(--muted); }
/* ---- Lector ---- */
.reader { min-height: 100vh; background: #000; position: relative; }
/* Filtro de luz azul: tono ámbar multiplicado sobre las páginas (bajo las barras del lector). */
.reader.bluelight::after { content: ''; position: fixed; inset: 0; z-index: 25; pointer-events: none;
background: #ff9a3c; mix-blend-mode: multiply; opacity: .42; }
.rbar { position: fixed; left: 0; right: 0; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: rgba(10, 9, 14, .9); backdrop-filter: blur(6px); transition: transform .2s; flex-wrap: wrap; }
.rbar.top { top: 0; padding-top: max(8px, env(safe-area-inset-top)); }
.rbar.bottom { bottom: 0; justify-content: center; padding-bottom: max(8px, env(safe-area-inset-bottom)); }
.rbar.bottom.rtl, .rbar.bottom.rtl a.btn { flex-direction: row-reverse; }
/* Móvil: arriba la página y la barra de progreso; abajo cada botón en su extremo. */
@media (max-width: 640px) {
.rbar.bottom { justify-content: space-between; }
.rbar.bottom #pnum { order: -2; }
.rbar.bottom input[type=range] { order: -1; flex: 1 1 calc(100% - 80px); max-width: none; }
.rbar.bottom .chnext { margin-left: auto; }
.rbar.bottom.rtl .chnext { margin-left: 0; margin-right: auto; }
}
.rbar.bottom.rtl a.btn .ic, .paged[data-dir="rtl"] .chapter-end a.primary .ic { transform: scaleX(-1); }
.reader.hide-ui .rbar.top { transform: translateY(-110%); }
.reader.hide-ui .rbar.bottom { transform: translateY(110%); }
.rbar .ttl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .9rem; }
.rbar select { padding: 5px 8px; }
.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); }
#pages img { filter: var(--page-filter, none); transition: filter .2s, opacity .25s; }
/* Página que aún no ha llegado: indicador de carga y fundido al aparecer */
#pages .stage img.pending { opacity: 0; }
.paged.waiting::after, .paged .stage.loading::after { content: ''; position: absolute; left: 50%; top: 50%; width: 34px; height: 34px;
margin: -17px 0 0 -17px; border-radius: 50%; border: 3px solid rgba(255,255,255,.18); border-top-color: var(--accent);
animation: pgspin .8s linear infinite; z-index: 6; pointer-events: none; }
.paged .stage.loading { min-height: 100%; }
@keyframes pgspin { to { transform: rotate(360deg); } }
.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; }
.paged img { max-width: 100%; max-height: 100%; object-fit: contain; }
.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; }
.paged.fit-width img { max-height: none; width: 100%; max-width: 900px; }
.vertical { display: flex; flex-direction: column; align-items: center; }
.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; }
.vertical img[data-loaded] { min-height: 0; }
/* Vertical para mangas: cada página entera en pantalla, pegadas */
.vertical.fit-height img { width: auto; max-width: 100%; max-height: 100vh; max-height: 100dvh; }
.vertical.fit-height img:not([data-loaded]) { width: min(100%, 640px); }
.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; }
#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2);
padding: 10px 16px; border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.4); }
@media (max-width: 640px) {
.detail { grid-template-columns: 1fr; }
.detail .cover { max-width: 180px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; }
#topbar .brand span { display: none; }
#topbar nav a { padding: 6px 8px; }
}
/* Categorías y descargas */
.cats h3 { font-size: .95rem; color: var(--muted); margin: 18px 0 8px; }
a.tag { cursor: pointer; }
a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
.tag.big { font-size: .88rem; padding: 5px 12px; }
.dlbox { display: grid; gap: 6px; margin-top: 10px; max-width: 520px; }
.dlbar { height: 8px; border-radius: 4px; overflow: hidden; }
.dlbar > i { transition: width .6s ease; }
.history .cont a.info { display: block; min-width: 0; flex: 1; }
/* Iconos */
.ic { width: 1.15em; height: 1.15em; flex: none; vertical-align: -0.2em; }
#topbar nav a { display: inline-flex; align-items: center; gap: 6px; }
.tabs button { display: inline-flex; align-items: center; gap: 6px; }
.chapters .meta .ic { width: 1em; height: 1em; }
@media (max-width: 560px) {
#topbar nav a span { display: none; } /* en móvil solo iconos en el menú */
#topbar nav a { padding: 8px 10px; }
#topbar nav a .ic { width: 1.35em; height: 1.35em; }
.search .btn .lbl { display: none; }
}
/* Lector: escenario de páginas, doble página y zoom */
.paged { overflow: hidden; touch-action: none; }
.paged.fit-width:not(.zoomed) { overflow: visible; touch-action: pan-y; }
.paged .stage { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; transform-origin: 0 0; will-change: transform; }
.paged.fit-width .stage { height: auto; align-items: flex-start; }
.paged[data-dir="rtl"] .stage { flex-direction: row-reverse; }
.paged .stage.two img { max-width: 50%; }
.paged.fit-width .stage.two img { width: 50%; max-width: 700px; }
.paged img { -webkit-user-drag: none; user-select: none; }
.paged.zoomed { cursor: grab; }
/* Insignias y botón Seguir */
.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; }
.chapters .meta .queued { opacity: .55; }
.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); }
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
.btn.on { border-color: var(--accent); color: var(--accent); }
h2 .ic { color: var(--accent); }
/* Estadísticas */
:root { --viz-1: #5b3f8c; --viz-2: #7d4fc0; --viz-3: #a067e3; --viz-4: #c993ff; --viz-5: #ecd6ff; }
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; }
.tile .tv { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
.tile .tl { color: var(--muted); font-size: .88rem; }
.tile .ts { color: var(--muted); font-size: .78rem; margin-top: 4px; }
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
.panel { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; min-width: 0; }
.panel h3 { margin: 0 0 10px; font-size: .95rem; }
.chart { width: 100%; height: auto; display: block; }
.chart .grid { stroke: var(--surface-2); stroke-width: 1; }
.chart .axis { fill: var(--muted); font-size: 11px; }
.chart .mark { fill: var(--accent); }
.chart .hit { fill: transparent; }
.chart .col:hover .hit { fill: rgba(255, 255, 255, .04); }
.heat-wrap { overflow-x: auto; }
.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-auto-columns: 14px; gap: 3px; }
.cell { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: var(--surface-2); }
.cell.empty { background: transparent; }
.cell.l1 { background: var(--viz-1); } .cell.l2 { background: var(--viz-2); } .cell.l3 { background: var(--viz-3); }
.cell.l4 { background: var(--viz-4); } .cell.l5 { background: var(--viz-5); }
.heat-legend { display: flex; align-items: center; gap: 3px; font-size: .8rem; margin-top: 8px; }
.hbars { display: grid; gap: 8px; }
.hbar { display: grid; grid-template-columns: minmax(90px, 140px) 1fr 3em; gap: 8px; align-items: center; font-size: .88rem; }
.hbar .hl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar .ht { height: 10px; background: var(--surface-2); border-radius: 0 4px 4px 0; }
.hbar .ht i { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
.hbar .hv { text-align: right; color: var(--muted); }
.toplist { display: grid; gap: 8px; }
.toplist .cont { padding: 6px 8px; background: var(--bg); }
.toplist .cont img { width: 36px; height: 54px; }
.toplist .rank { width: 1.4em; text-align: center; color: var(--muted); font-weight: 700; }
details.panel summary { cursor: pointer; color: var(--muted); }
table.data { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: .88rem; }
table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--surface-2); }
table.data td:not(:first-child), table.data th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
#tip { position: fixed; z-index: 60; pointer-events: none; background: #000; color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: .82rem; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 280px; }
.rpanel { position: fixed; top: 52px; right: 8px; left: auto; z-index: 31; display: grid; gap: 8px; padding: 12px; width: min(320px, calc(100vw - 16px));
background: rgba(10, 9, 14, .96); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,.5); }
.rpanel select, .rpanel label { width: 100%; }
.rpanel p { margin: 0; font-size: .78rem; }
.reader.hide-ui .rpanel { display: none; }
.chart .axis { font-size: 12px; }
/* Ficha: acciones, menú ⋯ y panel de biblioteca local */
.detail-body { min-width: 0; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.btn.on { border-color: var(--accent); color: var(--accent); }
.facts { display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: .85rem; margin-top: 10px; }
.menu { position: relative; }
.menu > summary { list-style: none; padding-left: 12px; padding-right: 12px; }
.menu > summary::-webkit-details-marker { display: none; }
.menu[open] > summary { background: var(--surface-2); }
.menu-list { position: absolute; left: 0; top: calc(100% + 6px); z-index: 15; min-width: 230px; display: grid; padding: 6px;
background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 10px 28px rgba(0,0,0,.45); }
.menu-list a, .menu-list button { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: 8px;
background: none; text-align: left; cursor: pointer; white-space: nowrap; }
.menu-list a:hover, .menu-list button:hover { background: var(--surface-2); }
.menu-list .danger { color: var(--danger); }
.libpanel { margin-top: 20px; max-width: 640px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; }
.psec, .psec-grid { display: grid; gap: 10px; }
.psec { padding: 14px 16px; }
.psec + .psec { border-top: 1px solid var(--surface-2); }
.psec-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; }
.psec-title { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.psec-title .ic { color: var(--accent); }
.psec-status { font-size: .85rem; display: inline-flex; align-items: center; gap: 4px; }
.psec-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.psec-actions .push { margin-left: auto; }
.note { font-size: .83rem; margin: 0; }
.psec .dlbar { height: 6px; }
.check { display: flex; align-items: center; gap: 8px; font-size: .85rem; color: var(--muted); cursor: pointer; }
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; flex: none; }
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
.hdform { display: grid; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--surface-2); }
.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; justify-self: start; border-radius: 10px; background: var(--surface); border: 1px solid var(--surface-2); }
.seg button { border: 0; background: none; padding: 5px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-size: .88rem; }
.seg button.on { background: var(--surface-2); color: var(--text); }
.range label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; }
.range select { padding: 6px 10px; }
.sum { font-size: .9rem; font-weight: 600; }
.list-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 28px 0 12px; }
.list-head h2 { margin: 0; }
.list-head select { padding: 5px 10px; font-size: .88rem; }
.chapters label.pick { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); cursor: pointer; user-select: none; }
.chapters label.pick:hover { background: var(--surface-2); }
.chapters label.pick .t { flex: 1; min-width: 0; }
.chapters label.pick.off { opacity: .4; cursor: default; }
.chapters label.pick:has(input:checked) { outline: 1px solid var(--accent); }
#selbar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 25; display: flex; align-items: center; gap: 10px;
max-width: calc(100% - 32px); padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--surface-2); border: 1px solid #3a3548;
box-shadow: 0 10px 28px rgba(0,0,0,.5); font-size: .9rem; }
#selbar span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapters.picking { padding-bottom: 72px; }
@media (max-width: 640px) {
.actions .btn.primary { flex: 1 1 100%; }
.libpanel { max-width: none; }
.psec-actions .push { margin-left: 0; }
.seg { display: grid; grid-template-columns: repeat(3, 1fr); justify-self: stretch; }
#selbar { left: 12px; right: 12px; transform: none; max-width: none; }
#selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; }
}
/* Recortes: herramienta del lector */
.cropov { position: fixed; inset: 0; z-index: 40; cursor: crosshair; touch-action: none; background: rgba(0, 0, 0, .25); user-select: none; }
.crop-bar { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1;
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: rgba(20, 18, 26, .94);
box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); }
.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; }
.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; }
.crop-h { position: absolute; width: 44px; height: 44px; pointer-events: auto; touch-action: none; }
.crop-h::after { content: ''; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px; box-sizing: border-box; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.6); }
.crop-h[data-h="nw"] { left: -23px; top: -23px; cursor: nwse-resize; }
.crop-h[data-h="ne"] { right: -23px; top: -23px; cursor: nesw-resize; }
.crop-h[data-h="sw"] { left: -23px; bottom: -23px; cursor: nesw-resize; }
.crop-h[data-h="se"] { right: -23px; bottom: -23px; cursor: nwse-resize; }
.crop-bar em { font-style: normal; }
@media (max-width: 560px) { .crop-bar span { display: none; } }
/* Recortes: galería */
.clips { columns: 220px; column-gap: 12px; }
.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; }
.clip img { display: block; width: 100%; height: auto; max-height: 520px; object-fit: cover; object-position: top; background: var(--surface-2); }
.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; }
.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; }
.clip figcaption span { color: var(--muted); }
.clip figcaption i { color: var(--text); opacity: .85; }
.clip:hover { outline: 1px solid var(--accent); }
@media (max-width: 560px) { .clips { columns: 2; column-gap: 8px; } .clip { margin-bottom: 8px; } }
.lightbox { position: fixed; inset: 0; z-index: 45; background: rgba(8, 7, 12, .94); display: grid; grid-template-columns: 1fr 320px; }
.lightbox[hidden] { display: none; }
.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; }
.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
/* Recorte largo de lectura vertical: a su ancho y con desplazamiento; entero en pantalla sería una tira ilegible. */
.lb-img.long { align-items: flex-start; }
.lb-img.long img { max-height: none; height: auto; flex: none; }
.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
.lb-side h2 a { color: var(--text); }
body.noscroll { overflow: hidden; }
@media (max-width: 760px) {
.lightbox { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.lb-img { padding: 10px; }
.lb-side { max-height: 48vh; padding: 12px 14px max(12px, env(safe-area-inset-bottom)); }
}
/* Historial: tarjetas verticales */
.hcard .bar { margin: 0; height: 5px; }
.hcard .t { margin-bottom: 4px; }
.hcard .sub { margin-top: 0; }
.hcard .corner { position: absolute; top: 8px; left: 8px; }
.hdel { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; border: 0;
background: rgba(20, 18, 26, .75); color: var(--muted); cursor: pointer; opacity: 0; transition: opacity .15s; }
.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; }
.hdel:hover { color: var(--danger); }
@media (hover: none) { .hdel { opacity: 1; } }
/* Colas: tarjetas verticales como en Historial */
.qsec { margin-bottom: 26px; }
.qsec h2 { display: flex; align-items: center; gap: 8px; }
.qcard { display: flex; flex-direction: column; }
.qcard > a { color: inherit; }
.qcard .sub { display: grid; gap: 1px; padding-bottom: 8px; }
.qcard .sub .error { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.qact { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; margin-top: auto; }
.qact .btn { flex: 1; padding: 6px 8px; font-size: .8rem; }
.qpos { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(20, 18, 26, .85); color: var(--text); font-size: .8rem; font-weight: 700; }
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
.small { font-size: .82rem; }
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
/* Librería */
.libtabs button .muted { font-size: .78rem; }
.lhead { gap: 8px; flex-wrap: wrap; margin: -4px 0 12px; }
.lhead .small { flex: 1; min-width: 200px; }
.lcheck { padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.lcheck:hover { background: var(--surface-2); }
.chapters .meta .dev .ic { color: var(--accent); }
/* Recomendaciones: fila desplazable */
.rhead { gap: 10px; flex-wrap: wrap; align-items: baseline; }
.rhead h2 { margin-bottom: 4px; }
.rhead > span.small { flex: 1; min-width: 180px; }
.rhead .small a { color: var(--accent); }
.recrow { display: grid; grid-auto-flow: column; grid-auto-columns: 140px; gap: 12px; overflow-x: auto; padding-bottom: 8px; scroll-snap-type: x mandatory; }
.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; }
/* Animación al pasar página (modos por página) */
.paged { position: relative; perspective: 2200px; }
.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; }
.paged.fit-width .stage.leaving { bottom: auto; }
.paged .curlgl { position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; filter: var(--page-filter, none); }
.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
.paged .curl .sf { position: absolute; top: 0; bottom: 0; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
.paged .curl .back { transform: rotateY(180deg); }
.paged .curl .back.paper { background: #e9e5dc; }
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
/* Hendidura del lomo: la página se funde con el negro de detrás. Los puntos coinciden con gutter() en app.js. */
.paged img[data-gutter="l"], .paged img[data-gutter="r"] {
--gutter-stops: rgba(0,0,0,.4) 0, rgba(0,0,0,.6) 1.2%, rgba(0,0,0,.8) 3%, rgba(0,0,0,.93) 6%, #000 10%;
--gutter-mask: linear-gradient(var(--gutter-side), var(--gutter-stops));
-webkit-mask-image: var(--gutter-mask);
mask-image: var(--gutter-mask);
}
.paged img[data-gutter="l"] { --gutter-side: to right; }
.paged img[data-gutter="r"] { --gutter-side: to left; }
.paged .panmask { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: #000; }
.paged .leafkeepback { position: absolute; z-index: 2; pointer-events: none; background: #000; }
.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
/* Info de la obra */
.chapters .meta .cdate { white-space: nowrap; }
.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
.banner[hidden] { display: none; }
.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
.detail-body .info-block h2:first-child { margin-top: 18px; }
.detail-body .info-block + .tags { margin-top: 14px; }
.drawer .kv { margin: 0; }
.info-block .desc.long { max-height: none; }
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
.info-block > .note { margin-top: 16px; }
.kv:empty, #mscores:empty { display: none; }
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
.list-head { scroll-margin-top: 64px; }
/* Menú lateral de la ficha (botón ⋯) */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
.drawer.open { transform: none; visibility: visible; }
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
.drawer-opts { display: grid; gap: 8px; }
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
.drawer-back[hidden] { display: none; }
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
@keyframes libpulse { 50% { opacity: .25; } }
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
.score b { font-size: 1.25rem; }
.score span { font-size: .75rem; color: var(--muted); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 14px 0; font-size: .92rem; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
.tag.hot { color: var(--accent); }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; }
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
#shelf3d canvas { display: block; width: 100%; height: 100%; }
.shelftools { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); display: flex; gap: 8px; }
.shelfnote:empty { display: none; } /* vacía no se ve; [hidden] dice que ya llegaron los tomos */
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
.libhead { justify-content: space-between; align-items: center; }
#b3d { position: fixed; inset: 0; z-index: 0; }
#b3d[hidden] { display: none; }
#b3d canvas { display: block; width: 100%; height: 100%; }
.reader.b3d { background: #4a2e19; }
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
.reader.b3d > #pages img { visibility: hidden; }
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
.reader.b3d > #pages > * { pointer-events: none; }
/* El final del capítulo, sobre la contraportada y con sus colores. */
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
/* Para recortar se ve la página plana. */
.reader.b3d.b3dcrop { background: #000; }
.reader.b3d.b3dcrop > #b3d { display: none; }
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
/* Vistazo del estante: la nota del tomo sacado, como un pósit pegado junto a él (a la derecha en apaisado, abajo en vertical).
Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */
.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg;
position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np);
box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); }
.shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); }
.shelfpeek .ptitle { margin: 0 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; }
.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; }
.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; }
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; }
.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); }
.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; }
@media (orientation: portrait) {
.shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); }
.shelfpeek.on { transform: rotate(var(--nt)); }
}
body.handing .reader .loading { visibility: hidden; }
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
.handoff.out { opacity: 0; }
@media (max-width: 420px) { #topbar nav a { padding: 8px 7px; } }
+80
View File
@@ -0,0 +1,80 @@
// Service worker: la app abre sin conexión (shell en caché) y los capítulos guardados "en este
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
async function installShell() {
const c = await caches.open(SHELL)
const html = await fetch('/', { cache: 'reload' })
const text = await html.clone().text()
const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1])
const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))]
for (const f of files) await c.delete(f, { ignoreSearch: true })
await c.addAll(files.map(u => new Request(u, { cache: 'reload' })))
await c.put('/', html)
}
self.addEventListener('install', e => {
e.waitUntil(installShell().then(() => self.skipWaiting()))
})
self.addEventListener('activate', e => {
e.waitUntil(caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
.then(() => self.clients.claim()))
})
// 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 })
}
// single: guardar solo la última versión (app.js?v=… sustituye a la anterior).
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) {
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 (single) await c.delete(req, { ignoreSearch: true })
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
}
return res
})
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 => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== location.origin) return
const p = url.pathname
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true }))
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true }))
// Imágenes: si están guardadas, sin red (más rápido y sin gastar datos).
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) {
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
}
})
+148
View File
@@ -0,0 +1,148 @@
// Seguridad para exponer el servidor a internet: IP real del cliente (detrás de un proxy de
// confianza), baneo de IP tras varios intentos fallidos, cabeceras de seguridad y protección SSRF
// del proxy de imágenes.
import net from 'node:net'
import dns from 'node:dns/promises'
const MAX_FAILS = Number(process.env.BAN_MAX_FAILS || 6) // intentos fallidos…
const FAIL_WINDOW = Number(process.env.BAN_WINDOW_MIN || 60) * 60e3 // …dentro de esta ventana
const BAN_MS = Number(process.env.BAN_HOURS || 24) * 3600e3 // duración del baneo
// Cabecera con la IP real que pone el proxy (p. ej. x-forwarded-for, cf-connecting-ip, x-real-ip).
// Solo se cree si la conexión viene de una IP privada (el proxy), nunca directamente de internet.
const REAL_IP_HEADER = (process.env.REAL_IP_HEADER || '').toLowerCase().trim()
const PRIVATE_V4 = [['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['100.64.0.0', 10], ['0.0.0.0', 8]]
const v4num = ip => ip.split('.').reduce((n, x) => n * 256 + Number(x), 0)
export function isPrivateIp(ip) {
if (!ip) return true
ip = ip.replace(/^::ffff:/i, '')
if (net.isIPv4(ip)) {
const n = v4num(ip)
return PRIVATE_V4.some(([base, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4num(base) / 2 ** (32 - bits)))
}
const l = ip.toLowerCase()
return l === '::1' || l === '::' || /^f[cd]/.test(l) || /^fe[89ab]/.test(l)
}
// IPs que nunca se banean: loopback, red local, Tailscale y lo que se añada en BAN_IGNORE.
const IGNORE = (process.env.BAN_IGNORE || '').split(',').map(s => s.trim()).filter(Boolean)
const neverBan = ip => isPrivateIp(ip) || IGNORE.includes(ip)
export function setupSecurity({ db }) {
db.exec(`
CREATE TABLE IF NOT EXISTS ip_bans (
ip TEXT PRIMARY KEY,
until INTEGER NOT NULL,
reason TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ip_fails (
ip TEXT NOT NULL,
at INTEGER NOT NULL,
what TEXT
);
CREATE INDEX IF NOT EXISTS idx_ip_fails ON ip_fails(ip, at);
`)
const prune = () => {
db.prepare('DELETE FROM ip_bans WHERE until < ?').run(Date.now())
db.prepare('DELETE FROM ip_fails WHERE at < ?').run(Date.now() - Math.max(FAIL_WINDOW, 24 * 3600e3))
}
prune()
setInterval(prune, 3600e3).unref()
function clientIp(req) {
const direct = (req.socket.remoteAddress || '').replace(/^::ffff:/i, '')
if (REAL_IP_HEADER && isPrivateIp(direct)) {
// x-forwarded-for: "cliente, proxy1, proxy2". Cada proxy añade al final la IP que le habló, así
// que se recorre desde la derecha saltando los proxies propios (IPs privadas: VPS por Tailscale,
// Nginx Proxy Manager en Docker…). La primera pública es el cliente real; lo que haya más a la
// izquierda lo pudo escribir el propio cliente y no se cree.
const chain = String(req.headers[REAL_IP_HEADER] || '').split(',').map(s => s.trim().replace(/^::ffff:/i, '')).filter(s => net.isIP(s))
const ip = chain.findLast(s => !isPrivateIp(s)) ?? chain[0]
if (ip) return ip
}
return direct
}
// HTTPS real (directo o por el proxy de confianza): cookie Secure y HSTS.
const isHttps = req => !!req.socket.encrypted || (isPrivateIp(req.socket.remoteAddress) && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https')
const banned = ip => db.prepare('SELECT until FROM ip_bans WHERE ip = ? AND until > ?').get(ip, Date.now())
// Intento fallido (contraseña incorrecta, registro cerrado…). Al llegar a MAX_FAILS, baneo.
function fail(ip, what) {
if (neverBan(ip)) return
const now = Date.now()
db.prepare('INSERT INTO ip_fails (ip, at, what) VALUES (?, ?, ?)').run(ip, now, what)
const n = db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, now - FAIL_WINDOW).n
if (n >= MAX_FAILS) {
db.prepare(`INSERT INTO ip_bans (ip, until, reason, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT (ip) DO UPDATE SET until = excluded.until, reason = excluded.reason, created_at = excluded.created_at`)
.run(ip, now + BAN_MS, `${n} intentos fallidos (${what})`, now)
db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
console.warn(`Seguridad: IP ${ip} baneada ${BAN_MS / 3600e3} h tras ${n} intentos fallidos (${what})`)
}
}
const success = ip => db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
const remaining = ip => (neverBan(ip) ? null : Math.max(0, MAX_FAILS - db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, Date.now() - FAIL_WINDOW).n))
// Cabeceras para todas las respuestas.
function headers(req, res) {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Referrer-Policy', 'same-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Content-Security-Policy', [
"default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:",
"connect-src 'self'", "worker-src 'self'", "manifest-src 'self'", "font-src 'self'",
"object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'",
].join('; '))
if (isHttps(req)) res.setHeader('Strict-Transport-Security', 'max-age=15552000')
}
function route(p, method, req, res, json) {
if (p === '/api/bans' && method === 'GET') {
return json(res, 200, {
bans: db.prepare('SELECT ip, until, reason, created_at FROM ip_bans WHERE until > ? ORDER BY created_at DESC').all(Date.now()),
fails: db.prepare('SELECT ip, COUNT(*) n, MAX(at) last FROM ip_fails WHERE at > ? GROUP BY ip ORDER BY last DESC LIMIT 50').all(Date.now() - FAIL_WINDOW),
maxFails: MAX_FAILS, banHours: BAN_MS / 3600e3, windowMin: FAIL_WINDOW / 60e3, trustedHeader: REAL_IP_HEADER || null,
you: { ip: clientIp(req), protected: neverBan(clientIp(req)) },
}), true
}
const m = /^\/api\/bans\/([^/]+)$/.exec(p)
if (m && method === 'DELETE') {
const ip = decodeURIComponent(m[1])
db.prepare('DELETE FROM ip_bans WHERE ip = ?').run(ip)
db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
return json(res, 200, { ok: true }), true
}
return false
}
return { clientIp, isHttps, banned, fail, success, remaining, headers, route }
}
// ---------- SSRF: el proxy de imágenes no debe alcanzar la red interna ----------
const dnsCache = new Map()
async function hostIsPublic(hostname) {
if (net.isIP(hostname)) return !isPrivateIp(hostname)
if (/^(localhost|.*\.local|.*\.internal|.*\.lan)$/i.test(hostname)) return false
const hit = dnsCache.get(hostname)
if (hit && hit.exp > Date.now()) return hit.ok
const addrs = await dns.lookup(hostname, { all: true }).catch(() => [])
if (!addrs.length) return false // fallo de DNS: puede ser pasajero, no se recuerda
const ok = addrs.every(a => !isPrivateIp(a.address))
dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 })
if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value)
return ok
}
// fetch que solo va a hosts públicos y comprueba también cada redirección.
export async function safeFetch(url, opts = {}, redirects = 4) {
for (let i = 0; ; i++) {
const u = new URL(url)
if (!/^https?:$/.test(u.protocol) || !(await hostIsPublic(u.hostname))) throw new Error('Host no permitido')
const r = await fetch(u, { ...opts, redirect: 'manual' })
const loc = r.status >= 300 && r.status < 400 && r.headers.get('location')
if (!loc) return r
if (i >= redirects) throw new Error('Demasiadas redirecciones')
url = new URL(loc, u).toString()
}
}
+1222
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
// Estante 3D: las obras de un usuario que pueden ir en el mueble. Los tomos de cada una se piden aparte (/api/volumes), porque pueden tardar.
import { hasNoVolumes } from './volumes.js'
export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) {
function works(userId) {
const out = new Map()
const add = (id, fields) => {
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null }
w.title ||= fields.title || ''
w.cover ||= fields.cover || null
w.last = Math.max(w.last, fields.at || 0)
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null })
out.set(id, w)
}
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at })
}
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })
}
for (const r of db.prepare(`SELECT manga_id, title, folder, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS}`).all(userId)) {
add(r.manga_id, { title: r.title, cover: downloadCover(r.manga_id, r.folder, r.meta_cover), at: r.updated_at })
}
for (const r of db.prepare('SELECT i.manga_id, i.title, i.cover, i.added_at FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.added_at })
}
const typeOf = db.prepare('SELECT type FROM manga_meta WHERE manga_id = ?')
return [...out.values()].filter(w => !isBookId(w.id) && !hasNoVolumes(typeOf.get(w.id)?.type)).sort((a, b) => b.last - a.last)
}
function route(p, method, res, user) {
if (p !== '/api/shelf' || method !== 'GET') return false
json(res, 200, { works: works(user.id) })
return true
}
return { route, works }
}
+280
View File
@@ -0,0 +1,280 @@
// Reescalado (HD) de capítulos descargados con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri).
// El original no se toca: la versión HD va a <carpeta>/.hd/<mismo nombre>.cbz (oculta para Komga/Kavita).
// Una cola persistente en SQLite procesa un capítulo cada vez.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js'
export const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan'
export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models')
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
export function run(cmd, args, onSpawn) {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] })
onSpawn?.(p)
let err = ''
p.stderr.on('data', d => { err = (err + d).slice(-2000) })
p.on('error', reject)
p.on('close', (code, signal) => (code === 0 ? resolve() : reject(new Error(signal ? `interrumpido (${signal})` : `${path.basename(cmd)} salió con ${code}: ${err.trim().split('\n').filter(l => !/^\d+(\.\d+)?%$/.test(l)).slice(-2).join(' ')}`))))
})
}
export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) {
const cols = t => db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name)
if (!cols('downloaded_chapters').includes('hd_file')) db.exec('ALTER TABLE downloaded_chapters ADD COLUMN hd_file TEXT')
if (!cols('downloads').includes('hd_auto')) db.exec('ALTER TABLE downloads ADD COLUMN hd_auto INTEGER NOT NULL DEFAULT 0')
db.exec(`
CREATE TABLE IF NOT EXISTS upscale_jobs (
chapter_id TEXT PRIMARY KEY REFERENCES downloaded_chapters(chapter_id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
status TEXT NOT NULL, -- queued | running | error
error TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_upscale_manga ON upscale_jobs(manga_id);
`)
db.prepare("UPDATE upscale_jobs SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad
// Capítulos aún sin descargar que deben reescalarse en cuanto lleguen (descarga anticipada con HD).
db.exec('CREATE TABLE IF NOT EXISTS upscale_wanted (chapter_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)')
db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)')
// Segundos por página medidos (media móvil), para estimar cuánto tardará una cola.
let secPerPage = Number(db.prepare("SELECT v FROM kv WHERE k = 'hd_sec_per_page'").get()?.v) || 13
let hasCwebp = false
const available = () => fs.existsSync(BIN) && fs.existsSync(path.join(MODELS, `${MODEL}-x${SCALE}.bin`)) && fs.existsSync('/dev/dri') && hasCwebp
run('cwebp', ['-version']).then(() => { hasCwebp = true; if (available()) pump() }, () => {})
// Capítulo en curso (solo en memoria).
let current = null // { chapterId, mangaId, num, pages, total, proc }
const chapterRow = chapterId => db.prepare(`SELECT c.chapter_id, c.manga_id, c.num, c.file, c.hd_file, d.folder FROM downloaded_chapters c
JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId)
async function upscaleChapter(job) {
const row = chapterRow(job.chapter_id)
if (!row) return
const dir = path.join(MANGA_DIR, row.folder)
const src = path.join(dir, row.file)
const entries = readCbzIndex(src)
current = { chapterId: row.chapter_id, mangaId: row.manga_id, num: row.num, pages: 0, total: entries.length, proc: null }
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hd-'))
try {
const inDir = path.join(work, 'in'), outDir = path.join(work, 'out')
fs.mkdirSync(inDir); fs.mkdirSync(outDir)
const names = entries.map((e, i) => {
const ext = e.name.split('.').pop().toLowerCase()
const name = `${String(i + 1).padStart(4, '0')}.${ext}`
fs.writeFileSync(path.join(inDir, name), readCbzEntry(src, e))
return name
})
// Real-ESRGAN procesa la carpeta entera; el progreso se mide contando las salidas.
const poll = setInterval(() => { try { current.pages = fs.readdirSync(outDir).length } catch {} }, 1500)
try {
await run(BIN, ['-i', inDir, '-o', outDir, '-m', MODELS, '-n', MODEL, '-s', String(SCALE), '-f', 'png'], p => { current.proc = p })
} finally { clearInterval(poll) }
if (!db.prepare('SELECT 1 FROM upscale_jobs WHERE chapter_id = ?').get(row.chapter_id)) return // cancelado
const out = []
for (const name of names) {
const base = name.replace(/\.[^.]+$/, '')
const png = path.join(outDir, `${base}.png`)
if (UPSCALABLE.test(name) && fs.existsSync(png)) {
const webp = path.join(work, `${base}.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 {
out.push({ name, data: fs.readFileSync(path.join(inDir, name)) }) // sin reescalar
}
}
if (!chapterRow(row.chapter_id)) return // la descarga se borró mientras tanto
fs.mkdirSync(path.join(dir, HD_DIR), { recursive: true })
const hdFile = path.join(HD_DIR, row.file)
writeCbz(path.join(dir, hdFile), out)
const ok = db.prepare('UPDATE downloaded_chapters SET hd_file = ? WHERE chapter_id = ?').run(hdFile, row.chapter_id).changes
if (!ok) fs.rmSync(path.join(dir, hdFile), { force: true }) // la descarga se borró mientras tanto
return entries.length
} finally {
fs.rmSync(work, { recursive: true, force: true })
current = null
}
}
let pumping = false
async function pump() {
if (pumping || !available()) return
pumping = true
try {
for (;;) {
const job = db.prepare("SELECT * FROM upscale_jobs WHERE status = 'queued' ORDER BY created_at, rowid LIMIT 1").get()
if (!job) break
db.prepare("UPDATE upscale_jobs SET status = 'running' WHERE chapter_id = ?").run(job.chapter_id)
const t = Date.now()
try {
const pages = await upscaleChapter(job)
db.prepare('DELETE FROM upscale_jobs WHERE chapter_id = ?').run(job.chapter_id)
const secs = (Date.now() - t) / 1000
if (pages) {
secPerPage = secPerPage * 0.7 + (secs / pages) * 0.3
db.prepare("INSERT INTO kv (k, v) VALUES ('hd_sec_per_page', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v").run(secPerPage.toFixed(2))
}
console.log(`HD: ${job.chapter_id} en ${Math.round(secs)} s (${pages || 0} págs.)`)
} catch (e) {
console.warn('HD', job.chapter_id, e.message)
db.prepare("UPDATE upscale_jobs SET status = 'error', error = ? WHERE chapter_id = ?").run(e.message.slice(0, 500), job.chapter_id)
}
}
} finally {
pumping = false
}
}
function enqueue(chapterIds) {
const ins = db.prepare(`INSERT INTO upscale_jobs (chapter_id, manga_id, status, created_at)
SELECT chapter_id, manga_id, 'queued', ? FROM downloaded_chapters WHERE chapter_id = ? AND hd_file IS NULL
ON CONFLICT (chapter_id) DO UPDATE SET status = 'queued', error = NULL WHERE status = 'error'`)
const now = Date.now()
let n = 0
for (const id of chapterIds) n += ins.run(now, id).changes
pump()
return n
}
function cancel(mangaId) {
db.prepare('DELETE FROM upscale_jobs WHERE manga_id = ?').run(mangaId)
if (current?.mangaId === mangaId) current.proc?.kill('SIGTERM')
}
// Ruta absoluta del CBZ HD de un capítulo, o null.
function hdFileOf(chapterId) {
const row = chapterRow(chapterId)
if (!row?.hd_file) return null
const file = path.join(MANGA_DIR, row.folder, row.hd_file)
return fs.existsSync(file) ? file : null
}
function info(mangaId) {
const dl = db.prepare('SELECT folder, hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)
if (!dl) return { available: available() }
const rows = db.prepare('SELECT chapter_id, pages, hd_file FROM downloaded_chapters WHERE manga_id = ?').all(mangaId)
const jobs = db.prepare('SELECT chapter_id, status, error FROM upscale_jobs WHERE manga_id = ?').all(mangaId)
let size = 0
try { for (const f of fs.readdirSync(path.join(MANGA_DIR, dl.folder, HD_DIR))) size += fs.statSync(path.join(MANGA_DIR, dl.folder, HD_DIR, f)).size } catch {}
const cur = current?.mangaId === mangaId ? { chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total } : null
return {
available: available(), auto: !!dl.hd_auto, downloaded: rows.length, hd: rows.filter(r => r.hd_file).length, size, current: cur,
chapters: rows.filter(r => r.hd_file).map(r => r.chapter_id),
pages: Object.fromEntries(rows.map(r => [r.chapter_id, r.pages])), // para estimar tiempos
secPerPage: Math.round(secPerPage * 10) / 10,
jobs: Object.fromEntries(jobs.map(j => [j.chapter_id, j.status])),
error: (j => j && `Cap. ${db.prepare('SELECT num FROM downloaded_chapters WHERE chapter_id = ?').get(j.chapter_id)?.num ?? '?'}: ${j.error}`)(jobs.find(j => j.status === 'error')) || null,
}
}
// Cola global (página "Colas"): por manga, en el orden en que se procesan.
function queue() {
const rows = db.prepare(`SELECT j.chapter_id, j.manga_id, j.status, j.error, j.created_at, c.num, c.pages, d.title
FROM upscale_jobs j JOIN downloaded_chapters c ON c.chapter_id = j.chapter_id JOIN downloads d ON d.manga_id = j.manga_id
ORDER BY j.created_at, j.rowid`).all()
const groups = new Map()
for (const r of rows) {
let g = groups.get(r.manga_id)
if (!g) groups.set(r.manga_id, g = { mangaId: r.manga_id, title: r.title, queued: 0, pages: 0, errors: [], next: [] })
if (r.status === 'error') g.errors.push({ chapterId: r.chapter_id, num: r.num, error: r.error })
else {
g.queued++
g.pages += r.pages || 0
if (g.next.length < 5 && r.chapter_id !== current?.chapterId) g.next.push(r.num)
}
}
const cur = current && { mangaId: current.mangaId, chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total }
// Páginas que faltan (la del capítulo en curso cuenta solo lo que le queda).
for (const g of groups.values()) {
if (cur?.mangaId === g.mangaId) g.pages = Math.max(0, g.pages - (rows.find(r => r.chapter_id === cur.chapterId)?.pages || 0) + (cur.total - cur.pages))
g.eta = Math.round(g.pages * secPerPage)
}
const list = [...groups.values()].sort((a, b) => (b.mangaId === cur?.mangaId) - (a.mangaId === cur?.mangaId))
return { available: available(), current: cur, groups: list, secPerPage: Math.round(secPerPage * 10) / 10, eta: list.reduce((n, g) => n + g.eta, 0) }
}
// Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo.
function onChapterDownloaded(mangaId, chapterId) {
const wanted = db.prepare('DELETE FROM upscale_wanted WHERE chapter_id = ?').run(chapterId).changes
if (wanted || db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId])
}
async function route(p, method, req, res) {
const m = /^\/api\/upscale\/([^/]+)(\/cancel|\/auto)?$/.exec(p)
if (!m || !MANGA_ID.test(m[1])) return false
const mangaId = m[1]
if (method === 'GET' && !m[2]) return json(res, 200, info(mangaId)), true
if (method === 'POST' && m[2] === '/auto') {
const b = await readBody(req)
db.prepare('UPDATE downloads SET hd_auto = ? WHERE manga_id = ?').run(b.auto ? 1 : 0, mangaId)
return json(res, 200, info(mangaId)), true
}
if (method === 'POST' && m[2]) {
cancel(mangaId)
db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId)
return json(res, 200, info(mangaId)), true
}
if (method === 'POST') {
if (!available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)')
const b = await readBody(req)
let ids
if (b.chapterId) ids = [String(b.chapterId)]
else if (Array.isArray(b.chapterIds)) ids = b.chapterIds.slice(0, 5000).map(String) // selección o rango
else {
// Todo el manga: lo descargado y, en adelante, lo que se descargue.
db.prepare('UPDATE downloads SET hd_auto = 1 WHERE manga_id = ?').run(mangaId)
ids = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ? ORDER BY CAST(num AS REAL), created_at').all(mangaId).map(r => r.chapter_id)
}
if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(mangaId)) throw new HttpError(404, 'Este manga no está descargado')
enqueue(ids)
return json(res, 200, info(mangaId)), true
}
if (method === 'DELETE') {
// Quitar la versión HD (el original se queda).
cancel(mangaId)
db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId)
const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId)
if (dl) fs.rmSync(path.join(MANGA_DIR, dl.folder, HD_DIR), { recursive: true, force: true })
db.prepare('UPDATE downloaded_chapters SET hd_file = NULL WHERE manga_id = ?').run(mangaId)
return json(res, 200, info(mangaId)), true
}
return false
}
if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado')
else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado')
// Reescalar estos capítulos: los descargados ya, el resto al terminar su descarga.
function request(chapterIds) {
const have = db.prepare('SELECT 1 FROM downloaded_chapters WHERE chapter_id = ?')
const later = db.prepare('INSERT OR IGNORE INTO upscale_wanted (chapter_id, created_at) VALUES (?, ?)')
const now = []
for (const id of chapterIds) have.get(id) ? now.push(id) : later.run(id, Date.now())
if (now.length) enqueue(now)
}
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue, request }
}
+770
View File
@@ -0,0 +1,770 @@
// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
// Lo descargado queda en <DATA_DIR>/tomes/<id de MangaDex>/<tomo>-<cara>.img.
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const DAY = 24 * 3600e3
const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
const AMAZON = 'https://m.media-amazon.com/images/P'
const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
const MAX_IMAGE = 30 * 1024 * 1024
const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
export const volKey = v => {
const s = String(v ?? '').trim()
if (!s || s.toLowerCase() === 'none') return null
return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
}
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
// Título comparable: sin signos, espacios ni diferencias de anchura.
export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
export function ndlVolume(s) {
const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
return m ? String(parseInt(m[1], 10)) : null
}
export function isbn10(raw) {
const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
if (/^\d{9}[\dX]$/.test(s)) return s
if (!/^978\d{10}$/.test(s)) return null
const body = s.slice(3, 12)
let sum = 0
for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
const c = (11 - (sum % 11)) % 11
return body + (c === 10 ? 'X' : String(c))
}
const xmlText = s => String(s || '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim()
// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
export function pickIsbns(xmlPages, titles, year) {
const keys = new Set(titles.map(titleKey).filter(Boolean))
const groups = new Map()
for (const xml of xmlPages) {
for (const [, it] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
if (!keys.has(titleKey(xmlText(/<dc:title>([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
const vol = ndlVolume(xmlText(/<dcndl:volume>([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
if (!vol) continue
const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+)</g)].map(m => isbn10(m[1])).filter(Boolean)
if (!isbns.length) continue
const author = titleKey(xmlText(/<author>([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
const when = parseInt(/<dc:date[^>]*>(\d{4})/.exec(it)?.[1], 10) || null
if (!groups.has(author)) groups.set(author, new Map())
const g = groups.get(author)
if (!g.has(vol)) g.set(vol, { isbns: [], when })
const e = g.get(vol)
for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
if (when && (!e.when || when < e.when)) e.when = when
}
}
const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
const best = [...groups.values()]
.filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
.sort((a, b) => b.size - a.size)[0]
if (!best) return {}
return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
}
// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
export function pickEnglishIsbns(docs, titles, vol) {
const out = []
for (const title of titles) {
const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
for (const d of docs) {
if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
for (const x of d.isbn || []) {
const i = isbn10(x)
if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
}
}
}
return out
}
const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
export function pickCovers(records, lang) {
const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
const out = {}
for (const r of records) {
const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
if (!vol || !file) continue
if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
}
return out
}
function imageKind(b) {
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
return null
}
/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
* Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
export function strayNums(nums) {
const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
if (known.length < 3) return new Set()
const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
const center = middle(known)
const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
const limit = Math.max(4 * spread, 5)
const far = known.filter(n => Math.abs(n - center) > limit)
return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
}
/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
export function cleanVolMap(map) {
const byVol = new Map()
for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
const usual = sizes[(sizes.length - 1) >> 1]
const out = {}
for (const [v, nums] of byVol) {
const size = whole(nums)
if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
}
return out
}
/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
export function volMapsAgree(a, b) {
const shared = Object.keys(a).filter(n => n in b)
if (shared.length < 5) return true
return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
}
/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
const merged = {}
// M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
const shown = new Set(Object.values(mi).map(Number))
const inMu = new Set(Object.values(mu).map(Number))
const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
if (!volMapsAgree(src, merged)) continue
for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
}
return merged
}
/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
export function parseMuVolumes(rows) {
const votes = {}
for (const { volume, chapter } of rows) {
const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
if (!vol) continue
const ch = String(chapter ?? '').trim()
const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
const nums = one ? [parseFloat(one[1])] : []
if (range) {
const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
if (hi >= lo && hi - lo < 50) {
nums.push(lo, hi)
for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
}
}
for (const n of new Set(nums)) {
const count = (votes[n] ||= {})
count[vol] = (count[vol] || 0) + 1
}
}
const map = {}
for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
return map
}
/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
* si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
export function orderedMuMap(map, total = 0) {
const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
const vol = nums.map(n => parseInt(map[n], 10))
// La subsecuencia no decreciente más larga.
const len = vol.map(() => 1), from = vol.map(() => -1)
let end = 0
vol.forEach((v, i) => {
for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
if (len[i] > len[end]) end = i
})
const out = {}
for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
const size = Object.keys(out).length
return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
}
/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
* antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
export function fillVolGaps(map, nums) {
const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
const out = { ...map }
for (const key of nums) {
if (key == null || key in map) continue
const n = parseFloat(key)
const prev = anchors.filter(a => a < n).at(-1)
const next = anchors.find(a => a > n)
if (next === undefined) continue
const after = map[next]
if (prev === undefined) {
if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
continue
}
const before = map[prev]
if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
}
return out
}
export const ownVolMap = chapters => {
const map = {}
for (const c of chapters) {
const n = numKey(c.num), v = volKey(c.volume)
if (n && v && !(n in map)) map[n] = v
}
return map
}
/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
export function numbersRestart(chapters) {
const seen = new Map()
let repeated = 0
for (const c of chapters) {
const n = numKey(c.num), v = volKey(c.volume)
if (!n || !v) continue
if (seen.has(n) && seen.get(n) !== v) repeated++
else seen.set(n, v)
}
return repeated >= 3
}
/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
* y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
export function assignVolumes(chapters, map = {}) {
const vols = chapters.map(c => map[numKey(c.num)] ?? null)
vols.forEach((v, i) => {
const n = parseFloat(chapters[i].num)
if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
})
let last = -1
vols.forEach((v, i) => {
if (!v) return
if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
last = i
})
return vols
}
/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
* (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
export function dropMisplaced(vols, maxRun = 3) {
const out = [...vols]
if (!vols.every(v => !v || /^\d+$/.test(v))) return out
const runsOf = () => {
const runs = []
out.forEach((v, i) => {
if (!v) return
if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
else runs.push({ v: parseInt(v, 10), at: [i] })
})
return runs
}
// De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
for (;;) {
const runs = runsOf()
const wrong = runs.filter((r, j) => {
if (r.at.length > maxRun) return false
const [a, b] = [runs[j - 1], runs[j + 1]]
if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
const near = a || b
return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
})
if (!wrong.length) return out
const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
for (const i of hit.at) out[i] = null
}
}
/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
* known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
export function placeLoose(chapters, vols, known = []) {
const out = dropMisplaced(vols)
const isNum = v => /^\d+$/.test(v ?? '')
const floorOf = i => Math.floor(parseFloat(chapters[i].num))
// Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
const spread = (from, to, names) => {
let start = from
names.forEach((name, j) => {
const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
for (let i = start; i < end; i++) out[i] = name
start = Math.max(start, end)
})
}
const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
const assigned = out.flatMap((v, i) => (v ? [i] : []))
if (!assigned.length) {
if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
return out
}
const count = {}
for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
const sizes = Object.values(count).sort((a, b) => a - b)
const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
const free = []
for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
return free
}
// Antes del primer tomo.
const first = assigned[0]
if (first) {
const head = out[first]
const free = isNum(head) ? between(0, parseInt(head, 10)) : []
const n = Math.min(free.length, Math.round(first / usual))
if (n) spread(0, first, free.slice(-n))
else for (let i = 0; i < first; i++) out[i] = head
}
// Entre dos tomos.
for (let a = 0; a < assigned.length - 1; a++) {
const [l, r] = [assigned[a], assigned[a + 1]]
if (r - l < 2) continue
const [left, right] = [out[l], out[r]]
const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
if (free.length) {
// Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
for (let i = l + 1; i < from; i++) out[i] = left
for (let i = to; i < r; i++) out[i] = right
spread(from, to, free)
count[left] += from - l - 1
count[right] += r - to
continue
}
if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
for (let i = l + 1; i < r; i++) out[i] = left
continue
}
// Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
const whole = []
for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
count[left] += at - l - 1
count[right] += r - at
}
// Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
const end = assigned.at(-1)
const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
const rest = chapters.length - end - 1
if (free.length && rest) {
const fits = rest <= free.length * usual * 1.3
spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
}
return out
}
/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
function numberedListCount(text, from) {
let depth = 0, bars = 0
for (let i = from; i < text.length - 1; i++) {
const two = text.slice(i, i + 2)
if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
if (--depth < 0) break
i++
} else if (text[i] === '|' && depth === 0) bars++
}
return bars
}
/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
* "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
* empezar por el tomo 1, se ignoran. */
export function parseWikiVolumes(text) {
const map = {}, seen = new Set()
let next = 1
for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
if (!vol) continue
if (seen.has(vol)) break
seen.add(vol)
const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
if (!fields.length) continue
const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
for (const f of fields) {
const start = f.index + f[0].length
const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
const body = block.slice(start, end < 0 ? undefined : start + end)
const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
if (numbered.length) {
for (const m of numbered) {
const first = parseInt(m[1], 10)
for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
}
continue
}
const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
if (range) {
for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
continue
}
for (const line of body.split('\n')) {
if (!/^\s*[*#]/.test(line)) continue
const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
add(own ? parseInt(own[1], 10) : next)
}
}
}
return map
}
/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
export function workTitle(title) {
const t = String(title || '').trim()
const base = t
.replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
.replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
.replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
return base.length >= 3 ? base : t
}
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
export function pickLogo(files, names) {
let best = null, bestScore = 0
files.forEach((file, i) => {
if (!/\.(png|svg|webp)$/i.test(file)) return
const fw = logoWords(file.replace(/^File:/, ''))
if (!fw.includes('logo')) return
names.forEach((name, k) => {
const nw = logoWords(name)
if (!nw.length || nw.join('').length < 4) return
const hit = nw.filter(w => fw.includes(w)).length / nw.length
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
})
})
return best
}
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
const DIR = path.join(DATA_DIR, 'tomes')
const fetchImage = async url => {
net.guard()
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
if (!r.ok) return null
const b = Buffer.from(await r.arrayBuffer())
return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
}
// ---------- Lo que dice MangaDex de la obra ----------
async function mdVolumes(mdId, lang) {
return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
const [agg, mangaData] = await Promise.all([
md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
md(`/manga/${mdId}`, {}, 30 * 60e3),
])
const byNum = {}
const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
for (const v of vols) {
const vol = volKey(v.volume)
if (!vol) continue
const inVol = Object.values(v.chapters || {})
const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
for (const c of inVol) {
const n = numKey(c.chapter)
if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
}
}
const records = []
for (let offset = 0; offset < 1000; offset += 100) {
const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
records.push(...r.data)
if (offset + 100 >= r.total || !r.data.length) break
}
const a = mangaData.data.attributes
const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
}, true)
}
let gate = Promise.resolve()
const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
// ---------- Otras fuentes del reparto ----------
async function wikiPages(titles) {
if (!titles.length) return []
await turn() // Wikipedia corta a quien va demasiado rápido
net.guard()
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
let r = await get()
if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
await new Promise(res => setTimeout(res, 3000))
r = await get()
}
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
}
async function wikiSearch(names) {
await turn()
net.guard()
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
const wanted = new Set(names.map(wikiKey))
return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
}
function wikiMap(mangaId, names) {
return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
let best = {}
let pages = await wikiPages(titles)
if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
for (const text of pages) {
// One Piece y otras largas reparten la lista en subpáginas.
const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
let found = parseWikiVolumes(text)
for (let i = 0; i < subs.length; i += 10) {
for (const sub of await wikiPages(subs.slice(i, i + 10))) {
const part = parseWikiVolumes(sub)
found = { ...part, ...found }
}
}
if (Object.keys(found).length > Object.keys(best).length) best = found
}
return best
}, true)
}
function m440Map(mangaId, names) {
return stored(`mimap:${mangaId}`, DAY, async () => {
for (const name of names.slice(0, 2)) {
const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
}
return {}
}, true)
}
// ---------- ISBN de cada tomo ----------
async function ndlSearch(title) {
const pages = []
for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
await turn()
net.guard()
const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
const xml = await r.text()
pages.push(xml)
if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
}
return pages
}
async function isbnsOf(mdId) {
return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
const d = await mdVolumes(mdId)
if (d.original !== 'ja' || !d.jaTitles.length) return {}
for (const t of d.jaTitles.slice(0, 3)) {
const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
if (Object.keys(found).length) return found
}
return {}
}, true)
}
async function englishIsbns(mdId, vol) {
const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
if (!titles.length) return []
await turn()
net.guard()
const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
}
// ---------- Imágenes de un tomo ----------
async function fetchFace(mdId, vol, side) {
const volNum = /^\d+$/.test(vol) ? vol : null
if (side === 'front') {
const c = (await mdVolumes(mdId)).covers[vol]
if (c) {
const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
if (b) return b
}
}
if (!volNum) return null
for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
if (b) return b
}
if (side === 'back') {
for (const isbn of await englishIsbns(mdId, volNum)) {
const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
if (b) return b
}
}
return null
}
const inflight = new Map()
function face(mdId, vol, side) {
const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
if (fs.existsSync(file)) return Promise.resolve(file)
const key = `tome:${mdId}:${vol}:${label}`
if (!inflight.has(key)) {
// Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
inflight.set(key, stored(key, 14 * DAY, async () => {
const b = await fetchFace(mdId, vol, side)
if (!b) return { found: false }
fs.mkdirSync(path.dirname(file), { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, b)
fs.renameSync(tmp, file)
return { found: true }
}, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
}
return inflight.get(key)
}
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
async function commonsGet(params) {
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
await turn()
net.guard()
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
const text = r?.ok ? await r.text() : ''
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
const data = text.startsWith('{') ? JSON.parse(text) : null
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
return data
}
async function commonsLogo(names) {
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
for (const q of queries) {
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
if (!best) continue
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
}
return null
}
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
function logo(mangaId, lang) {
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
const { manga } = await fetchMangaFull(mangaId, lang)
if (hasNoVolumes(manga.type)) return null
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
return commonsLogo(names)
}, true)
}
// ---------- API ----------
async function list(mangaId, lang) {
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
if (hasNoVolumes(manga.type)) return []
const source = sourceOf(mangaId)
// Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
const found = await mdMatches(mangaId).catch(() => [])
const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
const mdId = source === 'md' ? mangaId : best?.id || null
const data = editions.find(e => e.id === mdId)?.data || null
const own = cleanVolMap(ownVolMap(chapters))
let vols
if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
else {
const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
const [wiki, mi] = await Promise.all([
wikiMap(mangaId, names).catch(() => ({})),
source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
])
const byNum = cleanVolMap(best?.data.byNum || {})
const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
// Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
const known = { ...main.wiki, ...main.md }
const missing = chapters.filter(c => !(numKey(c.num) in known)).length
const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
}
vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
const groups = new Map()
vols.forEach((vol, i) => {
if (!vol) return
if (!groups.has(vol)) groups.set(vol, [])
groups.get(vol).push(chapters[i].id)
})
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
return [...groups.keys()].sort(volOrder).map(vol => {
const cover = data?.covers[vol]
return {
vol,
chapters: groups.get(vol),
pages: pagesOf(groups.get(vol)),
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
}
})
}
async function sendFace(res, mdId, vol, side) {
if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
const file = await face(mdId, vol, side)
if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
const head = Buffer.alloc(16)
const fd = fs.openSync(file, 'r')
fs.readSync(fd, head, 0, 16, 0)
fs.closeSync(fd)
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
fs.createReadStream(file).pipe(res)
}
return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
}