// 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(/\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 } }