Back to Home
Gistify Snippet
kyiov
1h ago·3 file(s)
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
Raw
soundcloud-scraper.jsjavascript
506 lines
1const fs = require("fs");
2const path = require("path");
3const { exec } = require("child_process");
4
5const 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";
6const API_BASE = "https://api-v2.soundcloud.com";
7const SOUNDCLOUD_BASE = "https://soundcloud.com";
8
9let cachedClientId = "tHVbSbDP4TaoMbPppngv6Tdry83vNbMQ";
10
11function 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
19async 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
57async 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
92async function resolve(scUrl) {
93 if (!scUrl) throw new Error("URL SoundCloud harus disertakan.");
94 return await apiRequest("/resolve", { url: scUrl });
95}
96
97async 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
123async 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
165async 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
217async 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
262async 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
292async 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
315async 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
347async function runCli() {
348 const args = process.argv.slice(2);
349
350 if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
351 console.log(`
352SoundCloud Scraper (Node.js)
353
354Penggunaan:
355 node scrape_soundcloud.js <url_atau_perintah> [opsi]
356
357Contoh:
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
491if (require.main === module) {
492 runCli();
493}
494
495module.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.