Manga Reader: lector web de manga con historial de lectura

Basado en las fuentes de Tanko (MangaDex y LeerCapitulo), con usuarios
con contraseña, progreso por página guardado en SQLite, lector por página
o vertical, filtros de imagen y proxy de imágenes con URLs firmadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 23:26:33 -06:00
co-authored by Claude Opus 5.5
commit 9d031d4ba3
11 changed files with 1171 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
const app = document.getElementById('app')
const SOURCES = { lc: 'LeerCapitulo (español)', md: 'MangaDex (multi-idioma)' }
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
const FILTERS = {
none: ['Sin filtro', ''],
sepia: ['Sepia', 'sepia(.65) contrast(.95)'],
paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'],
vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'],
night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'],
gray: ['Blanco y negro', 'grayscale(1)'],
contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'],
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
}
const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' }
let me = null
let cleanup = null
// ---------- Utilidades ----------
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const $ = (sel, root = document) => root.querySelector(sel)
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} },
}
function toast(msg) {
const t = document.getElementById('toast')
t.textContent = msg
t.hidden = false
clearTimeout(toast.t)
toast.t = setTimeout(() => (t.hidden = true), 2500)
}
async function api(path, opts = {}) {
const res = await fetch(path, {
...opts,
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
body: opts.body ? JSON.stringify(opts.body) : undefined,
})
const data = await res.json().catch(() => ({}))
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
return data
}
function timeAgo(ts) {
const s = (Date.now() - ts) / 1000
if (s < 60) return 'hace un momento'
if (s < 3600) return `hace ${Math.floor(s / 60)} min`
if (s < 86400) return `hace ${Math.floor(s / 3600)} h`
return `hace ${Math.floor(s / 86400)} d`
}
const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '')
const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}`
// ---------- Router ----------
async function router() {
if (cleanup) { cleanup(); cleanup = null }
document.body.classList.remove('reading')
const [pathPart, query] = location.hash.slice(1).split('?')
const parts = (pathPart || '/').split('/').filter(Boolean)
const params = new URLSearchParams(query || '')
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
if (!me) {
const r = await api('/api/me').catch(() => ({}))
me = r.user
me && (me.signup = r.signup)
if (!me) return viewAuth(r.signup)
}
try {
if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'settings') return viewSettings()
return await viewHome(params)
} catch (e) {
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
}
}
window.addEventListener('hashchange', router)
router()
// ---------- Login / registro ----------
function viewAuth(signup) {
app.innerHTML = `
<div class="auth">
<h1>Manga Reader</h1>
<p class="muted">Inicia sesión para leer y guardar tu progreso.</p>
<form>
<input type="text" name="username" placeholder="Usuario" autocomplete="username" required>
<input type="password" name="password" placeholder="Contraseña" autocomplete="current-password" required>
<p class="error"></p>
<button class="btn primary" value="login">Entrar</button>
${signup ? '<button class="btn" value="register">Crear cuenta</button>' : ''}
</form>
</div>`
const form = $('form', app)
form.addEventListener('submit', async ev => {
ev.preventDefault()
const mode = ev.submitter?.value === 'register' ? 'register' : 'login'
const fd = new FormData(form)
try {
await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } })
me = null
location.hash = '#/'
router()
} catch (e) { $('.error', form).textContent = e.message }
})
}
// ---------- Inicio ----------
function mangaCard(m) {
return `<a class="card" href="#/manga/${m.id}">
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="">
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
}
function continueCard(h) {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
return `<a class="cont" href="${readUrl(h.manga_id, h.chapter_id, h.page)}">
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy">
<div class="info"><b>${esc(h.manga_title)}</b>
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
}
async function viewHome(params) {
const q = params.get('q') || ''
const mode = params.get('mode') || 'popular'
const anyLang = params.get('any') === '1'
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
store.set('source', source)
app.innerHTML = `
<section id="cont"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar en ${esc(SOURCES[source])}…" value="${esc(q)}"><button class="btn primary">Buscar</button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
${q ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes"><button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">Populares</button><button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">Actualizados recientemente</button></div>`}
<div id="results" class="loading">Cargando…</div>`
const form = $('form', app)
const go = (extra = {}) => {
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
location.hash = `#/?${p}`
}
form.addEventListener('submit', e => { e.preventDefault(); go() })
$('[name=any]').addEventListener('change', () => go())
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => go({ source: b.dataset.s })))
api('/api/history').then(hist => {
if (!hist.length || q) return
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>`
}).catch(() => {})
const qs = new URLSearchParams({ q, mode, source, ...(anyLang ? { anyLang: '1' } : {}) })
const list = await api(`/api/search?${qs}`)
const res = $('#results')
res.className = list.length ? 'grid' : 'loading'
res.innerHTML = list.length ? list.map(mangaCard).join('') : 'Sin resultados.'
}
// ---------- Ficha del manga ----------
async function viewManga(id, langOverride) {
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source === 'lc'
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
const first = chapters[0]
let mainBtn = ''
if (progress && chapters.some(c => c.id === progress.chapter_id)) {
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}</a>`
} else if (progress) {
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}</a>`
}
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">Empezar a leer</a>`
app.innerHTML = `
<div class="detail">
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<div>
<h1>${esc(manga.title)}</h1>
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
<div class="tags">${manga.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
<p class="desc">${esc(manga.description)}</p>
<div class="row">${mainBtn}
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
<a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`}
</div>
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}</div>
</div>
</div>
<h2>Capítulos <span class="muted">(${chapters.length})</span>
${chapters.length > 1 ? '<button class="btn small" id="order" style="float:right">Invertir orden</button>' : ''}</h2>
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}`
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
const ul = $('.chapters', app)
let desc = store.get('chapterOrderDesc', true)
const renderList = () => {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
ul.innerHTML = list.map(c => {
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
const meta = r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
}).join('')
}
renderList()
$('#order')?.addEventListener('click', () => { desc = !desc; store.set('chapterOrderDesc', desc); renderList() })
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
}
// ---------- Lector ----------
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
const lang = store.get(`lang:${mangaId}`, me.lang)
const settings = { mode: 'paged', fit: 'height', dir: 'ltr', saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${settings.saver ? 1 : 0}`),
])
const { manga, chapters } = info
const idx = chapters.findIndex(c => c.id === chapterId)
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const pages = chap.pages
const total = pages.length
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
// Guardado del progreso (con debounce y al salir de la página).
let saveTimer = null
const payload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page, totalPages: total,
})
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(payload())) } }
const onVis = () => document.visibilityState === 'hidden' && beacon()
document.addEventListener('visibilitychange', onVis)
window.addEventListener('pagehide', beacon)
saveNow()
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
app.innerHTML = `
<div class="reader">
<div class="rbar top">
<a class="btn small" href="#/manga/${mangaId}">←</a>
<div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select>
<select id="mode"><option value="paged">Por página</option><option value="vertical">Vertical</option></select>
<select id="fit"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
<select id="dir"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
${manga.source === 'lc' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
</div>
<div id="pages"></div>
<div class="rbar bottom">
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">« Cap. ant.</a>` : ''}
<span id="pnum" class="muted"></span>
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
${next ? `<a class="btn small" href="${readUrl(mangaId, next.id)}">Cap. sig. »</a>` : ''}
</div>
</div>`
const reader = $('.reader', app)
const box = $('#pages', app)
const slider = $('#slider', app)
$('#mode').value = settings.mode
$('#fit').value = settings.fit
$('#dir').value = settings.dir
$('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none'
$('#bright').value = settings.brightness
const applyFilter = () => {
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
box.style.setProperty('--page-filter', f || 'none')
}
applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">Volver a la ficha</a></div>`
const setPageLabel = () => { $('#pnum').textContent = `${page + 1} / ${total}`; slider.value = page + 1 }
let observer = null
let atEnd = false
function goTo(n, fromScroll = false) {
if (n >= total && settings.mode === 'paged') { atEnd = true; box.innerHTML = endBlock; reader.classList.remove('hide-ui'); return }
n = Math.min(Math.max(n, 0), total - 1)
const changed = n !== page || atEnd
atEnd = false
page = n
setPageLabel()
if (settings.mode === 'paged') {
const img = $('img', box) || Object.assign(document.createElement('img'), { alt: '' })
if (!img.isConnected) { box.innerHTML = ''; box.append(img) }
img.src = pages[page]
window.scrollTo(0, 0)
for (let k = 1; k <= 3; k++) if (pages[page + k]) new Image().src = pages[page + k]
} else if (!fromScroll) {
box.querySelectorAll('img')[page]?.scrollIntoView()
}
if (changed) scheduleSave()
}
function render() {
observer?.disconnect()
box.className = settings.mode === 'paged' ? `paged ${settings.fit === 'width' ? 'fit-width' : ''}` : 'vertical'
$('#fit').hidden = settings.mode !== 'paged'
$('#dir').hidden = settings.mode !== 'paged'
if (settings.mode === 'paged') {
box.innerHTML = ''
goTo(page)
return
}
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
}), { rootMargin: '2000px 0px' })
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
let ready = false
observer = new IntersectionObserver(entries => {
if (!ready) return
for (const e of entries) if (e.isIntersecting) goTo(Number(e.target.dataset.i), true)
}, { rootMargin: '-45% 0px -45% 0px' })
const imgs = [...box.querySelectorAll('img')]
imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
const lazyDisconnect = observer.disconnect.bind(observer)
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
imgs[page]?.scrollIntoView()
lastY = window.scrollY
setTimeout(() => (ready = true), 300)
})
setPageLabel()
}
const saveSettings = () => store.set('readerSettings', settings)
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; saveSettings(); render() })
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; saveSettings(); render() })
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; saveSettings() })
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveSettings(); applyFilter() })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveSettings(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveSettings(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveSettings(); history.replaceState(null, '', readUrl(mangaId, chapterId, page)); router() })
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
// Toques: izquierda/derecha cambian de página (según dirección), centro muestra/oculta barras.
box.addEventListener('click', e => {
if (e.target.closest('a,button')) return
if (settings.mode !== 'paged') { reader.classList.toggle('hide-ui'); return }
const x = e.clientX / window.innerWidth
if (x > 0.35 && x < 0.65) { reader.classList.toggle('hide-ui'); return }
const forward = (x >= 0.65) === (settings.dir === 'ltr')
if (atEnd && !forward) { atEnd = false; box.innerHTML = ''; goTo(total - 1); return }
if (!atEnd) goTo(page + (forward ? 1 : -1))
})
const onKey = e => {
if (settings.mode !== 'paged' || e.target.matches('input,select')) return
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); if (!atEnd) goTo(page + 1) }
else if (e.key === backKey) { if (atEnd) { atEnd = false; box.innerHTML = ''; goTo(total - 1) } else goTo(page - 1) }
}
document.addEventListener('keydown', onKey)
let lastY = 0
const onScroll = () => {
if (settings.mode !== 'vertical') return
const y = window.scrollY
if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y }
}
window.addEventListener('scroll', onScroll, { passive: true })
render()
cleanup = () => {
if (saveTimer) saveNow()
observer?.disconnect()
document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('pagehide', beacon)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll)
}
}
// ---------- Historial ----------
async function viewHistory() {
app.innerHTML = '<div class="loading">Cargando…</div>'
const hist = await api('/api/history')
app.innerHTML = `<h1>Historial de lectura</h1>
${hist.length ? '' : '<p class="muted">Aún no has leído nada.</p>'}
<div class="history">${hist.map(h => `
<div class="cont">
${continueCard(h).replace('class="cont"', 'style="display:flex;gap:12px;flex:1;min-width:0;align-items:center"')}
<div class="row" style="flex:none">
<a class="btn small" href="#/manga/${h.manga_id}">Ficha</a>
<button class="btn small danger" data-del="${h.manga_id}">Borrar</button>
</div>
</div>`).join('')}</div>`
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
viewHistory()
}))
}
// ---------- Ajustes ----------
function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1>
<div class="settings">
<section><b>Cuenta</b><div class="muted">Sesión iniciada como <b>${esc(me.username)}</b></div>
<button class="btn" id="logout">Cerrar sesión</button></section>
<section><b>Idioma preferido de los capítulos</b>
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
<section><b>Cambiar contraseña</b>
<form id="pw" style="display:grid;gap:8px">
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="6">
<button class="btn primary">Guardar</button></form></section>
</div>`
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
$('#pw').addEventListener('submit', async e => {
e.preventDefault()
try {
await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } })
e.target.reset(); toast('Contraseña cambiada')
} catch (err) { toast(err.message) }
})
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#bf78fa"/><path d="M14 16h16v32H14zM34 16h16v32H34z" fill="#14121a"/><path d="M18 22h8M18 28h8M18 34h6M38 22h8M38 28h8M38 34h6" stroke="#bf78fa" stroke-width="2.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 298 B

+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a">
<title>Manga Reader</title>
<link rel="icon" href="/icon.svg">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header id="topbar">
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> Manga Reader</a>
<nav>
<a href="#/">Inicio</a>
<a href="#/history">Historial</a>
<a href="#/settings">Ajustes</a>
</nav>
</header>
<main id="app"></main>
<div id="toast" hidden></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
:root {
--bg: #14121a;
--surface: #1e1b26;
--surface-2: #2a2635;
--text: #ece8f4;
--muted: #9a93ab;
--accent: #bf78fa;
--accent-ink: #14121a;
--danger: #ff6b81;
--radius: 10px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; color: inherit; }
img { display: block; }
#topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 16px; background: rgba(20, 18, 26, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--surface-2); }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
#topbar nav { display: flex; gap: 4px; }
#topbar nav a { padding: 6px 10px; border-radius: 8px; color: var(--muted); }
#topbar nav a.active, #topbar nav a:hover { color: var(--text); background: var(--surface-2); }
body.reading #topbar { display: none; }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
body.reading main { max-width: none; padding: 0; }
h1 { font-size: 1.6rem; margin: 0 0 8px; }
h2 { font-size: 1.15rem; margin: 24px 0 12px; }
.muted { color: var(--muted); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--surface-2);
border-radius: var(--radius); background: var(--surface); cursor: pointer; }
.btn:hover { background: var(--surface-2); }
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.small { padding: 5px 10px; font-size: .88rem; }
.btn.danger { color: var(--danger); }
input[type=text], input[type=password], input[type=search], select {
padding: 9px 12px; border-radius: var(--radius); border: 1px solid var(--surface-2); background: var(--surface); outline: none; }
input:focus, select:focus { border-color: var(--accent); }
.auth { max-width: 360px; margin: 10vh auto; background: var(--surface); padding: 24px; border-radius: 14px; }
.auth form { display: grid; gap: 12px; }
.error { color: var(--danger); min-height: 1.2em; margin: 0; }
.search { display: flex; gap: 8px; margin-bottom: 8px; }
.search input { flex: 1; min-width: 0; }
.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; }
.tabs button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--surface-2); background: transparent; cursor: pointer; color: var(--muted); }
.tabs button.on { background: var(--surface-2); color: var(--text); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }
.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); }
.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); }
.card .t { padding: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; }
.bar { height: 4px; background: var(--surface-2); }
.bar > i { display: block; height: 100%; background: var(--accent); }
.continue { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 320px); gap: 12px; overflow-x: auto; padding-bottom: 6px; }
.cont { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius); background: var(--surface); align-items: center; }
.cont img { width: 56px; height: 84px; object-fit: cover; border-radius: 6px; background: var(--surface-2); flex: none; }
.cont .info { min-width: 0; flex: 1; }
.cont .info b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cont .bar { margin-top: 6px; border-radius: 2px; }
.detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
.detail .cover { width: 100%; border-radius: var(--radius); aspect-ratio: 2/3; object-fit: cover; background: var(--surface-2); }
.desc { white-space: pre-line; color: #cfc8dc; max-height: 12em; overflow: auto; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.tag { font-size: .78rem; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); }
.chapters { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.chapters a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); align-items: center; }
.chapters a:hover { background: var(--surface-2); }
.chapters a.read { opacity: .5; }
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
.history { display: grid; gap: 10px; }
.history .cont { justify-content: space-between; }
.settings { max-width: 480px; display: grid; gap: 16px; }
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
.loading { padding: 40px; text-align: center; color: var(--muted); }
/* ---- Lector ---- */
.reader { min-height: 100vh; background: #000; position: relative; }
.rbar { position: fixed; left: 0; right: 0; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: rgba(10, 9, 14, .9); backdrop-filter: blur(6px); transition: transform .2s; flex-wrap: wrap; }
.rbar.top { top: 0; padding-top: max(8px, env(safe-area-inset-top)); }
.rbar.bottom { bottom: 0; justify-content: center; padding-bottom: max(8px, env(safe-area-inset-bottom)); }
.reader.hide-ui .rbar.top { transform: translateY(-110%); }
.reader.hide-ui .rbar.bottom { transform: translateY(110%); }
.rbar .ttl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .9rem; }
.rbar select { padding: 5px 8px; }
.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); }
#pages img { filter: var(--page-filter, none); transition: filter .2s; }
.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; }
.paged img { max-width: 100%; max-height: 100%; object-fit: contain; }
.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; }
.paged.fit-width img { max-height: none; width: 100%; max-width: 900px; }
.vertical { display: flex; flex-direction: column; align-items: center; }
.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; }
.vertical img[data-loaded] { min-height: 0; }
.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; }
#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2);
padding: 10px 16px; border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.4); }
@media (max-width: 640px) {
.detail { grid-template-columns: 1fr; }
.detail .cover { max-width: 180px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; }
#topbar .brand span { display: none; }
#topbar nav a { padding: 6px 8px; }
}
+510
View File
@@ -0,0 +1,510 @@
// Manga Reader: lector web con las mismas fuentes que Tanko (MangaDex y LeerCapitulo)
// con usuarios y historial de lectura persistente en SQLite.
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
const ALLOW_SIGNUP = (process.env.ALLOW_SIGNUP ?? 'true') !== 'false'
const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public')
const MD_API = 'https://api.mangadex.org'
const LC_BASE = 'https://www.leercapitulo.co'
const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'
const UA = 'manga-reader-selfhosted/1.0'
const SESSION_DAYS = 120
// ---------- Base de datos ----------
fs.mkdirSync(DATA_DIR, { recursive: true })
const db = new DatabaseSync(path.join(DATA_DIR, 'manga.db'))
const SECRET_FILE = path.join(DATA_DIR, 'secret')
if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 })
const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim()
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
pass_hash TEXT NOT NULL,
lang TEXT NOT NULL DEFAULT 'es-la',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
manga_title TEXT NOT NULL,
cover TEXT,
chapter_id TEXT NOT NULL,
chapter_num TEXT,
chapter_title TEXT,
lang TEXT,
page INTEGER NOT NULL DEFAULT 0,
total_pages INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, manga_id)
);
CREATE TABLE IF NOT EXISTS chapter_reads (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL,
manga_id TEXT NOT NULL,
page INTEGER NOT NULL DEFAULT 0,
total_pages INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, chapter_id)
);
CREATE INDEX IF NOT EXISTS idx_reads_manga ON chapter_reads(user_id, manga_id);
`)
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
// ---------- Contraseñas y sesiones ----------
function hashPassword(pw) {
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(pw, salt, 64)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
function verifyPassword(pw, stored) {
const [saltHex, hashHex] = stored.split(':')
const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 64)
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'))
}
function createSession(userId) {
const token = crypto.randomBytes(32).toString('base64url')
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, userId, Date.now() + SESSION_DAYS * 86400e3)
return token
}
function sessionCookie(token, maxAge) {
return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
}
function getUser(req) {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (!m) return null
return db.prepare(`SELECT u.id, u.username, u.lang FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null
}
// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ----------
const cache = new Map()
async function cached(key, ttl, fn) {
const hit = cache.get(key)
if (hit && hit.exp > Date.now()) return hit.data
const data = await fn()
cache.set(key, { data, exp: Date.now() + ttl })
if (cache.size > 500) cache.delete(cache.keys().next().value)
return data
}
// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor.
const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22)
const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
async function md(pathname, params = {}, ttl = 5 * 60e3) {
const url = new URL(MD_API + pathname)
for (const [k, v] of Object.entries(params)) {
if (Array.isArray(v)) v.forEach(x => url.searchParams.append(k, x))
else if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
return cached(url.toString(), ttl, async () => {
const res = await fetch(url, { headers: { 'User-Agent': UA } })
if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`)
return res.json()
})
}
const pick = (obj, langs) => {
if (!obj) return ''
for (const l of langs) if (obj[l]) return obj[l]
return Object.values(obj)[0] ?? ''
}
function mangaSummary(m, lang) {
const a = m.attributes
const langs = [lang, 'es', 'es-la', 'en', 'ja-ro']
let title = a.title.en ?? Object.values(a.title)[0]
const alt = (a.altTitles || []).find(t => t[lang] || t.es || t['es-la'])
if (alt) title = pick(alt, [lang, 'es-la', 'es'])
const coverRel = m.relationships?.find(r => r.type === 'cover_art')
const cover = coverRel?.attributes?.fileName
? imgUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.256.jpg`)
: null
return {
id: m.id,
source: 'md',
title,
description: pick(a.description, langs),
status: a.status,
year: a.year,
tags: (a.tags || []).map(t => pick(t.attributes.name, ['es', 'en'])).slice(0, 8),
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
languages: a.availableTranslatedLanguages || [],
cover,
}
}
const RATINGS = ['safe', 'suggestive']
async function searchManga(q, lang, mode) {
const params = {
limit: 30,
'includes[]': ['cover_art'],
'contentRating[]': RATINGS,
'availableTranslatedLanguage[]': lang ? [lang] : undefined,
}
if (q) { params.title = q; params['order[relevance]'] = 'desc' }
else if (mode === 'latest') params['order[latestUploadedChapter]'] = 'desc'
else params['order[followedCount]'] = 'desc'
const data = await md('/manga', params)
return data.data.map(m => mangaSummary(m, lang))
}
async function getManga(id, lang) {
const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3)
return mangaSummary(data.data, lang)
}
// Lista de capítulos en un idioma, un registro por número de capítulo (como Tanko).
// El español junta es-la y es, prefiriendo la variante elegida.
async function getChapters(mangaId, lang) {
const langs = lang === 'es-la' ? ['es-la', 'es'] : lang === 'es' ? ['es', 'es-la'] : [lang]
const all = []
for (let offset = 0; ; offset += 500) {
const data = await md(`/manga/${mangaId}/feed`, {
limit: 500, offset,
'translatedLanguage[]': langs,
'order[volume]': 'asc', 'order[chapter]': 'asc',
'contentRating[]': [...RATINGS, 'erotica'],
includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0,
'includes[]': ['scanlation_group'],
})
all.push(...data.data)
if (offset + 500 >= data.total || data.data.length === 0) break
}
const byNum = new Map()
for (const c of all) {
const num = c.attributes.chapter ?? `oneshot-${c.id}`
const prev = byNum.get(num)
if (prev && langs.indexOf(prev.lang) <= langs.indexOf(c.attributes.translatedLanguage)) continue
byNum.set(num, {
id: c.id,
num: c.attributes.chapter,
volume: c.attributes.volume,
title: c.attributes.title || '',
pages: c.attributes.pages,
group: c.relationships.find(r => r.type === 'scanlation_group')?.attributes?.name || '',
publishAt: c.attributes.publishAt,
lang: c.attributes.translatedLanguage,
})
}
const out = [...byNum.values()]
out.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0))
return out
}
// Idiomas con capítulos legibles de verdad (availableTranslatedLanguages incluye capítulos retirados o externos).
async function getChapterLangs(mangaId) {
const data = await md(`/manga/${mangaId}/feed`, {
limit: 500, includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0,
'contentRating[]': [...RATINGS, 'erotica'],
}, 30 * 60e3)
const count = {}
for (const c of data.data) count[c.attributes.translatedLanguage] = (count[c.attributes.translatedLanguage] || 0) + 1
return Object.entries(count).sort((a, b) => b[1] - a[1]).map(([l]) => l)
}
async function getPages(chapterId, saver) {
// Las URLs de at-home caducan a los ~15 min: caché corta.
const data = await md(`/at-home/server/${chapterId}`, {}, 5 * 60e3)
const { baseUrl, chapter } = data
const files = saver ? chapter.dataSaver : chapter.data
const kind = saver ? 'data-saver' : 'data'
return files.map(f => imgUrl(`${baseUrl}/${kind}/${chapter.hash}/${f}`))
}
// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ----------
// IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
// las páginas del capítulo vienen en el HTML como <img data-src>.
const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()]
if (named) return named
return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
}).trim()
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => {
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' } })
if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`)
return asJson ? res.json() : res.text()
})
}
const lcCover = src => (src ? imgUrl(new URL(src, LC_BASE).toString()) : null)
const lcId = uri => {
const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '')
return m ? `lc~${m[1]}~${m[2]}` : null
}
function lcParseCards(html) {
const re = /class="lc-(?:slide|side-item|release|card)(?:-cover)?" href="(\/manga\/[a-z0-9]+\/[a-z0-9-]+\/)"[^>]*>\s*<img src="([^"]+)"[\s\S]*?class="lc-(?:slide|side|release|card)-(?:name|title)[^"]*"[^>]*>([^<]+)/g
const out = []
const seen = new Set()
for (const m of html.matchAll(re)) {
const id = lcId(m[1])
if (!id || seen.has(id)) continue
seen.add(id)
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2]) })
}
return out
}
async function lcSearch(q, mode) {
if (q) {
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name }))
.filter(e => e.id)
}
const html = await lc('/')
const section = (from, to) => {
const i = html.indexOf(from)
if (i < 0) return ''
const j = html.indexOf(to, i + from.length)
return html.slice(i, j < 0 ? undefined : j)
}
if (mode === 'latest') return lcParseCards(section('<h1>Ultimos capitulos', '<footer'))
const popular = [...lcParseCards(section('<h2>Tendencias', '</section>')), ...lcParseCards(section('>Populares</h2>', 'lc-panel-title'))]
return popular.filter((m, i) => popular.findIndex(x => x.id === m.id) === i)
}
async function lcManga(id) {
const [, mid, slug] = id.split('~')
const html = await lc(`/manga/${mid}/${slug}/`)
const fact = k => decode(new RegExp(`<span class="k">${k}</span>\\s*<(?:a|span)[^>]*>([^<]*)<`).exec(html)?.[1])
const sinopsis = /id="sinopsis"[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/.exec(html)?.[1]
const chapters = []
const seen = new Set()
const re = new RegExp(`href="/leer/${mid}/${slug}/([0-9a-z.-]+)/">\\s*<span class="n">([^<]*)</span>[\\s\\S]*?<span class="d">([^<]*)</span>`, 'g')
for (const m of html.matchAll(re)) {
if (seen.has(m[1])) continue
seen.add(m[1])
const title = decode(m[2]).replace(/^Cap[ií]tulo\s+[0-9.]+\s*[:-]?\s*/i, '')
chapters.push({ id: `${id}~${m[1]}`, num: m[1], volume: null, title, pages: null, group: m[3], publishAt: m[3] })
}
chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0))
const manga = {
id,
source: 'lc',
title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
status: fact('Estado'),
year: null,
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+)</g)].map(m => decode(m[1])).slice(0, 10),
authors: fact('Autor') ? [fact('Autor')] : [],
languages: ['es'],
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1]),
}
return { manga, chapters }
}
async function lcPages(chapterId) {
const [, mid, slug, num] = chapterId.split('~')
const html = await lc(`/leer/${mid}/${slug}/${num}/`, 30 * 60e3)
const main = html.slice(html.indexOf('id="lcPages"'))
const pages = [...main.matchAll(/<img data-src="(https?:\/\/[^"]+)"/g)].map(m => imgUrl(decode(m[1])))
if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo')
return pages
}
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
async function proxyImage(req, res, u, s) {
let url
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
const expected = sign(u)
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
const isMd = /(^|\.)mangadex\.(org|network)$/.test(url.hostname)
const r = await fetch(url, { headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
'Cache-Control': 'private, max-age=604800, immutable',
})
for await (const chunk of r.body) res.write(chunk)
res.end()
}
// ---------- HTTP ----------
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status }
}
function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
res.end(JSON.stringify(body))
}
async function readBody(req) {
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > 64 * 1024) throw new HttpError(413, 'Cuerpo demasiado grande')
chunks.push(c)
}
try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') }
}
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
const requireUser = req => {
const u = getUser(req)
if (!u) throw new HttpError(401, 'No autenticado')
return u
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json' }
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
fs.createReadStream(file).pipe(res)
}
const loginAttempts = new Map()
function rateLimit(ip) {
const now = Date.now()
const list = (loginAttempts.get(ip) || []).filter(t => now - t < 10 * 60e3)
if (list.length >= 10) throw new HttpError(429, 'Demasiados intentos, espera unos minutos')
list.push(now)
loginAttempts.set(ip, list)
}
async function route(req, res) {
const url = new URL(req.url, 'http://x')
const p = url.pathname
const q = url.searchParams
const method = req.method
if (!p.startsWith('/api/')) return serveStatic(res, p)
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(req.socket.remoteAddress)
const { username, password } = await readBody(req)
if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos')
let user
if (p === '/api/register') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
if (!ALLOW_SIGNUP && userCount > 0) throw new HttpError(403, 'El registro está deshabilitado')
if (!/^[\w.-]{3,32}$/.test(username)) throw new HttpError(400, 'Usuario: 3-32 caracteres (letras, números, . _ -)')
if (password.length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
if (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) throw new HttpError(409, 'Ese usuario ya existe')
const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)')
.run(username, hashPassword(password), Date.now())
user = { id: Number(r.lastInsertRowid) }
} else {
const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username)
if (!row || !verifyPassword(password, row.pass_hash)) throw new HttpError(401, 'Usuario o contraseña incorrectos')
user = row
}
const token = createSession(user.id)
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400) })
}
if (p === '/api/logout' && method === 'POST') {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1])
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0) })
}
// Todo lo demás requiere sesión.
const user = requireUser(req)
if (p === '/api/settings' && method === 'POST') {
const body = await readBody(req)
if (body.lang !== undefined) {
if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido')
db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id)
}
if (body.newPassword !== undefined) {
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
if (!verifyPassword(String(body.oldPassword || ''), row.pass_hash)) throw new HttpError(401, 'Contraseña actual incorrecta')
if (String(body.newPassword).length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id)
}
return json(res, 200, { ok: true })
}
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'))
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/search' && method === 'GET') {
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode')))
const anyLang = q.get('anyLang') === '1'
return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode')))
}
let m
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
const { manga, chapters } = m[1].startsWith('lc~')
? await lcManga(m[1])
: { manga: await getManga(m[1], lang), chapters: await getChapters(m[1], lang) }
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source === 'lc' ? [] : await getChapterLangs(m[1])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const pages = m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { pages, savedPage: read?.page ?? 0 })
}
// --- Historial ---
if (p === '/api/history' && method === 'GET') {
return json(res, 200, db.prepare('SELECT * FROM progress WHERE user_id = ? ORDER BY updated_at DESC LIMIT 200').all(user.id))
}
if (p === '/api/progress' && method === 'POST') {
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(b.mangaId.startsWith('lc~') ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos')
const page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0)
const now = Date.now()
db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover,
chapter_id = excluded.chapter_id, chapter_num = excluded.chapter_num, chapter_title = excluded.chapter_title,
lang = excluded.lang, page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
.run(user.id, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
b.chapterId, b.chapterNum != null ? String(b.chapterNum) : null, String(b.chapterTitle || '').slice(0, 300),
LANG.test(b.lang || '') ? b.lang : null, page, total, now)
db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
.run(user.id, b.chapterId, b.mangaId, page, total, now)
return json(res, 200, { ok: true })
}
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
return json(res, 200, { ok: true })
}
throw new HttpError(404, 'No encontrado')
}
// Migración: portadas guardadas antes de firmar las URLs del proxy.
for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(imgUrl(u), row.user_id, row.manga_id)
}
http.createServer(async (req, res) => {
try {
await route(req, res)
} catch (err) {
const status = err instanceof HttpError ? err.status : 500
if (status >= 500) console.error(req.method, req.url, err)
if (!res.headersSent) json(res, status, { error: err.message || 'Error' })
else res.end()
}
}).listen(PORT, () => console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'})`))