Back to Home
Gistify Snippet
Public
SoundCloud Scraper & Audio Stream Downloader
Node.js scraper and audio downloader for SoundCloud with dynamic client token discovery. Extracts track details, stream links, and downloads MP3 files.
#scraper#downloader#tools#javascript#api
6
Rawsoundcloud-scraper.jsjavascript
506 lines| 1 | const fs = require("fs"); |
| 2 | const path = require("path"); |
| 3 | const { exec } = require("child_process"); |
| 4 | |
| 5 | 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"; |
| 6 | const API_BASE = "https://api-v2.soundcloud.com"; |
| 7 | const SOUNDCLOUD_BASE = "https://soundcloud.com"; |
| 8 | |
| 9 | let cachedClientId = "tHVbSbDP4TaoMbPppngv6Tdry83vNbMQ"; |
| 10 | |
| 11 | function formatDuration(ms) { |
| 12 | if (!ms || isNaN(ms)) return "00:00"; |
| 13 | const totalSeconds = Math.floor(ms / 1000); |
| 14 | const minutes = Math.floor(totalSeconds / 60); |
| 15 | const seconds = totalSeconds % 60; |
| 16 | return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; |
| 17 | } |
| 18 | |
| 19 | async function getClientId(forceRefresh = false) { |
| 20 | if (cachedClientId && !forceRefresh) { |
| 21 | return cachedClientId; |
| 22 | } |
| 23 | |
| 24 | try { |
| 25 | const htmlRes = await fetch(SOUNDCLOUD_BASE, { |
| 26 | headers: { "User-Agent": USER_AGENT }, |
| 27 | signal: AbortSignal.timeout(10000), |
| 28 | }); |
| 29 | const html = await htmlRes.text(); |
| 30 | |
| 31 | const scriptUrls = [...html.matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^\"]+\.js/g)].map( |
| 32 | (m) => m[0] |
| 33 | ); |
| 34 | |
| 35 | for (const scriptUrl of scriptUrls.reverse()) { |
| 36 | try { |
| 37 | const codeRes = await fetch(scriptUrl, { |
| 38 | headers: { "User-Agent": USER_AGENT }, |
| 39 | signal: AbortSignal.timeout(10000), |
| 40 | }); |
| 41 | const code = await codeRes.text(); |
| 42 | const match = |
| 43 | code.match(/client_id[:=]["\x27]([a-zA-Z0-9]{32})["\x27]/) || |
| 44 | code.match(/client_id=([a-zA-Z0-9]{32})/); |
| 45 | |
| 46 | if (match && match[1]) { |
| 47 | cachedClientId = match[1]; |
| 48 | return cachedClientId; |
| 49 | } |
| 50 | } catch {} |
| 51 | } |
| 52 | } catch {} |
| 53 | |
| 54 | return cachedClientId; |
| 55 | } |
| 56 | |
| 57 | async function apiRequest(endpoint, params = {}) { |
| 58 | const clientId = await getClientId(); |
| 59 | const url = new URL(endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`); |
| 60 | |
| 61 | url.searchParams.set("client_id", clientId); |
| 62 | for (const [key, value] of Object.entries(params)) { |
| 63 | if (value !== undefined && value !== null) { |
| 64 | url.searchParams.set(key, String(value)); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | const res = await fetch(url.toString(), { |
| 69 | headers: { |
| 70 | "User-Agent": USER_AGENT, |
| 71 | Accept: "application/json, text/javascript, */*; q=0.01", |
| 72 | }, |
| 73 | signal: AbortSignal.timeout(12000), |
| 74 | }); |
| 75 | |
| 76 | if (!res.ok) { |
| 77 | if (res.status === 401 || res.status === 403) { |
| 78 | const freshClientId = await getClientId(true); |
| 79 | url.searchParams.set("client_id", freshClientId); |
| 80 | const retryRes = await fetch(url.toString(), { |
| 81 | headers: { "User-Agent": USER_AGENT }, |
| 82 | signal: AbortSignal.timeout(12000), |
| 83 | }); |
| 84 | if (retryRes.ok) return await retryRes.json(); |
| 85 | } |
| 86 | throw new Error(`SoundCloud API HTTP ${res.status}: ${res.statusText}`); |
| 87 | } |
| 88 | |
| 89 | return await res.json(); |
| 90 | } |
| 91 | |
| 92 | async function resolve(scUrl) { |
| 93 | if (!scUrl) throw new Error("URL SoundCloud harus disertakan."); |
| 94 | return await apiRequest("/resolve", { url: scUrl }); |
| 95 | } |
| 96 | |
| 97 | async function getStreamUrl(trackData) { |
| 98 | if (!trackData?.media?.transcodings || !Array.isArray(trackData.media.transcodings)) { |
| 99 | return null; |
| 100 | } |
| 101 | |
| 102 | const transcodings = trackData.media.transcodings; |
| 103 | const target = |
| 104 | transcodings.find((t) => t.format?.protocol === "progressive") || |
| 105 | transcodings.find((t) => t.format?.protocol === "hls") || |
| 106 | transcodings[0]; |
| 107 | |
| 108 | if (!target?.url) return null; |
| 109 | |
| 110 | try { |
| 111 | const streamInfo = await apiRequest(target.url); |
| 112 | return { |
| 113 | protocol: target.format?.protocol || "unknown", |
| 114 | mimeType: target.format?.mime_type || "audio/mpeg", |
| 115 | streamUrl: streamInfo.url, |
| 116 | quality: target.quality || "standard", |
| 117 | }; |
| 118 | } catch { |
| 119 | return null; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | async function getTrack(urlOrId) { |
| 124 | let rawData; |
| 125 | if (typeof urlOrId === "number" || /^\d+$/.test(urlOrId)) { |
| 126 | rawData = await apiRequest(`/tracks/${urlOrId}`); |
| 127 | } else { |
| 128 | rawData = await resolve(urlOrId); |
| 129 | } |
| 130 | |
| 131 | if (rawData.kind !== "track") { |
| 132 | throw new Error(`Target '${urlOrId}' bukan trek audio.`); |
| 133 | } |
| 134 | |
| 135 | const stream = await getStreamUrl(rawData); |
| 136 | |
| 137 | return { |
| 138 | id: rawData.id, |
| 139 | title: rawData.title, |
| 140 | permalinkUrl: rawData.permalink_url, |
| 141 | description: rawData.description || "", |
| 142 | genre: rawData.genre || "Unset", |
| 143 | durationMs: rawData.duration, |
| 144 | durationFormatted: formatDuration(rawData.duration), |
| 145 | playbackCount: rawData.playback_count || 0, |
| 146 | likesCount: rawData.likes_count || 0, |
| 147 | repostsCount: rawData.reposts_count || 0, |
| 148 | commentCount: rawData.comment_count || 0, |
| 149 | createdAt: rawData.created_at, |
| 150 | releaseDate: rawData.release_date || rawData.created_at, |
| 151 | artworkUrl: (rawData.artwork_url || "").replace("-large", "-t500x500"), |
| 152 | waveformUrl: rawData.waveform_url, |
| 153 | artist: { |
| 154 | id: rawData.user?.id, |
| 155 | username: rawData.user?.username, |
| 156 | permalinkUrl: rawData.user?.permalink_url, |
| 157 | avatarUrl: rawData.user?.avatar_url, |
| 158 | verified: Boolean(rawData.user?.verified), |
| 159 | followersCount: rawData.user?.followers_count || 0, |
| 160 | }, |
| 161 | stream, |
| 162 | }; |
| 163 | } |
| 164 | |
| 165 | async function downloadTrack(urlOrId, customPath = null) { |
| 166 | const track = await getTrack(urlOrId); |
| 167 | if (!track.stream || !track.stream.streamUrl) { |
| 168 | throw new Error(`Tidak ditemukan stream audio untuk trek: ${track.title}`); |
| 169 | } |
| 170 | |
| 171 | const safeFilename = `${track.artist.username} - ${track.title}` |
| 172 | .replace(/[\/\\:*?"<>|]/g, "_") |
| 173 | .replace(/\s+/g, " ") |
| 174 | .trim(); |
| 175 | |
| 176 | let targetFile = customPath; |
| 177 | if (!targetFile) { |
| 178 | targetFile = path.resolve(process.cwd(), `${safeFilename}.mp3`); |
| 179 | } else if (fs.existsSync(targetFile) && fs.statSync(targetFile).isDirectory()) { |
| 180 | targetFile = path.join(targetFile, `${safeFilename}.mp3`); |
| 181 | } |
| 182 | |
| 183 | return new Promise((resolve, reject) => { |
| 184 | const cmd = `ffmpeg -y -i "${track.stream.streamUrl}" -vn -c:a libmp3lame -b:a 192k "${targetFile}"`; |
| 185 | exec(cmd, async (err) => { |
| 186 | if (!err && fs.existsSync(targetFile)) { |
| 187 | const stats = fs.statSync(targetFile); |
| 188 | return resolve({ |
| 189 | success: true, |
| 190 | track: track.title, |
| 191 | artist: track.artist.username, |
| 192 | filePath: targetFile, |
| 193 | sizeMb: (stats.size / 1024 / 1024).toFixed(2), |
| 194 | }); |
| 195 | } |
| 196 | |
| 197 | try { |
| 198 | const res = await fetch(track.stream.streamUrl); |
| 199 | if (!res.ok) throw new Error(`HTTP ${res.status}: Gagal download audio stream`); |
| 200 | const buffer = Buffer.from(await res.arrayBuffer()); |
| 201 | fs.writeFileSync(targetFile, buffer); |
| 202 | const stats = fs.statSync(targetFile); |
| 203 | resolve({ |
| 204 | success: true, |
| 205 | track: track.title, |
| 206 | artist: track.artist.username, |
| 207 | filePath: targetFile, |
| 208 | sizeMb: (stats.size / 1024 / 1024).toFixed(2), |
| 209 | }); |
| 210 | } catch (fallbackErr) { |
| 211 | reject(new Error(`Gagal mengunduh audio: ${err ? err.message : fallbackErr.message}`)); |
| 212 | } |
| 213 | }); |
| 214 | }); |
| 215 | } |
| 216 | |
| 217 | async function search(query, options = {}) { |
| 218 | const { type = "tracks", limit = 10 } = options; |
| 219 | const endpoint = type === "all" ? "/search" : `/search/${type}`; |
| 220 | const data = await apiRequest(endpoint, { q: query, limit }); |
| 221 | const items = data.collection || []; |
| 222 | |
| 223 | return items.map((item) => { |
| 224 | if (item.kind === "track") { |
| 225 | return { |
| 226 | kind: "track", |
| 227 | id: item.id, |
| 228 | title: item.title, |
| 229 | permalinkUrl: item.permalink_url, |
| 230 | duration: formatDuration(item.duration), |
| 231 | playbackCount: item.playback_count || 0, |
| 232 | likesCount: item.likes_count || 0, |
| 233 | artist: item.user?.username || "Unknown", |
| 234 | artworkUrl: (item.artwork_url || "").replace("-large", "-t500x500"), |
| 235 | }; |
| 236 | } else if (item.kind === "user") { |
| 237 | return { |
| 238 | kind: "user", |
| 239 | id: item.id, |
| 240 | username: item.username, |
| 241 | permalinkUrl: item.permalink_url, |
| 242 | followersCount: item.followers_count || 0, |
| 243 | trackCount: item.track_count || 0, |
| 244 | verified: Boolean(item.verified), |
| 245 | avatarUrl: item.avatar_url, |
| 246 | }; |
| 247 | } else if (item.kind === "playlist") { |
| 248 | return { |
| 249 | kind: "playlist", |
| 250 | id: item.id, |
| 251 | title: item.title, |
| 252 | permalinkUrl: item.permalink_url, |
| 253 | trackCount: item.track_count || 0, |
| 254 | author: item.user?.username || "Unknown", |
| 255 | artworkUrl: (item.artwork_url || "").replace("-large", "-t500x500"), |
| 256 | }; |
| 257 | } |
| 258 | return item; |
| 259 | }); |
| 260 | } |
| 261 | |
| 262 | async function getUser(urlOrUsername) { |
| 263 | let rawData; |
| 264 | if (urlOrUsername.startsWith("http")) { |
| 265 | rawData = await resolve(urlOrUsername); |
| 266 | } else { |
| 267 | rawData = await resolve(`${SOUNDCLOUD_BASE}/${urlOrUsername}`); |
| 268 | } |
| 269 | |
| 270 | if (rawData.kind !== "user") { |
| 271 | throw new Error(`Target '${urlOrUsername}' bukan profil pengguna.`); |
| 272 | } |
| 273 | |
| 274 | return { |
| 275 | id: rawData.id, |
| 276 | username: rawData.username, |
| 277 | fullName: rawData.full_name || "", |
| 278 | permalinkUrl: rawData.permalink_url, |
| 279 | city: rawData.city || "", |
| 280 | country: rawData.country_code || "", |
| 281 | description: rawData.description || "", |
| 282 | avatarUrl: (rawData.avatar_url || "").replace("-large", "-t500x500"), |
| 283 | verified: Boolean(rawData.verified), |
| 284 | followersCount: rawData.followers_count || 0, |
| 285 | followingCount: rawData.followings_count || 0, |
| 286 | trackCount: rawData.track_count || 0, |
| 287 | playlistCount: rawData.playlist_count || 0, |
| 288 | likesCount: rawData.likes_count || 0, |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | async function getUserTracks(urlOrUsername, limit = 10) { |
| 293 | const user = await getUser(urlOrUsername); |
| 294 | const data = await apiRequest(`/users/${user.id}/tracks`, { limit }); |
| 295 | const tracks = data.collection || []; |
| 296 | |
| 297 | return { |
| 298 | user: { |
| 299 | id: user.id, |
| 300 | username: user.username, |
| 301 | }, |
| 302 | totalTracksFound: tracks.length, |
| 303 | tracks: tracks.map((t) => ({ |
| 304 | id: t.id, |
| 305 | title: t.title, |
| 306 | permalinkUrl: t.permalink_url, |
| 307 | duration: formatDuration(t.duration), |
| 308 | playbackCount: t.playback_count || 0, |
| 309 | likesCount: t.likes_count || 0, |
| 310 | createdAt: t.created_at, |
| 311 | })), |
| 312 | }; |
| 313 | } |
| 314 | |
| 315 | async function getPlaylist(urlOrId) { |
| 316 | let rawData; |
| 317 | if (typeof urlOrId === "number" || /^\d+$/.test(urlOrId)) { |
| 318 | rawData = await apiRequest(`/playlists/${urlOrId}`); |
| 319 | } else { |
| 320 | rawData = await resolve(urlOrId); |
| 321 | } |
| 322 | |
| 323 | if (rawData.kind !== "playlist") { |
| 324 | throw new Error(`Target '${urlOrId}' bukan playlist.`); |
| 325 | } |
| 326 | |
| 327 | const tracks = (rawData.tracks || []).map((t) => ({ |
| 328 | id: t.id, |
| 329 | title: t.title, |
| 330 | permalinkUrl: t.permalink_url, |
| 331 | duration: formatDuration(t.duration), |
| 332 | artist: t.user?.username || "Unknown", |
| 333 | })); |
| 334 | |
| 335 | return { |
| 336 | id: rawData.id, |
| 337 | title: rawData.title, |
| 338 | permalinkUrl: rawData.permalink_url, |
| 339 | description: rawData.description || "", |
| 340 | trackCount: rawData.track_count || tracks.length, |
| 341 | likesCount: rawData.likes_count || 0, |
| 342 | author: rawData.user?.username || "Unknown", |
| 343 | tracks, |
| 344 | }; |
| 345 | } |
| 346 | |
| 347 | async function runCli() { |
| 348 | const args = process.argv.slice(2); |
| 349 | |
| 350 | if (args.length === 0 || args.includes("--help") || args.includes("-h")) { |
| 351 | console.log(` |
| 352 | SoundCloud Scraper (Node.js) |
| 353 | |
| 354 | Penggunaan: |
| 355 | node scrape_soundcloud.js <url_atau_perintah> [opsi] |
| 356 | |
| 357 | Contoh: |
| 358 | node scrape_soundcloud.js "https://soundcloud.com/forss/flickermood" |
| 359 | node scrape_soundcloud.js --download "https://soundcloud.com/forss/flickermood" |
| 360 | node scrape_soundcloud.js --search "lofi chill hop" |
| 361 | node scrape_soundcloud.js --search "alan walker" --type users |
| 362 | node scrape_soundcloud.js --user "octobersveryown" |
| 363 | node scrape_soundcloud.js --playlist "https://soundcloud.com/user/sets/playlist-name" |
| 364 | node scrape_soundcloud.js "https://soundcloud.com/forss/flickermood" --json |
| 365 | `); |
| 366 | return; |
| 367 | } |
| 368 | |
| 369 | const isJson = args.includes("--json"); |
| 370 | const filteredArgs = args.filter((a) => a !== "--json"); |
| 371 | |
| 372 | try { |
| 373 | if (filteredArgs[0] === "--download") { |
| 374 | const trackUrl = filteredArgs[1]; |
| 375 | if (!trackUrl) { |
| 376 | console.error("Masukkan URL lagu yang ingin diunduh."); |
| 377 | return; |
| 378 | } |
| 379 | const outIdx = filteredArgs.indexOf("--out") !== -1 ? filteredArgs.indexOf("--out") : filteredArgs.indexOf("-o"); |
| 380 | const outPath = outIdx !== -1 && filteredArgs[outIdx + 1] ? filteredArgs[outIdx + 1] : null; |
| 381 | |
| 382 | console.log(`\n⏳ Mengunduh audio dari SoundCloud...`); |
| 383 | const result = await downloadTrack(trackUrl, outPath); |
| 384 | if (isJson) { |
| 385 | console.log(JSON.stringify(result, null, 2)); |
| 386 | } else { |
| 387 | console.log(`\n✅ Berhasil diunduh!`); |
| 388 | console.log(`🎵 Lagu : ${result.track}`); |
| 389 | console.log(`👤 Artist: ${result.artist}`); |
| 390 | console.log(`📦 Ukuran: ${result.sizeMb} MB`); |
| 391 | console.log(`📁 Lokasi: ${result.filePath}\n`); |
| 392 | } |
| 393 | return; |
| 394 | } |
| 395 | |
| 396 | if (filteredArgs[0] === "--search") { |
| 397 | const q = filteredArgs[1] || "chill"; |
| 398 | const typeIdx = filteredArgs.indexOf("--type"); |
| 399 | const type = typeIdx !== -1 && filteredArgs[typeIdx + 1] ? filteredArgs[typeIdx + 1] : "tracks"; |
| 400 | const results = await search(q, { type, limit: 8 }); |
| 401 | |
| 402 | if (isJson) { |
| 403 | console.log(JSON.stringify(results, null, 2)); |
| 404 | } else { |
| 405 | console.log(`\n🔍 Hasil pencarian SoundCloud untuk "${q}" [${type}]:\n`); |
| 406 | results.forEach((item, idx) => { |
| 407 | if (item.kind === "track") { |
| 408 | console.log(`[${idx + 1}] 🎵 ${item.title} — ${item.artist} (${item.duration})`); |
| 409 | console.log(` ▶ Plays: ${item.playbackCount.toLocaleString()} | ❤️ Likes: ${item.likesCount.toLocaleString()}`); |
| 410 | console.log(` 🔗 ${item.permalinkUrl}\n`); |
| 411 | } else if (item.kind === "user") { |
| 412 | console.log(`[${idx + 1}] 👤 @${item.username} ${item.verified ? "✓" : ""}`); |
| 413 | console.log(` 👥 Followers: ${item.followersCount.toLocaleString()} | 🎶 Tracks: ${item.trackCount}`); |
| 414 | console.log(` 🔗 ${item.permalinkUrl}\n`); |
| 415 | } |
| 416 | }); |
| 417 | } |
| 418 | return; |
| 419 | } |
| 420 | |
| 421 | if (filteredArgs[0] === "--user") { |
| 422 | const userParam = filteredArgs[1]; |
| 423 | const data = await getUserTracks(userParam, 6); |
| 424 | if (isJson) { |
| 425 | console.log(JSON.stringify(data, null, 2)); |
| 426 | } else { |
| 427 | console.log(`\n👤 Profil & Lagu Unggahan: @${data.user.username}\n`); |
| 428 | data.tracks.forEach((t, i) => { |
| 429 | console.log(`[${i + 1}] 🎵 ${t.title} (${t.duration}) - Plays: ${t.playbackCount.toLocaleString()}`); |
| 430 | console.log(` 🔗 ${t.permalinkUrl}`); |
| 431 | }); |
| 432 | } |
| 433 | return; |
| 434 | } |
| 435 | |
| 436 | if (filteredArgs[0] === "--playlist") { |
| 437 | const playlistParam = filteredArgs[1]; |
| 438 | const data = await getPlaylist(playlistParam); |
| 439 | if (isJson) { |
| 440 | console.log(JSON.stringify(data, null, 2)); |
| 441 | } else { |
| 442 | console.log(`\n📁 Playlist: ${data.title} (${data.trackCount} trek) oleh @${data.author}\n`); |
| 443 | data.tracks.slice(0, 10).forEach((t, i) => { |
| 444 | console.log(`[${i + 1}] 🎵 ${t.title} — ${t.artist} (${t.duration})`); |
| 445 | }); |
| 446 | } |
| 447 | return; |
| 448 | } |
| 449 | |
| 450 | const targetUrl = filteredArgs[0]; |
| 451 | const resolved = await resolve(targetUrl); |
| 452 | |
| 453 | if (resolved.kind === "track") { |
| 454 | const track = await getTrack(targetUrl); |
| 455 | if (isJson) { |
| 456 | console.log(JSON.stringify(track, null, 2)); |
| 457 | } else { |
| 458 | console.log("\n======================================================="); |
| 459 | console.log(`🎵 TRACK: ${track.title}`); |
| 460 | console.log(`👤 ARTIST: ${track.artist.username} ${track.artist.verified ? "[VERIFIED]" : ""}`); |
| 461 | console.log(`⏱ DURASI: ${track.durationFormatted} (${track.durationMs} ms)`); |
| 462 | console.log(`🏷 GENRE: ${track.genre}`); |
| 463 | console.log(`▶ PLAYS: ${track.playbackCount.toLocaleString()} | ❤️ LIKES: ${track.likesCount.toLocaleString()}`); |
| 464 | console.log(`🖼 ARTWORK: ${track.artworkUrl}`); |
| 465 | console.log(`🔗 PERMALINK: ${track.permalinkUrl}`); |
| 466 | if (track.stream) { |
| 467 | console.log(`\n🎧 AUDIO STREAM (${track.stream.protocol.toUpperCase()}):`); |
| 468 | console.log(` ${track.stream.streamUrl}`); |
| 469 | } |
| 470 | console.log("=======================================================\n"); |
| 471 | } |
| 472 | } else if (resolved.kind === "user") { |
| 473 | const user = await getUser(targetUrl); |
| 474 | if (isJson) { |
| 475 | console.log(JSON.stringify(user, null, 2)); |
| 476 | } else { |
| 477 | console.log(`\n👤 USER: @${user.username}`); |
| 478 | console.log(`👥 FOLLOWERS: ${user.followersCount.toLocaleString()}`); |
| 479 | console.log(`🎶 TRACKS: ${user.trackCount}`); |
| 480 | console.log(`🔗 ${user.permalinkUrl}\n`); |
| 481 | } |
| 482 | } else { |
| 483 | console.log(JSON.stringify(resolved, null, 2)); |
| 484 | } |
| 485 | } catch (err) { |
| 486 | console.error("\n❌ Terjadi kesalahan saat scraping:", err.message); |
| 487 | process.exit(1); |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | if (require.main === module) { |
| 492 | runCli(); |
| 493 | } |
| 494 | |
| 495 | module.exports = { |
| 496 | getClientId, |
| 497 | resolve, |
| 498 | getTrack, |
| 499 | getStreamUrl, |
| 500 | downloadTrack, |
| 501 | search, |
| 502 | getUser, |
| 503 | getUserTracks, |
| 504 | getPlaylist, |
| 505 | }; |
| 506 | |
Discussion & Comments0
No comments yet — be the first to leave feedback or start a code discussion.
Sign in with your GitHub account to write comments and join the discussion.