LOADING
scraper + CLI untuk mcpedl.com: cari/list addon·map·texture·skin, ambil detail + link download, cek versi Minecraft. Output default JSON. lengkap check readme
#!/usr/bin/env node
/**
* MCPEDL.com — Complete Node.js ESM Scraper / Wrapper + CLI
*
* Fitur:
* - Home / popular / newest listing (API)
* - Search (keyword, type, sort, page, updated filter)
* - Detail project (slug/URL) + downloads + minecraft_versions
* - Filter versi Minecraft (client-side pada detail / batch)
* - Tags / categories
* - CLI lengkap: home | search | detail | downloads | tags | version
*
* Zero dependency (Node ≥ 18). Pure ESM.
*/
import { URL } from 'node:url';
import vm from 'node:vm';
import process from 'node:process';
const WEB_BASE = 'https://mcpedl.com';
const API_BASE = 'https://api.mcpedl.com';
const DEFAULT_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'application/json, text/html, */*',
'Accept-Language': 'en-US,en;q=0.9',
};
/** type_id mapping */
export const TYPE = {
ALL: 0,
ADDON: 1,
WORLD: 2,
TEXTURE: 3,
SEED: 4,
SKIN: 5,
SKIN_PACK: 6,
SERVER: 7,
MODPE: 8,
SCRIPT: 9,
};
export const TYPE_NAME = {
0: 'All',
1: 'Add-On',
2: 'World',
3: 'Texture Pack',
4: 'Seed',
5: 'Skin',
6: 'Skin Pack',
7: 'Server',
8: 'ModPE Script',
9: 'Script',
};
/** sort options yang didukung API */
export const SORT = {
relevance: 'relevance',
popularity: 'popularity',
popularity_week: 'popularity_week',
popularity_month: 'popularity_month',
created_at: 'created_at',
updated_at: 'updated_at',
};
export class MCPEDL {
/**
* @param {Object} [opts]
* @param {number} [opts.timeout=25000]
* @param {Object} [opts.headers]
* @param {Function} [opts.fetch]
*/
constructor(opts = {}) {
this.timeout = opts.timeout ?? 25000;
this.headers = { ...DEFAULT_HEADERS, ...(opts.headers || {}) };
this.fetchFn = opts.fetch || globalThis.fetch.bind(globalThis);
}
// ─── low-level ───────────────────────────────────────────────
async _fetch(url, { json = false } = {}) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), this.timeout);
try {
const res = await this.fetchFn(url, {
headers: this.headers,
signal: controller.signal,
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} → ${url}`);
return json ? res.json() : res.text();
} finally {
clearTimeout(t);
}
}
async fetchHtml(pathOrUrl) {
const url = pathOrUrl.startsWith('http')
? pathOrUrl
: new URL(pathOrUrl, WEB_BASE).href;
return this._fetch(url);
}
async fetchApi(path, params = {}) {
const u = new URL(path, API_BASE);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v));
}
return this._fetch(u.href, { json: true });
}
// ─── Nuxt parser (untuk detail page) ─────────────────────────
parseNuxt(html) {
const match = html.match(
/window\.__NUXT__\s*=\s*(\(function[\s\S]*?\}\s*\([\s\S]*?\)\s*\);?)/
);
if (!match) {
throw new Error('window.__NUXT__ tidak ditemukan (mungkin Cloudflare challenge)');
}
const code = `"use strict"; const result = ${match[1].replace(/;?\s*$/, '')}; result;`;
const sandbox = Object.create(null);
const ctx = vm.createContext(sandbox);
return new vm.Script(code, { timeout: 8000 }).runInContext(ctx);
}
_cleanDownloads(items = [], { uniqueFile = false } = {}) {
const seen = new Set();
const out = [];
// sort so type 1 (web) comes before type 5 (vip) when uniqueFile
const list = uniqueFile
? [...items].sort((a, b) => (a.type ?? 99) - (b.type ?? 99))
: items;
for (const item of list) {
let file = item.file;
if (!file) continue;
if (typeof file === 'string' && file.includes('/leaving') && file.includes('url=')) {
try {
const u = new URL(file, WEB_BASE);
file = decodeURIComponent(u.searchParams.get('url') || file);
} catch { /* keep */ }
}
const key = uniqueFile ? file : `${file}|${item.type}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
display_name: item.display_name || item.name,
name: item.name,
file,
type: item.type,
fileDate: item.fileDate,
id: item.id,
});
}
return out;
}
normalizeProject(model) {
if (!model?.slug) throw new Error('Model tidak valid');
const downloads = this._cleanDownloads(model.downloads, { uniqueFile: true });
const downloads_vip = this._cleanDownloads(model.downloads_vip, { uniqueFile: true });
return {
id: model.id ?? model.submission_id,
slug: model.slug,
title: model.title || '',
short_description: model.short_description || model.summary || '',
description: model.description || '',
introduction: model.introduction || '',
changelog: model.changelog || '',
image: model.image || null,
thumbnails: model.thumbnails || [],
user: {
id: model.user_id,
username: model.user?.username || model.username || model.user_nicename,
display_name: model.user?.display_name || model.display_name,
avatar: model.user?.avatar || model.user_avatar,
},
average_rating: model.average_rating != null ? Math.round(Number(model.average_rating) * 100) / 100 : null,
comments_total: model.comments_total ?? 0,
download_count: model.downloadCount ?? model.download_count ?? null,
publish_date: model.publish_date || model.created_at,
update_date: model.update_date || model.updated_at,
type_id: model.type_id,
type_name: TYPE_NAME[model.type_id] || 'Unknown',
downloads,
downloads_vip,
minecraft_versions: model.minecraft_versions || [],
tags: (Array.isArray(model.tags) && model.tags.length ? model.tags : null) || model.cf_tags || [],
categories: model.categories || [],
related: model.related || [],
url: `${WEB_BASE}/${model.slug}/`,
raw: model,
};
}
normalizeListItem(item) {
return {
id: item.id,
slug: item.slug,
title: item.title,
summary: item.summary || item.introduction || '',
image: item.image,
created_at: item.created_at,
updated_at: item.updated_at,
download_count: item.downloadCount ?? item.download_count,
average_rating: item.average_rating != null ? Math.round(Number(item.average_rating) * 100) / 100 : null,
type_id: item.type_id,
type_name: TYPE_NAME[item.type_id] || 'Unknown',
author: item.display_name || item.user_nicename,
user_avatar: item.user_avatar,
tags: item.cf_tags || item.tags || [],
url: `${WEB_BASE}/${item.slug}/`,
};
}
// ─── public API ──────────────────────────────────────────────
/**
* Home / listing via API
*/
async home(opts = {}) {
return this.search('', {
sort: opts.sort || 'popularity_week',
type_id: opts.type_id ?? 0,
page: opts.page ?? 1,
page_size: opts.page_size ?? 20,
updated_at: opts.updated_at,
});
}
/**
* Search advanced (API)
*/
async search(keyword = '', opts = {}) {
const params = {
keyword: keyword || undefined,
sort: opts.sort || (keyword ? 'relevance' : 'popularity'),
type_id: opts.type_id ?? 0,
page: opts.page ?? 1,
page_size: opts.page_size ?? 20,
updated_at: opts.updated_at,
};
if (params.type_id === 0) delete params.type_id;
const data = await this.fetchApi('/api/search/advanced', params);
const items = (data.results || []).map((r) => this.normalizeListItem(r));
return {
items,
total: data.meta?.total ?? items.length,
page: data.meta?.page ?? 1,
page_size: data.meta?.page_size ?? 20,
page_count: data.meta?.page_count ?? 1,
params,
};
}
/**
* Detail project (scrape Nuxt dari halaman web)
*/
async getProject(slugOrUrl) {
let path = slugOrUrl.trim();
if (path.startsWith('http')) {
path = new URL(path).pathname.replace(/^\/|\/$/g, '');
} else {
path = path.replace(/^\/|\/$/g, '');
}
if (!path) throw new Error('Slug kosong');
const html = await this.fetchHtml(`/${path}/`);
const nuxt = this.parseNuxt(html);
const model = nuxt?.state?.slug?.model;
if (!model) {
throw new Error(`Data project tidak ditemukan untuk "${path}"`);
}
return this.normalizeProject(model);
}
/** Hanya downloads */
async getDownloads(slugOrUrl) {
const p = await this.getProject(slugOrUrl);
return { downloads: p.downloads, downloads_vip: p.downloads_vip, title: p.title, slug: p.slug };
}
/**
* Cek apakah project support versi tertentu
*/
supportsVersion(project, version) {
const versions = (project.minecraft_versions || []).map((v) =>
String(v.name || v.version || v).toLowerCase()
);
if (!versions.length) return null;
const targets = Array.isArray(version) ? version : [version];
return targets.some((t) =>
versions.some((v) => v.includes(String(t).toLowerCase()))
);
}
/**
* Batch filter list hasil search yang support versi
*/
async filterByVersion(listItems, version, { delayMs = 800, limit = 10 } = {}) {
const matched = [];
const slice = listItems.slice(0, limit);
for (const item of slice) {
try {
const detail = await this.getProject(item.slug);
if (this.supportsVersion(detail, version)) {
matched.push({ ...item, minecraft_versions: detail.minecraft_versions, detail });
}
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
} catch {
// skip
}
}
return matched;
}
/** Tags tree */
async getTags() {
return this.fetchApi('/api/search/tags');
}
static extractSlug(url) {
try {
return new URL(url).pathname.replace(/^\/|\/$/g, '').split('/')[0];
} catch {
return String(url).replace(/^\/|\/$/g, '');
}
}
}
// ─── convenience ───────────────────────────────────────────────
export async function getProject(slug, opts) {
return new MCPEDL(opts).getProject(slug);
}
export async function getDownloads(slug, opts) {
return new MCPEDL(opts).getDownloads(slug);
}
export async function search(keyword, opts) {
return new MCPEDL(opts).search(keyword, opts);
}
export async function home(opts) {
return new MCPEDL(opts).home(opts);
}
export default MCPEDL;
// ═══════════════════════════════════════════════════════════════
// CLI — default output: JSON
// ═══════════════════════════════════════════════════════════════
function printHelp() {
console.log(`
MCPEDL — scraper mcpedl.com (output default: JSON)
node mcpedl.js <cmd> [args] [opsi]
Commands:
home Listing popular
search <kata> Cari
detail <slug|url> Detail + download links
downloads <slug|url> Link download saja
tags Kategori / tags
version <slug> <ver> Cek support versi MC
Opsi (semua opsional):
-t, --type addon|world|texture|skin|script|all (default all)
-s, --sort popularity|popularity_week|relevance|created_at|updated_at
-p, --page nomor halaman (default 1)
-n, --limit max item ditampilkan
--text tampilan manusia (bukan JSON)
Contoh:
node mcpedl.js home
node mcpedl.js home -t addon -s popularity_week -n 5
node mcpedl.js search shader -t texture
node mcpedl.js detail cloud-dynamic-light-api
node mcpedl.js downloads cloud-dynamic-light-api
node mcpedl.js version cloud-dynamic-light-api 26
node mcpedl.js search light -t addon --text
`);
}
function parseArgs(argv) {
const args = { _: [], text: false };
const aliases = {
t: 'type', s: 'sort', p: 'page', n: 'limit', h: 'help',
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') { args.help = true; continue; }
if (a === '--text') { args.text = true; continue; }
if (a === '--json') { args.text = false; continue; } // legacy, ignore
if (a.startsWith('--')) {
const key = a.slice(2);
const val = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true;
args[key] = val;
continue;
}
if (a.startsWith('-') && a.length === 2) {
const key = aliases[a[1]] || a[1];
if (key === 'help') { args.help = true; continue; }
const val = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true;
args[key] = val;
continue;
}
args._.push(a);
}
return args;
}
function resolveType(t) {
if (t == null || t === '' || t === true) return 0;
if (/^\d+$/.test(t)) return Number(t);
const map = {
all: 0, addon: 1, addons: 1, 'add-on': 1,
world: 2, worlds: 2, map: 2, maps: 2,
texture: 3, textures: 3, 'texture-pack': 3, 'texture-packs': 3,
seed: 4, seeds: 4,
skin: 5, skins: 5,
'skin-pack': 6, 'skin-packs': 6,
server: 7, servers: 7,
modpe: 8,
script: 9, scripts: 9,
};
return map[String(t).toLowerCase()] ?? 0;
}
function fmtRating(r) {
if (r == null) return '-';
return Number(r).toFixed(1);
}
function fmtNum(n) {
if (n == null) return '-';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
function out(data, asText, textFn) {
if (asText && textFn) textFn(data);
else console.log(JSON.stringify(data, null, 2));
}
async function runCli() {
const args = parseArgs(process.argv.slice(2));
if (args.help || args._.length === 0) {
printHelp();
process.exit(0);
}
const cmd = args._[0];
const client = new MCPEDL();
const typeId = resolveType(args.type);
const limit = args.limit ? Number(args.limit) : null;
const asText = !!args.text;
try {
if (cmd === 'home') {
const res = await client.home({
sort: args.sort || 'popularity_week',
type_id: typeId,
page: Number(args.page || 1),
page_size: Number(args.size || args.limit || 20),
updated_at: args.updated,
});
if (limit) res.items = res.items.slice(0, limit);
out(res, asText, (r) => {
console.log(`Home | total ${r.total} | page ${r.page}/${r.page_count}\n`);
for (const it of r.items) {
console.log(`• ${it.title}`);
console.log(` ${it.type_name} ★${fmtRating(it.average_rating)} ↓${fmtNum(it.download_count)} by ${it.author}`);
console.log(` ${it.url}\n`);
}
});
} else if (cmd === 'search') {
const keyword = args._.slice(1).join(' ') || '';
let res = await client.search(keyword, {
sort: args.sort,
type_id: typeId,
page: Number(args.page || 1),
page_size: Number(args.size || 20),
updated_at: args.updated,
});
if (args.version) {
const matched = await client.filterByVersion(
res.items, args.version, { limit: limit || 10, delayMs: 600 }
);
res = { ...res, items: matched, total: matched.length };
} else if (limit) {
res.items = res.items.slice(0, limit);
}
out(res, asText, (r) => {
console.log(`Search "${keyword}" | total ${r.total} | page ${r.page}/${r.page_count}\n`);
for (const it of r.items) {
console.log(`• ${it.title}`);
console.log(` ${it.type_name} ★${fmtRating(it.average_rating)} ↓${fmtNum(it.download_count)} by ${it.author}`);
console.log(` ${it.url}\n`);
}
});
} else if (cmd === 'detail') {
const target = args._[1];
if (!target) { console.error('Usage: detail <slug|url>'); process.exit(1); }
const p = await client.getProject(target);
// jangan dump raw biar JSON rapi
const { raw, ...clean } = p;
out(clean, asText, (x) => {
console.log(`\n${x.title}`);
console.log(`${x.type_name} | ★${fmtRating(x.average_rating)} | ${x.url}`);
console.log(`by ${x.user.display_name || x.user.username}`);
if (x.minecraft_versions?.length) {
console.log('versions:', x.minecraft_versions.map(v => v.name || v.version || v).join(', '));
}
if (x.short_description) console.log('\n' + x.short_description);
console.log(`\nDownloads (${x.downloads.length}):`);
for (const d of x.downloads) console.log(` ${d.display_name}\n ${d.file}`);
console.log();
});
} else if (cmd === 'downloads') {
const target = args._[1];
if (!target) { console.error('Usage: downloads <slug|url>'); process.exit(1); }
const d = await client.getDownloads(target);
out(d, asText, (x) => {
console.log(`\n${x.title}\n`);
for (const f of x.downloads) console.log(`${f.display_name}\n ${f.file}\n`);
});
} else if (cmd === 'tags') {
const tags = await client.getTags();
out(tags, asText, (t) => {
for (const root of (t.roots || t)) {
console.log(`\n${root.name} (${root.slug})`);
for (const c of root.children || []) console.log(` - ${c.name} [${c.slug}]`);
}
console.log();
});
} else if (cmd === 'version') {
const slug = args._[1];
const ver = args._[2] || args.version;
if (!slug || !ver) { console.error('Usage: version <slug> <ver>'); process.exit(1); }
const p = await client.getProject(slug);
const ok = client.supportsVersion(p, ver);
const result = {
slug,
title: p.title,
version: ver,
supports: ok,
versions: p.minecraft_versions,
};
out(result, asText, (r) => {
console.log(`\n${r.title}`);
console.log(`Support ${r.version}? → ${r.supports === null ? 'unknown' : r.supports ? 'YES' : 'NO'}`);
if (r.versions?.length) {
console.log('Listed:', r.versions.map(v => v.name || v.version || v).join(', '));
}
console.log();
});
} else {
console.error(`Command tidak dikenal: ${cmd}`);
printHelp();
process.exit(1);
}
} catch (err) {
if (asText) console.error('Error:', err.message || err);
else console.log(JSON.stringify({ error: err.message || String(err) }));
process.exit(1);
}
}
const isMain =
typeof process !== 'undefined' &&
process.argv[1] &&
(process.argv[1].endsWith('mcpedl.js') || process.argv[1].includes('mcpedl'));
if (isMain) {
runCli();
}Scraper / wrapper + CLI untuk mcpedl.com
Output default: JSON · Node.js ≥ 18 · zero dependency · ESM
bashnode mcpedl.js <cmd> [args] [opsi]
| Command | Contoh |
|---|---|
home | node mcpedl.js home |
search <kata> | node mcpedl.js search shader |
detail <slug|url> | node mcpedl.js detail cloud-dynamic-light-api |
downloads <slug|url> | node mcpedl.js downloads cloud-dynamic-light-api |
tags | node mcpedl.js tags |
version <slug> <ver> | node mcpedl.js version cloud-dynamic-light-api 26 |
| Flag | Arti |
|---|---|
-t addon | type: addon / world / texture / skin / script / all |
-s popularity_week | sort |
-p 2 | page |
-n 5 | limit item |
--text | tampilan biasa (bukan JSON) |
bashnode mcpedl.js home node mcpedl.js home -t addon -s popularity_week -n 5 node mcpedl.js search shader -t texture node mcpedl.js detail cloud-dynamic-light-api node mcpedl.js downloads cloud-dynamic-light-api node mcpedl.js version cloud-dynamic-light-api 26 # pipe JSON node mcpedl.js search light -n 3 | jq '.items[].title'
jsimport MCPEDL, { getProject, search, home, getDownloads } from './mcpedl.js'; const list = await home({ type_id: 1, sort: 'popularity_week' }); const project = await getProject('cloud-dynamic-light-api'); const { downloads } = await getDownloads('cloud-dynamic-light-api');
addon=1 · world=2 · texture=3 · skin=5 · script=9 · all=0
MIT