Mcpedl
scraper + CLI untuk mcpedl.com: cari/list addon·map·texture·skin, ambil detail + link download, cek versi Minecraft. Output default JSON. lengkap check readme
4 Oktober 2026432 file
mcpedl.jsjavascript · 581 baris
#!/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 MCOpsi (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();}readme.mdmarkdown · 60 baris
Komentar
Masuk untuk ikut berkomentar.
Belum ada komentar.