La ficha del manga incluye la info de la obra, menú lateral y banners reescalados
- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones. - La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado. - Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra. - Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+81
-41
@@ -9,8 +9,12 @@
|
||||
// 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).
|
||||
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
|
||||
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
|
||||
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
|
||||
const DAY = 24 * 3600e3
|
||||
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
|
||||
const SYNC_EVERY = 3600e3
|
||||
const ANILIST = 'https://graphql.anilist.co'
|
||||
const MU_API = 'https://api.mangaupdates.com/v1'
|
||||
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
|
||||
@@ -34,29 +38,30 @@ const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? Strin
|
||||
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 }) {
|
||||
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
|
||||
|
||||
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
|
||||
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
|
||||
const inflight = new Map()
|
||||
function stored(key, ttl, fn) {
|
||||
function stored(key, ttl, fn, wait = false) {
|
||||
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
|
||||
const old = row ? JSON.parse(row.value) : undefined
|
||||
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
|
||||
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
|
||||
if (inflight.has(key)) 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
|
||||
if (!inflight.has(key)) {
|
||||
inflight.set(key, (async () => {
|
||||
try {
|
||||
const v = await fn(old)
|
||||
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
|
||||
return v
|
||||
} catch (e) {
|
||||
if (old !== undefined) return old
|
||||
throw e
|
||||
}
|
||||
})().finally(() => inflight.delete(key)))
|
||||
}
|
||||
return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
|
||||
}
|
||||
|
||||
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
|
||||
@@ -87,7 +92,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
}
|
||||
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
|
||||
|
||||
async function links(mangaId) {
|
||||
async function links(mangaId, wait = false) {
|
||||
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
|
||||
if (sourceOf(mangaId) === 'md') {
|
||||
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
|
||||
@@ -96,18 +101,22 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
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)
|
||||
// Una fuente caída no significa "no encontrado": no se guarda y se reintenta.
|
||||
let failed = false
|
||||
const soft = p => p.catch(() => { failed = true; return null })
|
||||
const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3))
|
||||
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
|
||||
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
|
||||
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
|
||||
const a = await anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }).catch(() => null)
|
||||
const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }))
|
||||
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
|
||||
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
|
||||
const s = await mu('/series/search', { search: title, perpage: 8 }).catch(() => null)
|
||||
const s = await soft(mu('/series/search', { search: title, perpage: 8 }))
|
||||
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
|
||||
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
|
||||
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
|
||||
return {}
|
||||
})
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// ---------- Ficha ----------
|
||||
@@ -124,15 +133,18 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
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 () => {
|
||||
async function info(mangaId, wait = false) {
|
||||
const l = await links(mangaId, wait)
|
||||
return stored(`info:${mangaId}`, INFO_TTL, async old => {
|
||||
let failed = false
|
||||
const [al, ms] = await Promise.all([
|
||||
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
|
||||
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
|
||||
l.mu ? mu(`/series/${l.mu}`) : null,
|
||||
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); return null })))
|
||||
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
|
||||
const a = al?.Media
|
||||
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
|
||||
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
|
||||
if (!a && !ms) return { found: false }
|
||||
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
|
||||
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
|
||||
@@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
|
||||
matched: { via: l.via || null, title: l.title || null },
|
||||
}
|
||||
})
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
|
||||
function cachedInfo(mangaId) {
|
||||
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
|
||||
if (!row) return null
|
||||
info(mangaId).catch(() => {})
|
||||
return JSON.parse(row.value)
|
||||
}
|
||||
|
||||
// ---------- Fechas de los capítulos ----------
|
||||
async function dates(mangaId) {
|
||||
const l = await links(mangaId)
|
||||
async function dates(mangaId, wait = false) {
|
||||
const l = await links(mangaId, wait)
|
||||
if (!l.md && !l.mu) return { dates: {}, via: [] }
|
||||
return stored(`dates:${mangaId}`, DAY, async () => {
|
||||
return stored(`dates:${mangaId}`, DAY, async old => {
|
||||
let failed = false
|
||||
const soft = p => p.catch(() => { failed = true; return null })
|
||||
const out = {}
|
||||
const via = []
|
||||
const add = (n, d) => {
|
||||
@@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
if (l.md) {
|
||||
let n = 0
|
||||
for (let offset = 0; offset < 10000; offset += 500) {
|
||||
const d = await md(`/manga/${l.md}/feed`, {
|
||||
const d = await soft(md(`/manga/${l.md}/feed`, {
|
||||
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
|
||||
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
|
||||
}, 3600e3).catch(() => null)
|
||||
}, 3600e3))
|
||||
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
|
||||
if (!d || offset + 500 >= d.total) break
|
||||
}
|
||||
@@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
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)
|
||||
const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }))
|
||||
for (const x of r?.results || []) {
|
||||
const ch = String(x.record.chapter || '')
|
||||
const d = x.record.release_date
|
||||
@@ -229,23 +251,41 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
}
|
||||
if (n) via.push('MangaUpdates')
|
||||
}
|
||||
if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
|
||||
return { dates: out, via }
|
||||
})
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// ---------- Mangas seguidos: siempre con su información ----------
|
||||
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
|
||||
// y se reescalan los banners guardados que aún no lo estén.
|
||||
let syncing = false
|
||||
async function syncFollowed() {
|
||||
if (syncing || !net.online()) return
|
||||
syncing = true
|
||||
try {
|
||||
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
|
||||
if (!net.online()) break
|
||||
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
|
||||
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
|
||||
}
|
||||
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
|
||||
const { banner } = JSON.parse(value)
|
||||
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
|
||||
}
|
||||
} finally { syncing = false }
|
||||
}
|
||||
setTimeout(syncFollowed, 60e3).unref()
|
||||
setInterval(syncFollowed, SYNC_EVERY).unref()
|
||||
net.whenOnline(syncFollowed)
|
||||
|
||||
async function route(p, method, res) {
|
||||
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
|
||||
if (!m || method !== 'GET') return false
|
||||
const id = decodeURIComponent(m[1])
|
||||
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 } })
|
||||
}
|
||||
json(res, 200, await (m[2] ? dates(id) : info(id)))
|
||||
return true
|
||||
}
|
||||
|
||||
return { route, links, info, dates }
|
||||
return { route, links, info, cachedInfo, dates }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user