const fs = require("fs"); const path = require("path"); const { exec } = require("child_process"); const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; const API_BASE = "https://api-v2.soundcloud.com"; const SOUNDCLOUD_BASE = "https://soundcloud.com"; let cachedClientId = "tHVbSbDP4TaoMbPppngv6Tdry83vNbMQ"; function formatDuration(ms) { if (!ms || isNaN(ms)) return "00:00"; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } async function getClientId(forceRefresh = false) { if (cachedClientId && !forceRefresh) { return cachedClientId; } try { const htmlRes = await fetch(SOUNDCLOUD_BASE, { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(10000), }); const html = await htmlRes.text(); const scriptUrls = [...html.matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^\"]+\.js/g)].map( (m) => m[0] ); for (const scriptUrl of scriptUrls.reverse()) { try { const codeRes = await fetch(scriptUrl, { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(10000), }); const code = await codeRes.text(); const match = code.match(/client_id[:=]["\x27]([a-zA-Z0-9]{32})["\x27]/) || code.match(/client_id=([a-zA-Z0-9]{32})/); if (match && match[1]) { cachedClientId = match[1]; return cachedClientId; } } catch {} } } catch {} return cachedClientId; } async function apiRequest(endpoint, params = {}) { const clientId = await getClientId(); const url = new URL(endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`); url.searchParams.set("client_id", clientId); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { url.searchParams.set(key, String(value)); } } const res = await fetch(url.toString(), { headers: { "User-Agent": USER_AGENT, Accept: "application/json, text/javascript, */*; q=0.01", }, signal: AbortSignal.timeout(12000), }); if (!res.ok) { if (res.status === 401 || res.status === 403) { const freshClientId = await getClientId(true); url.searchParams.set("client_id", freshClientId); const retryRes = await fetch(url.toString(), { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(12000), }); if (retryRes.ok) return await retryRes.json(); } throw new Error(`SoundCloud API HTTP ${res.status}: ${res.statusText}`); } return await res.json(); } async function resolve(scUrl) { if (!scUrl) throw new Error("URL SoundCloud harus disertakan."); return await apiRequest("/resolve", { url: scUrl }); } async function getStreamUrl(trackData) { if (!trackData?.media?.transcodings || !Array.isArray(trackData.media.transcodings)) { return null; } const transcodings = trackData.media.transcodings; const target = transcodings.find((t) => t.format?.protocol === "progressive") || transcodings.find((t) => t.format?.protocol === "hls") || transcodings[0]; if (!target?.url) return null; try { const streamInfo = await apiRequest(target.url); return { protocol: target.format?.protocol || "unknown", mimeType: target.format?.mime_type || "audio/mpeg", streamUrl: streamInfo.url, quality: target.quality || "standard", }; } catch { return null; } } async function getTrack(urlOrId) { let rawData; if (typeof urlOrId === "number" || /^\d+$/.test(urlOrId)) { rawData = await apiRequest(`/tracks/${urlOrId}`); } else { rawData = await resolve(urlOrId); } if (rawData.kind !== "track") { throw new Error(`Target '${urlOrId}' bukan trek audio.`); } const stream = await getStreamUrl(rawData); return { id: rawData.id, title: rawData.title, permalinkUrl: rawData.permalink_url, description: rawData.description || "", genre: rawData.genre || "Unset", durationMs: rawData.duration, durationFormatted: formatDuration(rawData.duration), playbackCount: rawData.playback_count || 0, likesCount: rawData.likes_count || 0, repostsCount: rawData.reposts_count || 0, commentCount: rawData.comment_count || 0, createdAt: rawData.created_at, releaseDate: rawData.release_date || rawData.created_at, artworkUrl: (rawData.artwork_url || "").replace("-large", "-t500x500"), waveformUrl: rawData.waveform_url, artist: { id: rawData.user?.id, username: rawData.user?.username, permalinkUrl: rawData.user?.permalink_url, avatarUrl: rawData.user?.avatar_url, verified: Boolean(rawData.user?.verified), followersCount: rawData.user?.followers_count || 0, }, stream, }; } async function downloadTrack(urlOrId, customPath = null) { const track = await getTrack(urlOrId); if (!track.stream || !track.stream.streamUrl) { throw new Error(`Tidak ditemukan stream audio untuk trek: ${track.title}`); } const safeFilename = `${track.artist.username} - ${track.title}` .replace(/[\/\\:*?"<>|]/g, "_") .replace(/\s+/g, " ") .trim(); let targetFile = customPath; if (!targetFile) { targetFile = path.resolve(process.cwd(), `${safeFilename}.mp3`); } else if (fs.existsSync(targetFile) && fs.statSync(targetFile).isDirectory()) { targetFile = path.join(targetFile, `${safeFilename}.mp3`); } return new Promise((resolve, reject) => { const cmd = `ffmpeg -y -i "${track.stream.streamUrl}" -vn -c:a libmp3lame -b:a 192k "${targetFile}"`; exec(cmd, async (err) => { if (!err && fs.existsSync(targetFile)) { const stats = fs.statSync(targetFile); return resolve({ success: true, track: track.title, artist: track.artist.username, filePath: targetFile, sizeMb: (stats.size / 1024 / 1024).toFixed(2), }); } try { const res = await fetch(track.stream.streamUrl); if (!res.ok) throw new Error(`HTTP ${res.status}: Gagal download audio stream`); const buffer = Buffer.from(await res.arrayBuffer()); fs.writeFileSync(targetFile, buffer); const stats = fs.statSync(targetFile); resolve({ success: true, track: track.title, artist: track.artist.username, filePath: targetFile, sizeMb: (stats.size / 1024 / 1024).toFixed(2), }); } catch (fallbackErr) { reject(new Error(`Gagal mengunduh audio: ${err ? err.message : fallbackErr.message}`)); } }); }); } async function search(query, options = {}) { const { type = "tracks", limit = 10 } = options; const endpoint = type === "all" ? "/search" : `/search/${type}`; const data = await apiRequest(endpoint, { q: query, limit }); const items = data.collection || []; return items.map((item) => { if (item.kind === "track") { return { kind: "track", id: item.id, title: item.title, permalinkUrl: item.permalink_url, duration: formatDuration(item.duration), playbackCount: item.playback_count || 0, likesCount: item.likes_count || 0, artist: item.user?.username || "Unknown", artworkUrl: (item.artwork_url || "").replace("-large", "-t500x500"), }; } else if (item.kind === "user") { return { kind: "user", id: item.id, username: item.username, permalinkUrl: item.permalink_url, followersCount: item.followers_count || 0, trackCount: item.track_count || 0, verified: Boolean(item.verified), avatarUrl: item.avatar_url, }; } else if (item.kind === "playlist") { return { kind: "playlist", id: item.id, title: item.title, permalinkUrl: item.permalink_url, trackCount: item.track_count || 0, author: item.user?.username || "Unknown", artworkUrl: (item.artwork_url || "").replace("-large", "-t500x500"), }; } return item; }); } async function getUser(urlOrUsername) { let rawData; if (urlOrUsername.startsWith("http")) { rawData = await resolve(urlOrUsername); } else { rawData = await resolve(`${SOUNDCLOUD_BASE}/${urlOrUsername}`); } if (rawData.kind !== "user") { throw new Error(`Target '${urlOrUsername}' bukan profil pengguna.`); } return { id: rawData.id, username: rawData.username, fullName: rawData.full_name || "", permalinkUrl: rawData.permalink_url, city: rawData.city || "", country: rawData.country_code || "", description: rawData.description || "", avatarUrl: (rawData.avatar_url || "").replace("-large", "-t500x500"), verified: Boolean(rawData.verified), followersCount: rawData.followers_count || 0, followingCount: rawData.followings_count || 0, trackCount: rawData.track_count || 0, playlistCount: rawData.playlist_count || 0, likesCount: rawData.likes_count || 0, }; } async function getUserTracks(urlOrUsername, limit = 10) { const user = await getUser(urlOrUsername); const data = await apiRequest(`/users/${user.id}/tracks`, { limit }); const tracks = data.collection || []; return { user: { id: user.id, username: user.username, }, totalTracksFound: tracks.length, tracks: tracks.map((t) => ({ id: t.id, title: t.title, permalinkUrl: t.permalink_url, duration: formatDuration(t.duration), playbackCount: t.playback_count || 0, likesCount: t.likes_count || 0, createdAt: t.created_at, })), }; } async function getPlaylist(urlOrId) { let rawData; if (typeof urlOrId === "number" || /^\d+$/.test(urlOrId)) { rawData = await apiRequest(`/playlists/${urlOrId}`); } else { rawData = await resolve(urlOrId); } if (rawData.kind !== "playlist") { throw new Error(`Target '${urlOrId}' bukan playlist.`); } const tracks = (rawData.tracks || []).map((t) => ({ id: t.id, title: t.title, permalinkUrl: t.permalink_url, duration: formatDuration(t.duration), artist: t.user?.username || "Unknown", })); return { id: rawData.id, title: rawData.title, permalinkUrl: rawData.permalink_url, description: rawData.description || "", trackCount: rawData.track_count || tracks.length, likesCount: rawData.likes_count || 0, author: rawData.user?.username || "Unknown", tracks, }; } async function runCli() { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(` SoundCloud Scraper (Node.js) Penggunaan: node scrape_soundcloud.js [opsi] Contoh: node scrape_soundcloud.js "https://soundcloud.com/forss/flickermood" node scrape_soundcloud.js --download "https://soundcloud.com/forss/flickermood" node scrape_soundcloud.js --search "lofi chill hop" node scrape_soundcloud.js --search "alan walker" --type users node scrape_soundcloud.js --user "octobersveryown" node scrape_soundcloud.js --playlist "https://soundcloud.com/user/sets/playlist-name" node scrape_soundcloud.js "https://soundcloud.com/forss/flickermood" --json `); return; } const isJson = args.includes("--json"); const filteredArgs = args.filter((a) => a !== "--json"); try { if (filteredArgs[0] === "--download") { const trackUrl = filteredArgs[1]; if (!trackUrl) { console.error("Masukkan URL lagu yang ingin diunduh."); return; } const outIdx = filteredArgs.indexOf("--out") !== -1 ? filteredArgs.indexOf("--out") : filteredArgs.indexOf("-o"); const outPath = outIdx !== -1 && filteredArgs[outIdx + 1] ? filteredArgs[outIdx + 1] : null; console.log(`\nā³ Mengunduh audio dari SoundCloud...`); const result = await downloadTrack(trackUrl, outPath); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`\nāœ… Berhasil diunduh!`); console.log(`šŸŽµ Lagu : ${result.track}`); console.log(`šŸ‘¤ Artist: ${result.artist}`); console.log(`šŸ“¦ Ukuran: ${result.sizeMb} MB`); console.log(`šŸ“ Lokasi: ${result.filePath}\n`); } return; } if (filteredArgs[0] === "--search") { const q = filteredArgs[1] || "chill"; const typeIdx = filteredArgs.indexOf("--type"); const type = typeIdx !== -1 && filteredArgs[typeIdx + 1] ? filteredArgs[typeIdx + 1] : "tracks"; const results = await search(q, { type, limit: 8 }); if (isJson) { console.log(JSON.stringify(results, null, 2)); } else { console.log(`\nšŸ” Hasil pencarian SoundCloud untuk "${q}" [${type}]:\n`); results.forEach((item, idx) => { if (item.kind === "track") { console.log(`[${idx + 1}] šŸŽµ ${item.title} — ${item.artist} (${item.duration})`); console.log(` ā–¶ Plays: ${item.playbackCount.toLocaleString()} | ā¤ļø Likes: ${item.likesCount.toLocaleString()}`); console.log(` šŸ”— ${item.permalinkUrl}\n`); } else if (item.kind === "user") { console.log(`[${idx + 1}] šŸ‘¤ @${item.username} ${item.verified ? "āœ“" : ""}`); console.log(` šŸ‘„ Followers: ${item.followersCount.toLocaleString()} | šŸŽ¶ Tracks: ${item.trackCount}`); console.log(` šŸ”— ${item.permalinkUrl}\n`); } }); } return; } if (filteredArgs[0] === "--user") { const userParam = filteredArgs[1]; const data = await getUserTracks(userParam, 6); if (isJson) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`\nšŸ‘¤ Profil & Lagu Unggahan: @${data.user.username}\n`); data.tracks.forEach((t, i) => { console.log(`[${i + 1}] šŸŽµ ${t.title} (${t.duration}) - Plays: ${t.playbackCount.toLocaleString()}`); console.log(` šŸ”— ${t.permalinkUrl}`); }); } return; } if (filteredArgs[0] === "--playlist") { const playlistParam = filteredArgs[1]; const data = await getPlaylist(playlistParam); if (isJson) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`\nšŸ“ Playlist: ${data.title} (${data.trackCount} trek) oleh @${data.author}\n`); data.tracks.slice(0, 10).forEach((t, i) => { console.log(`[${i + 1}] šŸŽµ ${t.title} — ${t.artist} (${t.duration})`); }); } return; } const targetUrl = filteredArgs[0]; const resolved = await resolve(targetUrl); if (resolved.kind === "track") { const track = await getTrack(targetUrl); if (isJson) { console.log(JSON.stringify(track, null, 2)); } else { console.log("\n======================================================="); console.log(`šŸŽµ TRACK: ${track.title}`); console.log(`šŸ‘¤ ARTIST: ${track.artist.username} ${track.artist.verified ? "[VERIFIED]" : ""}`); console.log(`ā± DURASI: ${track.durationFormatted} (${track.durationMs} ms)`); console.log(`šŸ· GENRE: ${track.genre}`); console.log(`ā–¶ PLAYS: ${track.playbackCount.toLocaleString()} | ā¤ļø LIKES: ${track.likesCount.toLocaleString()}`); console.log(`šŸ–¼ ARTWORK: ${track.artworkUrl}`); console.log(`šŸ”— PERMALINK: ${track.permalinkUrl}`); if (track.stream) { console.log(`\nšŸŽ§ AUDIO STREAM (${track.stream.protocol.toUpperCase()}):`); console.log(` ${track.stream.streamUrl}`); } console.log("=======================================================\n"); } } else if (resolved.kind === "user") { const user = await getUser(targetUrl); if (isJson) { console.log(JSON.stringify(user, null, 2)); } else { console.log(`\nšŸ‘¤ USER: @${user.username}`); console.log(`šŸ‘„ FOLLOWERS: ${user.followersCount.toLocaleString()}`); console.log(`šŸŽ¶ TRACKS: ${user.trackCount}`); console.log(`šŸ”— ${user.permalinkUrl}\n`); } } else { console.log(JSON.stringify(resolved, null, 2)); } } catch (err) { console.error("\nāŒ Terjadi kesalahan saat scraping:", err.message); process.exit(1); } } if (require.main === module) { runCli(); } module.exports = { getClientId, resolve, getTrack, getStreamUrl, downloadTrack, search, getUser, getUserTracks, getPlaylist, };