#!/usr/bin/env node /** * aplmate.js - Wrapper/scraper aplmate.com (Apple Music -> MP3) * Node.js >= 18 (ESM, tanpa dependency) * * CLI : node aplmate.js https://music.apple.com/id/song/nicole-kidman/6792884088 * Module : import aplmate from "./aplmate.js"; console.log(await aplmate(link)); */ import { writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; const BASE = "https://aplmate.com"; const DEFAULT_UA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"; const SERVER_MESSAGES = { post_required: "Form harus disubmit.", invalid_input: "Masukkan judul/artis atau link Apple Music yang valid.", session_error: "Gagal memulai sesi download.", rate_limit: "Request terlalu cepat, tunggu 5 detik.", details_unavailable: "Detail lagu gagal dimuat.", no_results: "Lagu tidak ditemukan.", link_not_found: "Link Apple Music tidak ditemukan.", no_tracks: "Tidak ada lagu pada link ini.", invalid_track: "Data track tidak valid, cari ulang.", generic_error: "Terjadi kesalahan di server.", }; export class AplMateError extends Error { constructor(message, extra = {}) { super(message); this.name = "AplMateError"; Object.assign(this, extra); } } /* ------------------------------ helpers ------------------------------ */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const decodeEntities = (s = "") => s .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(+d)) .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); const attr = (tag, name) => { const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); return m ? m[1] ?? m[2] : null; }; const stripTags = (s = "") => decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); function jwtPayload(token) { try { const part = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); return JSON.parse(Buffer.from(part, "base64").toString("utf8")); } catch { return null; } } /* ------------------------------ parsers ------------------------------ */ /** Ambil semua form /action/track dari HTML hasil link. */ export function parseTracks(html) { const out = []; const re = /]*action=["']\/action\/track["'][^>]*>([\s\S]*?)<\/form>/gi; let m; while ((m = re.exec(html))) { const form = {}; for (const i of m[1].matchAll(/]*>/gi)) { const name = attr(i[0], "name"); if (name) form[name] = decodeEntities(attr(i[0], "value") ?? ""); } if (!form.data) continue; let meta = {}; try { meta = JSON.parse(Buffer.from(form.data, "base64").toString("utf8")); } catch {} out.push({ form, meta }); } return out; } /** Ambil link download (mp3, cover HD, lossless) dari HTML hasil /action/track. */ export function parseDownloads(html) { const d = { mp3: null, cover_hd: null, lossless_page: null, filename: null, expires_at: null, expires_in: null }; for (const m of html.matchAll(/]*)>([\s\S]*?)<\/a>/gi)) { const href = decodeEntities(attr(m[1], "href") ?? ""); const label = stripTags(m[2]); if (!href) continue; if (/^https?:\/\/cdndl\.aplmate\.com\//i.test(href)) { let claims = null; try { claims = jwtPayload(new URL(href).searchParams.get("token") ?? ""); } catch {} if (/cover/i.test(label) || claims?.cover) { d.cover_hd ??= href; } else { d.mp3 ??= href; if (claims?.filename) d.filename = `${claims.filename}.mp3`; if (claims?.exp) { d.expires_at = new Date(claims.exp * 1000).toISOString(); if (claims.iat) d.expires_in = claims.exp - claims.iat; } } } else if (/lossless/i.test(label)) { d.lossless_page ??= href; } } return d; } /** Fallback metadata kalau respons langsung berupa kartu download (tanpa form track). */ function parseCard(html) { const pick = (re) => decodeEntities((html.match(re) || [])[1] ?? "") || null; return { id: null, title: pick(/]*>\s*]*title="([^"]*)"/i), artist: pick(/

