LOADING
#!/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 <manga-id>
* node mangadex.js chapters <manga-id> --lang en
* node mangadex.js read <chapter-id>
*/
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 <cmd> [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();
}Wrapper Node.js (ESM) untuk API publik MangaDex. Tanpa API key.
Bisa dipakai sebagai CLI atau module.
bashnpm install axios # package.json harus ada: "type": "module"
Di situs manga biasa biasanya:
1 Judul └─ Chapter 1, 2, 3, ... (berurutan, 1 bahasa)
Di MangaDex strukturnya beda:
1 Manga (1 UUID) ├─ Chapter 1 EN Group A pages: 30 ├─ Chapter 1 EN Group B pages: 28 ← nomor sama, group beda ├─ Chapter 1 ID Group C pages: 30 ├─ Chapter 1 EN Webnovel pages: 0 ← external, tidak bisa di-read └─ Chapter 2 EN Group A pages: 25
Artinya:
| Istilah | Arti di MangaDex |
|---|---|
| manga id | ID unik 1 judul/series (UUID) |
| chapter id | ID unik 1 upload chapter (bukan nomor chapter) |
| chapter (nomor) | "1", "2", "10.5" — bisa dobel karena banyak group/bahasa |
| pages | Jumlah gambar. 0 = tidak ada file di MD (external/official) |
| lang | Bahasa terjemahan: en, id, ja, ... |
Alur yang benar:
search / home ↓ dapat manga id detail <manga-id> ↓ info judul chapters <manga-id> --lang id ↓ dapat chapter id (pilih yang pages > 0) read <chapter-id> ↓ dapat URL gambar
Jangan harap detail mengembalikan daftar chapter.
Jangan pakai nomor chapter ("1", "2") untuk read — harus UUID chapter.
bashnode mangadex.js home --limit 5 --pretty node mangadex.js search "one piece" --limit 5 --pretty node mangadex.js detail <manga-id> --pretty node mangadex.js chapters <manga-id> --lang en --limit 20 --pretty node mangadex.js read <chapter-id> --pretty
| Command | Input | Output |
|---|---|---|
home | — | popular + latest + recent |
search | "judul" | list manga |
detail | manga-id | 1 manga (judul, cover, tags, ...) |
chapters | manga-id | list chapter bisa dibaca (pages > 0) |
read | chapter-id | { count, urls: [...] } gambar |
popular | — | manga populer |
recent | — | manga baru |
latest | — | chapter terbaru (pages > 0) |
allchapters | manga-id | semua chapter (auto page) |
chapter | chapter-id | meta 1 chapter |
covers | manga-id | cover volumes |
tags | — | semua tag |
stats | manga-id | follows & rating |
random | — | manga acak |
| Flag | Default | Fungsi |
|---|---|---|
--limit <n> | 20 | jumlah item |
--offset <n> | 0 | geser halaman |
--lang <code> | en | filter bahasa (en, id, ja, ...) |
--quality <q> | data | data (asli) / data-saver (kecil) |
--empty | off | ikutkan chapter pages=0 / external |
--pretty | off | JSON rapi |
bashnode mangadex.js search "frieren" --limit 3 --pretty
Ambil "id" dari hasil, misalnya:
b0b721ff-c388-4486-aa0f-c2b0bb321512
bashnode mangadex.js detail b0b721ff-c388-4486-aa0f-c2b0bb321512 --pretty
bash# English node mangadex.js chapters b0b721ff-c388-4486-aa0f-c2b0bb321512 --lang en --limit 10 --pretty # Indonesia node mangadex.js chapters b0b721ff-c388-4486-aa0f-c2b0bb321512 --lang id --limit 10 --pretty
Hasil contoh:
json{ "data": [ { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "chapter": "1", "volume": "1", "title": null, "lang": "en", "pages": 30, "groups": ["Some Scans"] } ], "total": 100, "limit": 10, "offset": 0 }
"id" (UUID) untuk read"chapter" (nomor) untuk pemanggilan APIpages > 0 (bisa dibaca di MD)Kalau hasil "data": []:
--lang id atau bahasa lainhome → latest (biasanya masih ada pages)bashnode mangadex.js read aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --pretty node mangadex.js read aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --quality data-saver --pretty
Hasil:
json{ "chapterId": "...", "quality": "data", "count": 30, "urls": [ "https://....mangadex.network/data/.../1-xxx.png", "https://....mangadex.network/data/.../2-xxx.png" ] }
Buka URL segera — base URL MD@Home hanya valid sekitar 15 menit.
Kalau 404, jalankan read lagi (wrapper auto-ganti server).
bash# chapter dengan pages > 0 node mangadex.js read 71f5daf9-f4f1-47a2-9041-264f6e0098fa --pretty # manga + chapters-nya node mangadex.js detail 9a1c3ea1-048b-4b97-b642-2f493039e771 --pretty node mangadex.js chapters 9a1c3ea1-048b-4b97-b642-2f493039e771 --lang en --limit 10 --pretty
jsimport MangaDex from './mangadex.js'; const md = new MangaDex(); // cari const found = await md.search({ title: 'one piece', limit: 5 }); const mangaId = found.data[0].id; // detail const info = await md.detail(mangaId); // chapters 1 bahasa, hanya yang bisa dibaca const ch = await md.chapters(mangaId, { lang: 'en', limit: 50 }); // ch.data[0].id → chapter UUID // ch.data[0].chapter → nomor "1", "2", ... // semua chapter (pagination otomatis) const all = await md.allChapters(mangaId, { lang: 'id' }); // URL gambar const pages = await md.read(ch.data[0].id); // pages.urls → array string URL // ikutkan external / pages=0 const raw = await md.chapters(mangaId, { lang: 'en', includeEmpty: true, });
jsmd.home({ limit, lang }) md.search({ title, limit, offset, lang, status }) md.detail(mangaId) md.chapters(mangaId, { limit, offset, lang, includeEmpty }) md.allChapters(mangaId, { lang, includeEmpty }) md.chapter(chapterId) md.read(chapterId, quality, { retries }) // quality: 'data' | 'data-saver' md.popular({ limit }) / md.recent({ limit }) / md.latest({ limit, lang }) md.covers(mangaId) / md.tags() / md.stats(mangaId) / md.random()
Q: Kenapa chapters kosong padahal judul terkenal?
A: Sering karena official takedown. English fan translation dihapus, sisa link external (pages: 0). Coba --lang id atau judul dari latest.
Q: Kenapa 1 nomor chapter muncul berkali-kali?
A: Beda scanlation group dan/atau bahasa. Filter --lang dan di app kamu bisa group by chapter nomor, pilih group favorit.
Q: read dapat URL tapi browser 404?
A: Node MD@Home mati atau URL expired. Jalankan read ulang. Wrapper sudah auto-retry + ganti server.
Q: Bedanya detail / chapters / read?
| Input | Hasil | |
|---|---|---|
| detail | manga-id | info seri |
| chapters | manga-id | daftar episode + chapter-id |
| read | chapter-id | URL halaman gambar |
Q: Bisa tanpa filter bahasa?
A: Bisa, tapi hasil campur EN/ID/JP dan dobel nomor. Disarankan selalu set --lang.
MIT