From e66b5fea95c478b05347daaa795d56665c0f9d59 Mon Sep 17 00:00:00 2001 From: Nino Date: Sun, 1 Nov 2026 13:32:23 -0600 Subject: [PATCH] =?UTF-8?q?Vista=20Info=20de=20la=20obra=20(AniList=20y=20?= =?UTF-8?q?MangaUpdates)=20y=20fechas=20de=20los=20cap=C3=ADtulos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 13 +++ app/meta.js | 251 +++++++++++++++++++++++++++++++++++++++++++ app/public/app.js | 125 ++++++++++++++++++++- app/public/style.css | 21 ++++ app/server.js | 3 + 5 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 app/meta.js diff --git a/README.md b/README.md index 69551c6..0ef5cb9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,19 @@ Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./ El primer usuario puede registrarse siempre; después el registro queda cerrado salvo `ALLOW_SIGNUP=true` en `.env`. +## Info de la obra y fechas de los capítulos + +El botón **Info** de cada manga muestra datos de [AniList](https://anilist.co) y +[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos, +puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones y enlaces oficiales. +La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título +(solo si coincide, para no mostrar datos de otra obra). + +Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo +(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación; +los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas +entre dos capítulos con fecha. Todo se guarda en la base de datos (ficha 7 días, fechas 1 día). + ## Sin internet en el servidor Si el servidor pierde la conexión a internet (pero tú sigues llegando a él por la red local o diff --git a/app/meta.js b/app/meta.js new file mode 100644 index 0000000..c31bb1c --- /dev/null +++ b/app/meta.js @@ -0,0 +1,251 @@ +// 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 } +} diff --git a/app/public/app.js b/app/public/app.js index 374db29..1cf86cd 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -44,6 +44,7 @@ const ICONS = { phone: '', plus: '', pencil: '', + info: '', sparkles: '', } const icon = name => `` @@ -66,6 +67,11 @@ const FILTERS = { const DL_ACTIVE = ['queued', 'downloading'] const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}` const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min` +const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null } +const fmtDate = d => { + const t = new Date(`${String(d).slice(0, 10)}T12:00:00`) + return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' }) +} const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`) // Progreso global contando la fracción del capítulo en curso. function downloadPct(d) { @@ -359,6 +365,7 @@ async function router() { try { if (parts[0] === 'login') return (location.hash = '#/') if (parts[0] === 'manga') return await viewManga(parts[1]) + if (parts[0] === 'info') return await viewInfo(parts[1]) if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre if (parts[0] === 'library') return await viewLibrary(params) @@ -578,6 +585,78 @@ async function viewHome(params) { } // ---------- Ficha del manga ---------- +// ---------- Info: datos de la obra de AniList y MangaUpdates ---------- +const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' } +const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' } +const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' } +const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo') +const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' } +const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' } +const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d)) + +async function viewInfo(id) { + if (cleanup) { cleanup(); cleanup = null } + app.innerHTML = '
Buscando información de la obra…
' + const d = await api(`/api/meta/${id}`) + const src = d.source || {} + const back = `${icon('back')} Volver` + if (!d.found) { + app.innerHTML = `
${back}

${esc(src.title || '')}

+

No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en + AniList.