\s*([\s\S]*?)<\/span>\s*<\/p>/i), album: null, duration: null, cover: pick(/aplmate-downloader-left[\s\S]*?]*src="([^"]+)"/i), }; } const parseHeading = (html) => decodeEntities((html.match(/]*>\s*]*title="([^"]*)"/i) || [])[1] ?? "") || null; /* ------------------------------ client ------------------------------ */ export class AplMate { /** * @param {object} o * @param {string} [o.lang="en"] * @param {string} [o.turnstile] token cf-turnstile-response (default: env APLMATE_TURNSTILE) * @param {string} [o.cookie] header Cookie mentah (mis. "cf_clearance=...") * @param {string} [o.userAgent] samakan dengan UA browser kalau pakai cf_clearance * @param {number} [o.delay=5000] jeda antar request /action/track (ms) * @param {number} [o.timeout=30000] */ constructor(o = {}) { this.lang = o.lang ?? "en"; this.turnstile = o.turnstile ?? process.env.APLMATE_TURNSTILE ?? null; this.cookie = o.cookie ?? process.env.APLMATE_COOKIE ?? null; this.userAgent = o.userAgent ?? process.env.APLMATE_UA ?? DEFAULT_UA; this.delay = o.delay ?? 5000; this.timeout = o.timeout ?? 30000; this.jar = new Map(); } #cookieHeader() { const parts = [...this.jar].map(([k, v]) => `${k}=${v}`); if (this.cookie) parts.unshift(this.cookie); return parts.join("; "); } #storeCookies(res) { for (const c of res.headers.getSetCookie?.() ?? []) { const [kv] = c.split(";"); const i = kv.indexOf("="); if (i > 0) this.jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim()); } } async #post(path, body, extra = {}) { const headers = { "user-agent": this.userAgent, accept: "*/*", "accept-language": "en-US,en;q=0.9", origin: BASE, referer: `${BASE}/`, ...extra, }; const ck = this.#cookieHeader(); if (ck) headers.cookie = ck; const res = await fetch(BASE + path, { method: "POST", headers, body, signal: AbortSignal.timeout(this.timeout), }); this.#storeCookies(res); return { res, text: await res.text() }; } #json({ res, text }) { try { return JSON.parse(text); } catch { const blocked = res.status === 403 || /just a moment|cloudflare/i.test(text); throw new AplMateError( blocked ? `Diblokir Cloudflare (HTTP ${res.status}). Pakai --cookie (cf_clearance) dan --ua dari browser yang sama.` : `Respons bukan JSON (HTTP ${res.status}).`, { code: blocked ? "CLOUDFLARE_BLOCKED" : "BAD_RESPONSE", status: res.status } ); } } #fail(j) { const raw = j.error ?? j.message ?? j.msg ?? j.code ?? ""; const key = typeof raw === "string" ? raw : ""; const msg = SERVER_MESSAGES[key] ?? (key ? stripTags(key) : "Server menolak request."); return new AplMateError(msg, { code: key || "SERVER_ERROR", raw: j }); } /** Minta token sesi dari endpoint userverify milik situs (dipakai browser untuk link). */ async verify(url) { try { const r = await this.#post( "/action/userverify", new URLSearchParams({ url, lang: this.lang }), { "x-requested-with": "XMLHttpRequest" } ); return r.text.match(/JWT\.[A-Za-z0-9_.-]+/)?.[0] ?? null; } catch { return null; } } /** Link -> HTML daftar track (atau kartu download langsung). */ async lookup(input) { const token = (await this.verify(input)) ?? this.turnstile; if (!token) { throw new AplMateError( "Token Turnstile dibutuhkan. Ambil dari browser lalu kirim lewat --turnstile atau env APLMATE_TURNSTILE (lihat README).", { code: "TURNSTILE_REQUIRED" } ); } for (let attempt = 0; attempt < 2; attempt++) { const fd = new FormData(); fd.append("lang", this.lang); fd.append("url", input); fd.append("cf-turnstile-response", token); const j = this.#json(await this.#post("/action", fd)); if (j.success && typeof j.html === "string") return { html: j.html }; const err = this.#fail(j); if (attempt === 0 && /rate_limit|too fast/i.test(`${err.code} ${err.message}`)) { await sleep(5000); continue; } throw err; } } /** Satu track (form hidden hasil parseTracks) -> link download. */ async resolve(form) { for (let attempt = 0; attempt < 2; attempt++) { const fd = new FormData(); fd.append("lang", this.lang); for (const k of ["data", "base", "token"]) fd.append(k, form[k] ?? ""); const j = this.#json(await this.#post("/action/track", fd)); const html = typeof j.html === "string" ? j.html : typeof j.data === "string" ? j.data : ""; if (j.success !== false && html) { const dl = parseDownloads(html); if (dl.mp3 || dl.cover_hd) return dl; throw new AplMateError("Link download tidak ditemukan pada respons.", { code: "NO_LINK", raw: j }); } const err = this.#fail(j); if (attempt === 0 && /rate_limit|too fast/i.test(`${err.code} ${err.message}`)) { await sleep(5000); continue; } throw err; } } } /* ------------------------------ main API ------------------------------ */ /** * @param {string} input link Apple Music (lagu / album / playlist) * @param {object} [opts] { resolve: 1 | "all", ...opsi AplMate } */ export async function aplmate(input, opts = {}) { input = String(input ?? "").trim(); if (!/^https?:\/\/(?:embed\.)?music\.apple\.com\//i.test(input)) { throw new AplMateError("Input harus berupa link Apple Music (https://music.apple.com/...).", { code: "INVALID_LINK" }); } const client = new AplMate(opts); const { html } = await client.lookup(input); const parsed = parseTracks(html); const heading = parseHeading(html); let results; let resolved = 0; if (parsed.length) { results = parsed.map((t, i) => ({ index: i + 1, id: t.meta.id ?? null, title: t.meta.name ?? null, artist: t.meta.artist ?? null, album: t.meta.album ?? null, duration: t.meta.duration ?? null, cover: t.meta.cover ?? null, url: t.meta.surl ?? t.form.base ?? null, download: null, })); const want = opts.resolve === "all" ? results.length : Math.max(0, Math.min(Number(opts.resolve ?? 1) || 0, results.length)); for (let i = 0; i < want; i++) { if (i > 0) await sleep(client.delay); opts.onProgress?.(`[${i + 1}/${want}] ${results[i].title} - ${results[i].artist}`); try { results[i].download = await client.resolve(parsed[i].form); resolved++; } catch (e) { results[i].error = e.message; } } } else { // Respons langsung berupa kartu download const download = parseDownloads(html); if (!download.mp3 && !download.cover_hd) { throw new AplMateError("Tidak ada hasil untuk input tersebut.", { code: "NO_RESULTS" }); } results = [{ index: 1, ...parseCard(html), url: input, download }]; resolved = 1; } return { status: true, input, heading, total: results.length, resolved, results, }; } export default aplmate; /* ------------------------------ CLI ------------------------------ */ const HELP = `aplmate.js - Apple Music downloader wrapper (aplmate.com) Pemakaian: node aplmate.js [opsi] Opsi: -r, --resolve Album/playlist: jumlah track yang diambil link downloadnya (default: 1) -t, --turnstile Token cf-turnstile-response (hanya jika situs memintanya) -c, --cookie Header Cookie mentah, mis. "cf_clearance=..." (env APLMATE_COOKIE) --ua User-Agent (env APLMATE_UA) -l, --lang Bahasa situs (default: en) -d, --delay Jeda antar track saat resolve banyak (default: 5000) -o, --out Simpan JSON ke file -h, --help Bantuan `; function parseArgs(argv) { const o = { _: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const next = () => argv[++i]; if (a === "-r" || a === "--resolve") o.resolve = next(); else if (a === "-t" || a === "--turnstile") o.turnstile = next(); else if (a === "-c" || a === "--cookie") o.cookie = next(); else if (a === "--ua") o.userAgent = next(); else if (a === "-l" || a === "--lang") o.lang = next(); else if (a === "-d" || a === "--delay") o.delay = Number(next()); else if (a === "-o" || a === "--out") o.out = next(); else if (a === "-h" || a === "--help") o.help = true; else o._.push(a); } return o; } async function cli() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args._.length) { console.log(HELP); return; } const { out, _, help, ...opts } = args; opts.onProgress = (msg) => console.error(msg); try { const result = await aplmate(_.join(" "), opts); const json = JSON.stringify(result, null, 2); if (out) await writeFile(out, json + "\n"); console.log(json); } catch (e) { console.log(JSON.stringify({ status: false, error: e.message, code: e.code ?? "ERROR" }, null, 2)); process.exitCode = 1; } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) cli();