#!/usr/bin/env node /** * MangaDex wrapper (ESM) — no API key * Flow: home/search → detail → chapters → read * * node mangadex.js home * node mangadex.js search "solo leveling" * node mangadex.js detail * node mangadex.js chapters --lang en * node mangadex.js read */ import axios from 'axios'; import { pathToFileURL } from 'url'; import path from 'path'; import process from 'process'; const API = 'https://api.mangadex.org'; const UP = 'https://uploads.mangadex.org'; const REPORT = 'https://api.mangadex.network/report'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function pick(obj, langs = ['en', 'ja-ro', 'ja', 'id']) { if (!obj || typeof obj !== 'object') return null; for (const l of langs) if (obj[l]) return obj[l]; const k = Object.keys(obj); return k.length ? obj[k[0]] : null; } function flat(params = {}) { const out = {}; for (const [k, v] of Object.entries(params)) { if (v == null) continue; if (Array.isArray(v)) out[`${k}[]`] = v; else if (typeof v === 'object') { for (const [sk, sv] of Object.entries(v)) out[`${k}[${sk}]`] = sv; } else out[k] = v; } return out; } export class MangaDex { constructor({ timeout = 20000, rateMs = 220 } = {}) { this.rateMs = rateMs; this._last = 0; this.http = axios.create({ baseURL: API, timeout, headers: { 'User-Agent': 'MangaDex-Wrapper/3.1', Accept: 'application/json', }, }); } async req(method, url, { params, data } = {}) { const wait = this.rateMs - (Date.now() - this._last); if (wait > 0) await sleep(wait); this._last = Date.now(); try { const res = await this.http.request({ method, url, params: params ? flat(params) : undefined, data, }); return res.data; } catch (err) { const status = err.response?.status; const msg = err.response?.data?.errors?.[0]?.detail || err.response?.data?.message || err.message; const e = new Error(`API ${status || '?'}: ${msg}`); e.status = status; throw e; } } // ── mappers ────────────────────────────────────────────────────────────── _manga(e) { if (!e) return null; const a = e.attributes || {}; const rels = e.relationships || []; const cover = rels.find((r) => r.type === 'cover_art'); const fn = cover?.attributes?.fileName; const authors = rels .filter((r) => r.type === 'author' || r.type === 'artist') .map((r) => r.attributes?.name) .filter(Boolean); return { id: e.id, title: pick(a.title), description: (pick(a.description) || '').slice(0, 300) || null, status: a.status, year: a.year, originalLanguage: a.originalLanguage, contentRating: a.contentRating, tags: (a.tags || []) .map((t) => pick(t.attributes?.name)) .filter(Boolean) .slice(0, 12), authors: [...new Set(authors)], cover: fn ? `${UP}/covers/${e.id}/${fn}.256.jpg` : null, coverFull: fn ? `${UP}/covers/${e.id}/${fn}` : null, lastChapter: a.lastChapter, lastVolume: a.lastVolume, }; } _chapter(e) { if (!e) return null; const a = e.attributes || {}; const rels = e.relationships || []; const manga = rels.find((r) => r.type === 'manga'); const groups = rels .filter((r) => r.type === 'scanlation_group') .map((r) => r.attributes?.name || r.id); return { id: e.id, chapter: a.chapter, volume: a.volume, title: a.title || null, lang: a.translatedLanguage, pages: a.pages, publishAt: a.publishAt, externalUrl: a.externalUrl || null, mangaId: manga?.id || null, mangaTitle: manga?.attributes?.title ? pick(manga.attributes.title) : null, groups, }; } _list(data, mapFn) { return { data: (data.data || []).map(mapFn), total: data.total ?? 0, limit: data.limit ?? 0, offset: data.offset ?? 0, }; } // ── public API ─────────────────────────────────────────────────────────── async home({ limit = 12, lang = 'en' } = {}) { const [popular, latest, recent] = await Promise.all([ this.popular({ limit }), this.latest({ limit, lang }), this.recent({ limit }), ]); return { popular: popular.data, latest: latest.data, recent: recent.data, }; } async popular({ limit = 20, offset = 0 } = {}) { return this.search({ limit, offset, order: { followedCount: 'desc' }, hasAvailableChapters: true, }); } async recent({ limit = 20, offset = 0 } = {}) { return this.search({ limit, offset, order: { createdAt: 'desc' }, hasAvailableChapters: true, }); } async latest({ limit = 20, offset = 0, lang = 'en' } = {}) { const data = await this.req('GET', '/chapter', { params: { limit: Math.min(limit, 100), offset, translatedLanguage: [lang], order: { readableAt: 'desc' }, includes: ['manga', 'scanlation_group'], contentRating: [ 'safe', 'suggestive', 'erotica', 'pornographic', ], includeFutureUpdates: '0', }, }); const list = this._list(data, (e) => this._chapter(e)); // default: only chapters with pages list.data = list.data.filter((c) => c.pages > 0); return list; } async search({ title, limit = 10, offset = 0, status, lang, order, hasAvailableChapters, contentRating = ['safe', 'suggestive', 'erotica'], } = {}) { const params = { limit: Math.min(limit, 100), offset, title, status: status ? [status] : undefined, availableTranslatedLanguage: lang ? [lang] : undefined, order, hasAvailableChapters, contentRating, includes: ['cover_art', 'author', 'artist'], }; Object.keys(params).forEach( (k) => params[k] === undefined && delete params[k] ); const data = await this.req('GET', '/manga', { params }); return this._list(data, (e) => this._manga(e)); } async detail(id) { const data = await this.req('GET', `/manga/${id}`, { params: { includes: ['cover_art', 'author', 'artist', 'tag'], }, }); return this._manga(data.data); } async random() { const data = await this.req('GET', '/manga/random', { params: { includes: ['cover_art', 'author', 'artist'], }, }); return this._manga(data.data); } /** * List chapters. Default: only pages > 0 (readable on MD). * Pass includeEmpty: true to keep pages=0 / external. */ async chapters( mangaId, { limit = 100, offset = 0, lang = 'en', includeEmpty = false, } = {} ) { const data = await this.req('GET', `/manga/${mangaId}/feed`, { params: { limit: Math.min(limit, 100), offset, translatedLanguage: [lang], order: { volume: 'asc', chapter: 'asc' }, includes: ['scanlation_group'], contentRating: [ 'safe', 'suggestive', 'erotica', 'pornographic', ], includeFutureUpdates: '0', includeEmptyPages: includeEmpty ? '1' : '0', includeExternalUrl: includeEmpty ? '1' : '0', }, }); const list = this._list(data, (e) => this._chapter(e)); if (!includeEmpty) { list.data = list.data.filter( (c) => c.pages > 0 && !c.externalUrl ); } return list; } async allChapters( mangaId, { lang = 'en', includeEmpty = false } = {} ) { const all = []; let offset = 0; let total = Infinity; while (offset < total) { const page = await this.chapters(mangaId, { limit: 100, offset, lang, includeEmpty, }); all.push(...page.data); total = page.total; offset += 100; if (!page.data.length && offset >= total) break; // when filtering, total is pre-filter; stop if empty page if (!page.data.length && !includeEmpty) { // keep paging until API exhausted if (offset >= total) break; continue; } if (!page.data.length) break; } return all; } async chapter(id) { const data = await this.req('GET', `/chapter/${id}`, { params: { includes: ['manga', 'scanlation_group'], }, }); return this._chapter(data.data); } /** * Get page image URLs with auto-retry on bad MD@Home node. * quality: 'data' | 'data-saver' * retries: how many times to re-fetch at-home server */ async read(chapterId, quality = 'data', { retries = 3 } = {}) { const q = quality === 'data-saver' ? 'data-saver' : 'data'; let lastErr = null; for (let attempt = 0; attempt <= retries; attempt++) { if (attempt > 0) await sleep(400 * attempt); const data = await this.req( 'GET', `/at-home/server/${chapterId}` ); const { baseUrl, chapter } = data; if (!chapter?.hash) { throw new Error('No page data (external/empty chapter?)'); } const files = q === 'data-saver' ? chapter.dataSaver : chapter.data; if (!files?.length) { throw new Error('No page files (pages=0 or external)'); } const urls = files.map( (f) => `${baseUrl}/${q}/${chapter.hash}/${f}` ); // probe first page const ok = await this._probe(urls[0]); if (ok) { return { chapterId, quality: q, count: urls.length, urls, }; } // report failure so MD can avoid this node this._report(urls[0], false).catch(() => {}); lastErr = new Error( `MD@Home node failed (attempt ${attempt + 1}/${retries + 1})` ); } throw lastErr || new Error('Failed to get readable pages'); } /** HEAD/GET probe — true if image reachable */ async _probe(url) { try { const res = await axios.head(url, { timeout: 8000, validateStatus: (s) => s >= 200 && s < 400, headers: { 'User-Agent': 'MangaDex-Wrapper/3.1', Referer: 'https://mangadex.org/', }, }); return res.status >= 200 && res.status < 400; } catch { // some nodes reject HEAD — try range GET try { const res = await axios.get(url, { timeout: 10000, responseType: 'stream', headers: { 'User-Agent': 'MangaDex-Wrapper/3.1', Referer: 'https://mangadex.org/', Range: 'bytes=0-0', }, validateStatus: (s) => s >= 200 && s < 400, }); res.data.destroy?.(); return true; } catch { return false; } } } async _report(url, success) { try { await axios.post( REPORT, { url, success, bytes: 0, duration: 0, cached: false, }, { headers: { 'Content-Type': 'application/json' }, timeout: 5000, } ); } catch { // ignore } } async covers(mangaId, { limit = 20 } = {}) { const data = await this.req('GET', '/cover', { params: { manga: [mangaId], limit: Math.min(limit, 100), order: { volume: 'asc' }, }, }); return (data.data || []).map((e) => { const a = e.attributes || {}; return { id: e.id, volume: a.volume, fileName: a.fileName, url: `${UP}/covers/${mangaId}/${a.fileName}`, url256: `${UP}/covers/${mangaId}/${a.fileName}.256.jpg`, }; }); } async tags() { const data = await this.req('GET', '/manga/tag'); return (data.data || []).map((t) => ({ id: t.id, name: pick(t.attributes?.name), group: t.attributes?.group, })); } async stats(mangaId) { const data = await this.req('GET', '/statistics/manga', { params: { manga: [mangaId] }, }); const s = data.statistics?.[mangaId]; if (!s) return null; return { follows: s.follows, rating: s.rating?.average ?? null, bayesian: s.rating?.bayesian ?? null, comments: s.comments?.repliesCount ?? 0, }; } } export default MangaDex; // ── CLI ──────────────────────────────────────────────────────────────────── const isMain = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; if (isMain) { const args = process.argv.slice(2); const flag = (n, d = null) => { const i = args.indexOf(`--${n}`); if (i === -1) return d; const v = args[i + 1]; return !v || v.startsWith('--') ? d : v; }; const has = (n) => args.includes(`--${n}`); const help = { usage: 'node mangadex.js [args] [opts]', flow: 'home/search → detail → chapters → read', commands: { home: 'popular + latest + recent', search: 'search "title"', detail: 'manga detail by id', popular: 'popular manga', recent: 'recently added', latest: 'latest chapters (pages>0)', chapters: 'chapters of manga (pages>0)', allchapters: 'all chapters', chapter: 'one chapter meta', read: 'page image URLs (auto-retry)', covers: 'cover list', tags: 'all tags', stats: 'follows & rating', random: 'random manga', }, options: { '--limit': 'default 12-20', '--offset': 'default 0', '--lang': 'en | id | ja ...', '--quality': 'data | data-saver', '--empty': 'include pages=0 / external', '--pretty': 'pretty json', }, }; const out = (data) => { console.log( has('pretty') ? JSON.stringify(data, null, 2) : JSON.stringify(data) ); }; async function main() { if (!args.length || has('help') || args[0] === 'help') { out(help); return; } const md = new MangaDex(); const cmd = args[0]; const limit = parseInt(flag('limit', '20'), 10); const offset = parseInt(flag('offset', '0'), 10); const lang = flag('lang', 'en'); const quality = flag('quality', 'data'); const includeEmpty = has('empty'); let r; try { switch (cmd) { case 'home': r = await md.home({ limit, lang }); break; case 'popular': r = await md.popular({ limit, offset }); break; case 'recent': r = await md.recent({ limit, offset }); break; case 'latest': r = await md.latest({ limit, offset, lang }); break; case 'search': { const q = args[1]; if (!q) throw new Error('need query'); r = await md.search({ title: q, limit, offset, lang }); break; } case 'detail': case 'manga': { const id = args[1]; if (!id) throw new Error('need manga id'); r = await md.detail(id); break; } case 'random': r = await md.random(); break; case 'chapters': { const id = args[1]; if (!id) throw new Error('need manga id'); r = await md.chapters(id, { limit, offset, lang, includeEmpty, }); break; } case 'allchapters': { const id = args[1]; if (!id) throw new Error('need manga id'); r = await md.allChapters(id, { lang, includeEmpty }); break; } case 'chapter': { const id = args[1]; if (!id) throw new Error('need chapter id'); r = await md.chapter(id); break; } case 'read': case 'pages': { const id = args[1]; if (!id) throw new Error('need chapter id'); r = await md.read(id, quality); break; } case 'covers': case 'cover': { const id = args[1]; if (!id) throw new Error('need manga id'); r = await md.covers(id, { limit }); break; } case 'tags': r = await md.tags(); break; case 'stats': { const id = args[1]; if (!id) throw new Error('need manga id'); r = await md.stats(id); break; } default: throw new Error(`unknown: ${cmd}`); } out(r); } catch (err) { out({ error: true, message: err.message, status: err.status || null, }); process.exit(1); } } main(); }