` + return + } + const row = (k, v) => (v ? `
${k}
${v}
` : '') + const list = a => a.map(esc).join(', ') + const t = d.titles + const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map()) + const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : '' + const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ') + const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`) + const search = title => `#/?q=${encodeURIComponent(title)}` + const cardOf = r => ` + +
${esc(r.title)}
${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}
` + const srcDesc = (src.description || '').trim() + app.innerHTML = ` +
+ ${d.banner ? `` : ''} +
${back}
+
+ +
+

${esc(t.english || t.romaji || src.title)}

+
${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}
+
+ ${d.score != null ? `
${d.score}%AniList
` : ''} + ${d.muScore != null ? `
${Number(d.muScore).toFixed(1)}MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}
` : ''} + ${d.popularity ? `
${fmtNum(d.popularity)}lectores en AniList
` : ''} +
+ ${ranks.length ? `
${ranks.map(r => `${esc(r)}`).join('')}
` : ''} +
+ ${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')} + ${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` (${esc(m.publisher)})` : '')).join(', '))} + ${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))} + ${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))} + ${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))} + ${row('Publicación', esc(dates))} + ${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))} + ${row('Extensión', esc(size))} + ${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}` : ''].filter(Boolean).join('
'))} +
+
${d.genres.map(g => `${esc(g)}`).join('')}${d.tags.map(g => `${esc(g)}`).join('')}
+
+
+ ${srcDesc ? `

Sinopsis

${esc(srcDesc)}

` : ''} + ${d.description ? (srcDesc ? `
Sinopsis de AniList (en inglés)

${esc(d.description)}

` : `

Sinopsis (en inglés)

${esc(d.description)}

`) : ''} + ${t.other.length ? `

Otros títulos

${list(t.other)}

` : ''} + ${d.related.length ? `

Obras relacionadas

${d.related.map(cardOf).join('')}
` : ''} + ${d.recommendations.length ? `

Si te gusta, prueba

${d.recommendations.map(cardOf).join('')}
` : ''} + ${d.links.length ? `

Enlaces

${d.links.map(l => `${esc(l.site)}`).join('')}
` : ''} +

Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.

+
` +} + async function viewManga(id, langOverride) { if (cleanup) { cleanup(); cleanup = null } app.innerHTML = '
Cargando…
' @@ -616,6 +695,7 @@ async function viewManga(id, langOverride) {

${esc(manga.description)}

${mainBtn} + ${offline ? '' : `${icon('info')} Info`} ${offline ? '' : ``} ${menuItems ? `` : ''}
@@ -1017,7 +1097,49 @@ async function viewManga(id, langOverride) { + (hdSet.has(c.id) ? 'HD ' : hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '') + (OFFLINE_OK && offHas(c.id) ? `${icon('phone')} ` : '') - + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) + + [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ') + // Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si + // no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos). + let extDates = {} + const srcDatesOk = (() => { + const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean) + const n = {} + for (const d of ds) n[d] = (n[d] || 0) + 1 + return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25 + })() + // Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las + // traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman. + let dateMap = new Map() + function buildDates() { + const DAY = 864e5 + const known = chapters.map(c => { + const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null) + const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN + return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t } + }).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n) + let min = Infinity + for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min } + dateMap = new Map() + let prev = null + known.forEach((x, i) => { + if (x.t == null) return + dateMap.set(x.c.id, { t: x.t, approx: false }) + if (prev && i - prev.i > 1 && i - prev.i <= 150) { + for (let k = prev.i + 1; k < i; k++) { + const f = (known[k].n - prev.n) / (x.n - prev.n) + dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true }) + } + } + prev = { i, n: x.n, t: x.t } + }) + } + buildDates() + const chapDate = c => { + const e = dateMap.get(c.id) + if (!e) return '' + const d = new Date(e.t).toISOString().slice(0, 10) + return `${e.approx ? '≈ ' : ''}${fmtDate(d)}` + } function renderList() { if (!ul) return const list = desc ? [...chapters].reverse() : chapters @@ -1053,6 +1175,7 @@ async function viewManga(id, langOverride) { updateSummary() }) renderList() + if (!offline && serverOnline) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {}) renderDl(download) renderHd(upscale) renderOff() diff --git a/app/public/style.css b/app/public/style.css index e6ca657..55d9d6d 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -368,3 +368,24 @@ body.reading #netbanner { display: none; } .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%); } .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; } +.info-page { position: relative; } +.info-page .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; } +.info-page .info-top { margin-bottom: 14px; } +.info-page .banner + .info-top { position: absolute; top: 12px; left: 0; } +.info-page h2 { margin: 26px 0 10px; font-size: 1.1rem; } +.info-page .desc.long { max-height: none; } +.info-page .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; } +.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; } .info-page .banner { height: 120px; } } +.chapters .meta .cdate.approx { opacity: .7; } diff --git a/app/server.js b/app/server.js index db78a0f..bbbdca7 100644 --- a/app/server.js +++ b/app/server.js @@ -14,6 +14,7 @@ import { setupLibrary } from './library.js' import { setupSecurity, safeFetch } from './security.js' import { setupM440, MI_BASE } from './m440.js' import { setupConnectivity } from './connectivity.js' +import { setupMeta } from './meta.js' const PORT = Number(process.env.PORT || 3352) const DATA_DIR = process.env.DATA_DIR || '/app/data' @@ -848,6 +849,7 @@ async function route(req, res) { if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) return if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return + if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return if (p === '/api/settings' && method === 'POST') { @@ -1048,6 +1050,7 @@ const covers = setupCovers({ DATA_DIR, upscale }) const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories, net }) // Al volver internet: reanudar las descargas en cola. net.whenOnline(() => pumpDownloads()) +const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) // Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.