Files
manga-reader/app/meta.js
T
ninoandClaude Opus 5.5 e66b5fea95 Vista Info de la obra (AniList y MangaUpdates) y fechas de los capítulos
- Info: autores, revista y editorial, fechas, tomos, puntuaciones, rankings, anime, obras relacionadas,
  recomendaciones y enlaces. La obra se identifica por los enlaces de MangaDex o por título.
- Fechas de los capítulos: primera traducción conocida (MangaDex en cualquier idioma y MangaUpdates),
  ordenadas y con los huecos estimados (≈); se ignoran las fechas de subidas masivas de las fuentes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:32:23 -06:00

252 lines
15 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 (si una fuente falla se usa lo último que se tenía).
const DAY = 24 * 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 }) {
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).
const inflight = new Map()
function stored(key, ttl, fn) {
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
}
// 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) {
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.
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3).catch(() => null)
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 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 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) }
return {}
})
}
// ---------- 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) {
const l = await links(mangaId)
return stored(`info:${mangaId}`, 7 * DAY, async () => {
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 })))
const a = al?.Media
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 },
}
})
}
// ---------- Fechas de los capítulos ----------
async function dates(mangaId) {
const l = await links(mangaId)
if (!l.md && !l.mu) return { dates: {}, via: [] }
return stored(`dates:${mangaId}`, DAY, async () => {
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 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)
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 mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }).catch(() => null)
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')
}
return { dates: out, via }
})
}
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 } })
}
return true
}
return { route, links, info, dates }
}