Files
manga-reader/app/volumes.js
T

771 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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 } }
}