LOADING
#!/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 = /<form\b[^>]*action=["']\/action\/track["'][^>]*>([\s\S]*?)<\/form>/gi;
let m;
while ((m = re.exec(html))) {
const form = {};
for (const i of m[1].matchAll(/<input\b[^>]*>/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(/<a\b([^>]*)>([\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(/<h3[^>]*>\s*<div[^>]*title="([^"]*)"/i),
artist: pick(/<p>\s*<span>([\s\S]*?)<\/span>\s*<\/p>/i),
album: null,
duration: null,
cover: pick(/aplmate-downloader-left[\s\S]*?<img[^>]*src="([^"]+)"/i),
};
}
const parseHeading = (html) =>
decodeEntities((html.match(/<h3[^>]*>\s*<div[^>]*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 <link Apple Music> [opsi]
Opsi:
-r, --resolve <n|all> Album/playlist: jumlah track yang diambil link downloadnya (default: 1)
-t, --turnstile <token> Token cf-turnstile-response (hanya jika situs memintanya)
-c, --cookie <string> Header Cookie mentah, mis. "cf_clearance=..." (env APLMATE_COOKIE)
--ua <string> User-Agent (env APLMATE_UA)
-l, --lang <kode> Bahasa situs (default: en)
-d, --delay <ms> Jeda antar track saat resolve banyak (default: 5000)
-o, --out <file> 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();Wrapper Node.js (ESM) untuk aplmate.com. Input link Apple Music, output JSON berisi metadata + URL direct download.
bashnode aplmate.js https://music.apple.com/id/song/nicole-kidman/6792884088
| Opsi | Keterangan |
|---|---|
-r, --resolve <n|all> | Album/playlist: jumlah track yang diambil link downloadnya (default 1) |
-t, --turnstile <token> | Token cf-turnstile-response, hanya jika situs memintanya |
-c, --cookie <str> / --ua <str> | Cookie cf_clearance + User-Agent browser, jika kena 403 |
-o, --out <file> | Simpan JSON ke file |
jsimport aplmate from "./aplmate.js"; const data = await aplmate("https://music.apple.com/gb/song/6769553656"); console.log(JSON.stringify(data, null, 2));
json{ "status": true, "input": "https://music.apple.com/gb/song/6769553656", "total": 1, "resolved": 1, "results": [ { "index": 1, "id": "6769553656", "title": "Great Expectation", "artist": "SIENNA SPIRO", "album": "Visitor", "duration": "2:53", "cover": "https://is1-ssl.mzstatic.com/image/thumb/.../630x630bb.jpg", "url": "https://music.apple.com/gb/song/6769553656", "download": { "mp3": "https://cdndl.aplmate.com/mp3?token=...", "cover_hd": "https://cdndl.aplmate.com/mp3?token=...", "filename": "Great Expectation - SIENNA SPIRO.mp3", "expires_at": "2026-10-17T14:58:27.000Z" } } ] }
Error: { "status": false, "error": "...", "code": "..." } (exit code 1).
TURNSTILE_REQUIRED, ambil cf-turnstile-response dari DevTools (Network → request action → Payload) dan kirim lewat --turnstile.