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>
This commit is contained in:
+251
@@ -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(/<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 }
|
||||
}
|
||||
+124
-1
@@ -44,6 +44,7 @@ const ICONS = {
|
||||
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
|
||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7.5h.01"/>',
|
||||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
||||
}
|
||||
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
@@ -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 = '<div class="loading">Buscando información de la obra…</div>'
|
||||
const d = await api(`/api/meta/${id}`)
|
||||
const src = d.source || {}
|
||||
const back = `<a class="btn small" href="#/manga/${id}">${icon('back')} Volver</a>`
|
||||
if (!d.found) {
|
||||
app.innerHTML = `<div class="info-page">${back}<h1>${esc(src.title || '')}</h1>
|
||||
<p class="muted">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
|
||||
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(src.title || '')}" target="_blank" rel="noopener">AniList</a>.</p></div>`
|
||||
return
|
||||
}
|
||||
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
|
||||
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 => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
|
||||
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
|
||||
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
|
||||
const srcDesc = (src.description || '').trim()
|
||||
app.innerHTML = `
|
||||
<div class="info-page">
|
||||
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''}
|
||||
<div class="row info-top">${back}</div>
|
||||
<div class="detail">
|
||||
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback>
|
||||
<div class="detail-body">
|
||||
<h1>${esc(t.english || t.romaji || src.title)}</h1>
|
||||
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</div>
|
||||
<div class="scores">
|
||||
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
|
||||
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
|
||||
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
|
||||
</div>
|
||||
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}
|
||||
<dl class="kv">
|
||||
${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')}
|
||||
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).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 ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}
|
||||
</dl>
|
||||
<div class="tags">${d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join('')}${d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
|
||||
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}
|
||||
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''}
|
||||
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
|
||||
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${d.recommendations.map(cardOf).join('')}</div>` : ''}
|
||||
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
|
||||
<p class="muted note">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.</p>
|
||||
</div>`
|
||||
}
|
||||
|
||||
async function viewManga(id, langOverride) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
@@ -616,6 +695,7 @@ async function viewManga(id, langOverride) {
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="actions">${mainBtn}
|
||||
<button class="btn" id="follow"></button>
|
||||
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`}
|
||||
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
|
||||
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
|
||||
</div>
|
||||
@@ -1017,7 +1097,49 @@ async function viewManga(id, langOverride) {
|
||||
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
|
||||
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
|
||||
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
|
||||
+ (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 `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user