Manga Reader: lector web autoalojado con modo libro 3D y estante
This commit is contained in:
+770
@@ -0,0 +1,770 @@
|
||||
// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
|
||||
// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
|
||||
// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
|
||||
// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
|
||||
// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
|
||||
// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
|
||||
// Lo descargado queda en <DATA_DIR>/tomes/<id de MangaDex>/<tomo>-<cara>.img.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const DAY = 24 * 3600e3
|
||||
const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
|
||||
const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
|
||||
const AMAZON = 'https://m.media-amazon.com/images/P'
|
||||
const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
|
||||
const MAX_IMAGE = 30 * 1024 * 1024
|
||||
const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
|
||||
const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
|
||||
const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
|
||||
// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
|
||||
export const volKey = v => {
|
||||
const s = String(v ?? '').trim()
|
||||
if (!s || s.toLowerCase() === 'none') return null
|
||||
return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
|
||||
}
|
||||
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
|
||||
const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
|
||||
|
||||
// Título comparable: sin signos, espacios ni diferencias de anchura.
|
||||
export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
export function ndlVolume(s) {
|
||||
const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
|
||||
return m ? String(parseInt(m[1], 10)) : null
|
||||
}
|
||||
export function isbn10(raw) {
|
||||
const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
|
||||
if (/^\d{9}[\dX]$/.test(s)) return s
|
||||
if (!/^978\d{10}$/.test(s)) return null
|
||||
const body = s.slice(3, 12)
|
||||
let sum = 0
|
||||
for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
|
||||
const c = (11 - (sum % 11)) % 11
|
||||
return body + (c === 10 ? 'X' : String(c))
|
||||
}
|
||||
const xmlText = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim()
|
||||
|
||||
// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
|
||||
// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
|
||||
export function pickIsbns(xmlPages, titles, year) {
|
||||
const keys = new Set(titles.map(titleKey).filter(Boolean))
|
||||
const groups = new Map()
|
||||
for (const xml of xmlPages) {
|
||||
for (const [, it] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
||||
if (!keys.has(titleKey(xmlText(/<dc:title>([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
|
||||
const vol = ndlVolume(xmlText(/<dcndl:volume>([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
|
||||
if (!vol) continue
|
||||
const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+)</g)].map(m => isbn10(m[1])).filter(Boolean)
|
||||
if (!isbns.length) continue
|
||||
const author = titleKey(xmlText(/<author>([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
|
||||
const when = parseInt(/<dc:date[^>]*>(\d{4})/.exec(it)?.[1], 10) || null
|
||||
if (!groups.has(author)) groups.set(author, new Map())
|
||||
const g = groups.get(author)
|
||||
if (!g.has(vol)) g.set(vol, { isbns: [], when })
|
||||
const e = g.get(vol)
|
||||
for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
|
||||
if (when && (!e.when || when < e.when)) e.when = when
|
||||
}
|
||||
}
|
||||
const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
|
||||
const best = [...groups.values()]
|
||||
.filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
|
||||
.sort((a, b) => b.size - a.size)[0]
|
||||
if (!best) return {}
|
||||
return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
|
||||
}
|
||||
|
||||
// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
|
||||
// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
|
||||
// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
|
||||
const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
|
||||
export function pickEnglishIsbns(docs, titles, vol) {
|
||||
const out = []
|
||||
for (const title of titles) {
|
||||
const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
|
||||
for (const d of docs) {
|
||||
if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
|
||||
for (const x of d.isbn || []) {
|
||||
const i = isbn10(x)
|
||||
if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
|
||||
export function pickCovers(records, lang) {
|
||||
const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
|
||||
const out = {}
|
||||
for (const r of records) {
|
||||
const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
|
||||
if (!vol || !file) continue
|
||||
if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function imageKind(b) {
|
||||
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
||||
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
||||
if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
|
||||
/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
|
||||
* Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
|
||||
export function strayNums(nums) {
|
||||
const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
|
||||
if (known.length < 3) return new Set()
|
||||
const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
|
||||
const center = middle(known)
|
||||
const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
|
||||
const limit = Math.max(4 * spread, 5)
|
||||
const far = known.filter(n => Math.abs(n - center) > limit)
|
||||
return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
|
||||
}
|
||||
|
||||
/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
|
||||
export function cleanVolMap(map) {
|
||||
const byVol = new Map()
|
||||
for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
|
||||
const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
|
||||
const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
|
||||
const usual = sizes[(sizes.length - 1) >> 1]
|
||||
const out = {}
|
||||
for (const [v, nums] of byVol) {
|
||||
const size = whole(nums)
|
||||
if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
|
||||
const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
|
||||
for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
|
||||
export function volMapsAgree(a, b) {
|
||||
const shared = Object.keys(a).filter(n => n in b)
|
||||
if (shared.length < 5) return true
|
||||
return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
|
||||
}
|
||||
|
||||
/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
|
||||
export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
|
||||
const merged = {}
|
||||
// M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
|
||||
const shown = new Set(Object.values(mi).map(Number))
|
||||
const inMu = new Set(Object.values(mu).map(Number))
|
||||
const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
|
||||
for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
|
||||
if (!volMapsAgree(src, merged)) continue
|
||||
for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
|
||||
export function parseMuVolumes(rows) {
|
||||
const votes = {}
|
||||
for (const { volume, chapter } of rows) {
|
||||
const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
|
||||
if (!vol) continue
|
||||
const ch = String(chapter ?? '').trim()
|
||||
const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
|
||||
const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
|
||||
const nums = one ? [parseFloat(one[1])] : []
|
||||
if (range) {
|
||||
const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
|
||||
if (hi >= lo && hi - lo < 50) {
|
||||
nums.push(lo, hi)
|
||||
for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
|
||||
}
|
||||
}
|
||||
for (const n of new Set(nums)) {
|
||||
const count = (votes[n] ||= {})
|
||||
count[vol] = (count[vol] || 0) + 1
|
||||
}
|
||||
}
|
||||
const map = {}
|
||||
for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
|
||||
return map
|
||||
}
|
||||
|
||||
/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
|
||||
* si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
|
||||
export function orderedMuMap(map, total = 0) {
|
||||
const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
const vol = nums.map(n => parseInt(map[n], 10))
|
||||
// La subsecuencia no decreciente más larga.
|
||||
const len = vol.map(() => 1), from = vol.map(() => -1)
|
||||
let end = 0
|
||||
vol.forEach((v, i) => {
|
||||
for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
|
||||
if (len[i] > len[end]) end = i
|
||||
})
|
||||
const out = {}
|
||||
for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
|
||||
const size = Object.keys(out).length
|
||||
return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
|
||||
}
|
||||
|
||||
/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
|
||||
* antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
|
||||
export function fillVolGaps(map, nums) {
|
||||
const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
|
||||
const out = { ...map }
|
||||
for (const key of nums) {
|
||||
if (key == null || key in map) continue
|
||||
const n = parseFloat(key)
|
||||
const prev = anchors.filter(a => a < n).at(-1)
|
||||
const next = anchors.find(a => a > n)
|
||||
if (next === undefined) continue
|
||||
const after = map[next]
|
||||
if (prev === undefined) {
|
||||
if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
|
||||
continue
|
||||
}
|
||||
const before = map[prev]
|
||||
if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const ownVolMap = chapters => {
|
||||
const map = {}
|
||||
for (const c of chapters) {
|
||||
const n = numKey(c.num), v = volKey(c.volume)
|
||||
if (n && v && !(n in map)) map[n] = v
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
|
||||
export function numbersRestart(chapters) {
|
||||
const seen = new Map()
|
||||
let repeated = 0
|
||||
for (const c of chapters) {
|
||||
const n = numKey(c.num), v = volKey(c.volume)
|
||||
if (!n || !v) continue
|
||||
if (seen.has(n) && seen.get(n) !== v) repeated++
|
||||
else seen.set(n, v)
|
||||
}
|
||||
return repeated >= 3
|
||||
}
|
||||
|
||||
/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
|
||||
* y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
|
||||
export function assignVolumes(chapters, map = {}) {
|
||||
const vols = chapters.map(c => map[numKey(c.num)] ?? null)
|
||||
vols.forEach((v, i) => {
|
||||
const n = parseFloat(chapters[i].num)
|
||||
if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
|
||||
})
|
||||
let last = -1
|
||||
vols.forEach((v, i) => {
|
||||
if (!v) return
|
||||
if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
|
||||
last = i
|
||||
})
|
||||
return vols
|
||||
}
|
||||
|
||||
/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
|
||||
* (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
|
||||
export function dropMisplaced(vols, maxRun = 3) {
|
||||
const out = [...vols]
|
||||
if (!vols.every(v => !v || /^\d+$/.test(v))) return out
|
||||
const runsOf = () => {
|
||||
const runs = []
|
||||
out.forEach((v, i) => {
|
||||
if (!v) return
|
||||
if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
|
||||
else runs.push({ v: parseInt(v, 10), at: [i] })
|
||||
})
|
||||
return runs
|
||||
}
|
||||
// De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
|
||||
for (;;) {
|
||||
const runs = runsOf()
|
||||
const wrong = runs.filter((r, j) => {
|
||||
if (r.at.length > maxRun) return false
|
||||
const [a, b] = [runs[j - 1], runs[j + 1]]
|
||||
if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
|
||||
const near = a || b
|
||||
return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
|
||||
})
|
||||
if (!wrong.length) return out
|
||||
const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
|
||||
for (const i of hit.at) out[i] = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
|
||||
* known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
|
||||
export function placeLoose(chapters, vols, known = []) {
|
||||
const out = dropMisplaced(vols)
|
||||
const isNum = v => /^\d+$/.test(v ?? '')
|
||||
const floorOf = i => Math.floor(parseFloat(chapters[i].num))
|
||||
// Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
|
||||
const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
|
||||
const spread = (from, to, names) => {
|
||||
let start = from
|
||||
names.forEach((name, j) => {
|
||||
const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
|
||||
for (let i = start; i < end; i++) out[i] = name
|
||||
start = Math.max(start, end)
|
||||
})
|
||||
}
|
||||
const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
|
||||
const assigned = out.flatMap((v, i) => (v ? [i] : []))
|
||||
if (!assigned.length) {
|
||||
if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
|
||||
return out
|
||||
}
|
||||
const count = {}
|
||||
for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
|
||||
const sizes = Object.values(count).sort((a, b) => a - b)
|
||||
const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
|
||||
const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
|
||||
const free = []
|
||||
for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
|
||||
return free
|
||||
}
|
||||
// Antes del primer tomo.
|
||||
const first = assigned[0]
|
||||
if (first) {
|
||||
const head = out[first]
|
||||
const free = isNum(head) ? between(0, parseInt(head, 10)) : []
|
||||
const n = Math.min(free.length, Math.round(first / usual))
|
||||
if (n) spread(0, first, free.slice(-n))
|
||||
else for (let i = 0; i < first; i++) out[i] = head
|
||||
}
|
||||
// Entre dos tomos.
|
||||
for (let a = 0; a < assigned.length - 1; a++) {
|
||||
const [l, r] = [assigned[a], assigned[a + 1]]
|
||||
if (r - l < 2) continue
|
||||
const [left, right] = [out[l], out[r]]
|
||||
const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
|
||||
if (free.length) {
|
||||
// Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
|
||||
const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
|
||||
const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
|
||||
const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
|
||||
for (let i = l + 1; i < from; i++) out[i] = left
|
||||
for (let i = to; i < r; i++) out[i] = right
|
||||
spread(from, to, free)
|
||||
count[left] += from - l - 1
|
||||
count[right] += r - to
|
||||
continue
|
||||
}
|
||||
if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
|
||||
for (let i = l + 1; i < r; i++) out[i] = left
|
||||
continue
|
||||
}
|
||||
// Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
|
||||
const whole = []
|
||||
for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
|
||||
if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
|
||||
const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
|
||||
const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
|
||||
const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
|
||||
for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
|
||||
count[left] += at - l - 1
|
||||
count[right] += r - at
|
||||
}
|
||||
// Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
|
||||
const end = assigned.at(-1)
|
||||
const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
|
||||
const rest = chapters.length - end - 1
|
||||
if (free.length && rest) {
|
||||
const fits = rest <= free.length * usual * 1.3
|
||||
spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
|
||||
function numberedListCount(text, from) {
|
||||
let depth = 0, bars = 0
|
||||
for (let i = from; i < text.length - 1; i++) {
|
||||
const two = text.slice(i, i + 2)
|
||||
if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
|
||||
if (--depth < 0) break
|
||||
i++
|
||||
} else if (text[i] === '|' && depth === 0) bars++
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
|
||||
* "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
|
||||
* empezar por el tomo 1, se ignoran. */
|
||||
export function parseWikiVolumes(text) {
|
||||
const map = {}, seen = new Set()
|
||||
let next = 1
|
||||
for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
|
||||
const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
|
||||
if (!vol) continue
|
||||
if (seen.has(vol)) break
|
||||
seen.add(vol)
|
||||
const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
|
||||
if (!fields.length) continue
|
||||
const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
|
||||
for (const f of fields) {
|
||||
const start = f.index + f[0].length
|
||||
const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
|
||||
const body = block.slice(start, end < 0 ? undefined : start + end)
|
||||
const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
|
||||
if (numbered.length) {
|
||||
for (const m of numbered) {
|
||||
const first = parseInt(m[1], 10)
|
||||
for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
|
||||
if (range) {
|
||||
for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
|
||||
continue
|
||||
}
|
||||
for (const line of body.split('\n')) {
|
||||
if (!/^\s*[*#]/.test(line)) continue
|
||||
const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
|
||||
add(own ? parseInt(own[1], 10) : next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
|
||||
export function workTitle(title) {
|
||||
const t = String(title || '').trim()
|
||||
const base = t
|
||||
.replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
|
||||
.replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
|
||||
.replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
|
||||
return base.length >= 3 ? base : t
|
||||
}
|
||||
|
||||
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
|
||||
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
|
||||
|
||||
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
|
||||
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
|
||||
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
|
||||
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
|
||||
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
|
||||
export function pickLogo(files, names) {
|
||||
let best = null, bestScore = 0
|
||||
files.forEach((file, i) => {
|
||||
if (!/\.(png|svg|webp)$/i.test(file)) return
|
||||
const fw = logoWords(file.replace(/^File:/, ''))
|
||||
if (!fw.includes('logo')) return
|
||||
names.forEach((name, k) => {
|
||||
const nw = logoWords(name)
|
||||
if (!nw.length || nw.join('').length < 4) return
|
||||
const hit = nw.filter(w => fw.includes(w)).length / nw.length
|
||||
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
|
||||
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
|
||||
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
|
||||
})
|
||||
})
|
||||
return best
|
||||
}
|
||||
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
|
||||
|
||||
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
|
||||
const DIR = path.join(DATA_DIR, 'tomes')
|
||||
|
||||
const fetchImage = async url => {
|
||||
net.guard()
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
|
||||
if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
|
||||
if (!r.ok) return null
|
||||
const b = Buffer.from(await r.arrayBuffer())
|
||||
return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
|
||||
}
|
||||
|
||||
// ---------- Lo que dice MangaDex de la obra ----------
|
||||
async function mdVolumes(mdId, lang) {
|
||||
return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
|
||||
const [agg, mangaData] = await Promise.all([
|
||||
md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
|
||||
md(`/manga/${mdId}`, {}, 30 * 60e3),
|
||||
])
|
||||
const byNum = {}
|
||||
const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
|
||||
for (const v of vols) {
|
||||
const vol = volKey(v.volume)
|
||||
if (!vol) continue
|
||||
const inVol = Object.values(v.chapters || {})
|
||||
const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
|
||||
for (const c of inVol) {
|
||||
const n = numKey(c.chapter)
|
||||
if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
|
||||
}
|
||||
}
|
||||
const records = []
|
||||
for (let offset = 0; offset < 1000; offset += 100) {
|
||||
const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
|
||||
records.push(...r.data)
|
||||
if (offset + 100 >= r.total || !r.data.length) break
|
||||
}
|
||||
const a = mangaData.data.attributes
|
||||
const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
|
||||
const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
|
||||
const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
|
||||
return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
|
||||
}, true)
|
||||
}
|
||||
|
||||
let gate = Promise.resolve()
|
||||
const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
|
||||
|
||||
// ---------- Otras fuentes del reparto ----------
|
||||
async function wikiPages(titles) {
|
||||
if (!titles.length) return []
|
||||
await turn() // Wikipedia corta a quien va demasiado rápido
|
||||
net.guard()
|
||||
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
|
||||
const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
let r = await get()
|
||||
if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
|
||||
await new Promise(res => setTimeout(res, 3000))
|
||||
r = await get()
|
||||
}
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
|
||||
return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
|
||||
}
|
||||
async function wikiSearch(names) {
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
|
||||
const wanted = new Set(names.map(wikiKey))
|
||||
return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
|
||||
}
|
||||
function wikiMap(mangaId, names) {
|
||||
return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
|
||||
const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
|
||||
let best = {}
|
||||
let pages = await wikiPages(titles)
|
||||
if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
|
||||
for (const text of pages) {
|
||||
// One Piece y otras largas reparten la lista en subpáginas.
|
||||
const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
|
||||
let found = parseWikiVolumes(text)
|
||||
for (let i = 0; i < subs.length; i += 10) {
|
||||
for (const sub of await wikiPages(subs.slice(i, i + 10))) {
|
||||
const part = parseWikiVolumes(sub)
|
||||
found = { ...part, ...found }
|
||||
}
|
||||
}
|
||||
if (Object.keys(found).length > Object.keys(best).length) best = found
|
||||
}
|
||||
return best
|
||||
}, true)
|
||||
}
|
||||
function m440Map(mangaId, names) {
|
||||
return stored(`mimap:${mangaId}`, DAY, async () => {
|
||||
for (const name of names.slice(0, 2)) {
|
||||
const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
|
||||
if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
|
||||
}
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- ISBN de cada tomo ----------
|
||||
async function ndlSearch(title) {
|
||||
const pages = []
|
||||
for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
|
||||
const xml = await r.text()
|
||||
pages.push(xml)
|
||||
if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
|
||||
}
|
||||
return pages
|
||||
}
|
||||
async function isbnsOf(mdId) {
|
||||
return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
|
||||
const d = await mdVolumes(mdId)
|
||||
if (d.original !== 'ja' || !d.jaTitles.length) return {}
|
||||
for (const t of d.jaTitles.slice(0, 3)) {
|
||||
const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
|
||||
if (Object.keys(found).length) return found
|
||||
}
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
async function englishIsbns(mdId, vol) {
|
||||
const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
|
||||
if (!titles.length) return []
|
||||
await turn()
|
||||
net.guard()
|
||||
const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
|
||||
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
|
||||
return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
|
||||
}
|
||||
|
||||
// ---------- Imágenes de un tomo ----------
|
||||
async function fetchFace(mdId, vol, side) {
|
||||
const volNum = /^\d+$/.test(vol) ? vol : null
|
||||
if (side === 'front') {
|
||||
const c = (await mdVolumes(mdId)).covers[vol]
|
||||
if (c) {
|
||||
const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
|
||||
if (b) return b
|
||||
}
|
||||
}
|
||||
if (!volNum) return null
|
||||
for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
|
||||
const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
|
||||
if (b) return b
|
||||
}
|
||||
if (side === 'back') {
|
||||
for (const isbn of await englishIsbns(mdId, volNum)) {
|
||||
const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
|
||||
if (b) return b
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const inflight = new Map()
|
||||
function face(mdId, vol, side) {
|
||||
const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
|
||||
const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
|
||||
if (fs.existsSync(file)) return Promise.resolve(file)
|
||||
const key = `tome:${mdId}:${vol}:${label}`
|
||||
if (!inflight.has(key)) {
|
||||
// Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
|
||||
inflight.set(key, stored(key, 14 * DAY, async () => {
|
||||
const b = await fetchFace(mdId, vol, side)
|
||||
if (!b) return { found: false }
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
fs.writeFileSync(tmp, b)
|
||||
fs.renameSync(tmp, file)
|
||||
return { found: true }
|
||||
}, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
|
||||
}
|
||||
return inflight.get(key)
|
||||
}
|
||||
|
||||
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
|
||||
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
|
||||
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
|
||||
async function commonsGet(params) {
|
||||
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
|
||||
await turn()
|
||||
net.guard()
|
||||
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||
const text = r?.ok ? await r.text() : ''
|
||||
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
|
||||
const data = text.startsWith('{') ? JSON.parse(text) : null
|
||||
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
|
||||
return data
|
||||
}
|
||||
async function commonsLogo(names) {
|
||||
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
|
||||
for (const q of queries) {
|
||||
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
|
||||
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
|
||||
if (!best) continue
|
||||
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
|
||||
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
|
||||
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
|
||||
function logo(mangaId, lang) {
|
||||
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
|
||||
const { manga } = await fetchMangaFull(mangaId, lang)
|
||||
if (hasNoVolumes(manga.type)) return null
|
||||
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
|
||||
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
|
||||
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
|
||||
return commonsLogo(names)
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---------- API ----------
|
||||
async function list(mangaId, lang) {
|
||||
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
|
||||
if (hasNoVolumes(manga.type)) return []
|
||||
const source = sourceOf(mangaId)
|
||||
// Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
|
||||
const found = await mdMatches(mangaId).catch(() => [])
|
||||
const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
|
||||
const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
|
||||
const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
|
||||
const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
|
||||
if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
|
||||
const mdId = source === 'md' ? mangaId : best?.id || null
|
||||
const data = editions.find(e => e.id === mdId)?.data || null
|
||||
const own = cleanVolMap(ownVolMap(chapters))
|
||||
let vols
|
||||
if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
|
||||
else {
|
||||
const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
|
||||
const [wiki, mi] = await Promise.all([
|
||||
wikiMap(mangaId, names).catch(() => ({})),
|
||||
source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
|
||||
])
|
||||
const byNum = cleanVolMap(best?.data.byNum || {})
|
||||
const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
|
||||
// Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
|
||||
const known = { ...main.wiki, ...main.md }
|
||||
const missing = chapters.filter(c => !(numKey(c.num) in known)).length
|
||||
const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
|
||||
vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
|
||||
}
|
||||
vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
|
||||
const groups = new Map()
|
||||
vols.forEach((vol, i) => {
|
||||
if (!vol) return
|
||||
if (!groups.has(vol)) groups.set(vol, [])
|
||||
groups.get(vol).push(chapters[i].id)
|
||||
})
|
||||
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
|
||||
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
|
||||
return [...groups.keys()].sort(volOrder).map(vol => {
|
||||
const cover = data?.covers[vol]
|
||||
return {
|
||||
vol,
|
||||
chapters: groups.get(vol),
|
||||
pages: pagesOf(groups.get(vol)),
|
||||
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
|
||||
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
|
||||
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
|
||||
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
async function sendFace(res, mdId, vol, side) {
|
||||
if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
|
||||
const file = await face(mdId, vol, side)
|
||||
if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
|
||||
const head = Buffer.alloc(16)
|
||||
const fd = fs.openSync(file, 'r')
|
||||
fs.readSync(fd, head, 0, 16, 0)
|
||||
fs.closeSync(fd)
|
||||
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
|
||||
fs.createReadStream(file).pipe(res)
|
||||
}
|
||||
return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
|
||||
}
|
||||
Reference in New Issue
Block a user