Files
manga-reader/app/meta.js
T
ninoandClaude Opus 5.5 53e5a0d40e 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>
2026-11-01 21:33:03 -06:00

292 lines
17 KiB
JavaScript

// 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.
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)
}
// ---------- 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, info, cachedInfo, dates }
}