La ficha del manga incluye la info de la obra, menú lateral y banners reescalados

- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones.
- La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas
  seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado.
- Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra.
- Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 21:33:03 -06:00
co-authored by Claude Opus 5.5
parent 41a87bc5ad
commit 53e5a0d40e
7 changed files with 289 additions and 145 deletions
+81 -41
View File
@@ -9,8 +9,12 @@
// 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 (si una fuente falla se usa lo último que se tenía).
// 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.
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']
@@ -34,29 +38,30 @@ const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? Strin
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 }) {
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) {
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)) return inflight.get(key)
const p = (async () => {
try {
const v = await fn()
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))
inflight.set(key, p)
return p
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).
@@ -87,7 +92,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
async function links(mangaId) {
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)
@@ -96,18 +101,22 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
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.
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3).catch(() => null)
// 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 anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }).catch(() => null)
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 mu('/series/search', { search: title, perpage: 8 }).catch(() => null)
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)
}
// ---------- Ficha ----------
@@ -124,15 +133,18 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
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) {
const l = await links(mangaId)
return stored(`info:${mangaId}`, 7 * DAY, async () => {
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); return 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) }))
@@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
].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) {
const l = await links(mangaId)
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 () => {
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) => {
@@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
if (l.md) {
let n = 0
for (let offset = 0; offset < 10000; offset += 500) {
const d = await md(`/manga/${l.md}/feed`, {
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).catch(() => null)
}, 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
}
@@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
if (l.mu) {
let n = 0
for (let page = 1; page <= 40; page++) {
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }).catch(() => null)
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
@@ -229,23 +251,41 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}
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])
if (!net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet: la información extra no está disponible.')
if (m[2]) json(res, 200, await dates(id))
else {
// La ficha de la fuente (título, sinopsis en español, portada) va aparte de lo externo.
const [src, ext] = await Promise.all([fetchMangaFull(id, 'es').then(r => r.manga, () => null), info(id)])
json(res, 200, { ...ext, source: src && { title: src.title, description: src.description, cover: src.cover, authors: src.authors, source: src.source } })
}
json(res, 200, await (m[2] ? dates(id) : info(id)))
return true
}
return { route, links, info, dates }
return { route, links, info, cachedInfo, dates }
}