suno-cli
Version:
CLI tool for downloading content from Suno
171 lines (170 loc) • 6.46 kB
JavaScript
import fs from "fs";
import path from "path";
import axios from "axios";
import * as cheerio from "cheerio";
// Function to sanitize filename
const sanitizeFilename = (name) => {
return name
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid characters
.replace(/\s+/g, '_') // Replace spaces with underscores
.toLowerCase();
};
// Function to get file extension from URL
const getExtension = (url) => {
const ext = path.extname(new URL(url).pathname);
return ext || '.unknown';
};
// Function to download a file
const downloadFile = async (url, outputPath) => {
try {
console.log(`Starting download from: ${url}`);
console.log(`Saving to: ${outputPath}`);
const response = await axios.get(url, {
responseType: "stream",
timeout: 30000, // 30 seconds timeout
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
});
const writer = fs.createWriteStream(outputPath);
return new Promise((resolve) => {
response.data.pipe(writer);
writer.on("finish", () => {
console.log(`Successfully downloaded to: ${outputPath}`);
resolve({ success: true, path: outputPath });
});
writer.on("error", (error) => {
console.error(`Write stream error: ${error.message}`);
fs.unlink(outputPath, () => {
console.log(`Cleaned up failed file: ${outputPath}`);
});
resolve({
success: false,
error: `Failed to write file: ${error.message}`
});
});
response.data.on("error", (error) => {
console.error(`Download stream error: ${error.message}`);
fs.unlink(outputPath, () => {
console.log(`Cleaned up failed file: ${outputPath}`);
});
resolve({
success: false,
error: `Failed to download: ${error.message}`
});
});
});
}
catch (error) {
console.error(`Axios error: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
error: `Error downloading ${url}: ${error instanceof Error ? error.message : String(error)}`
};
}
};
// Function to scrape Suno webpage
const scrapeSuno = async (sunoUrl) => {
try {
console.log(`\nFetching page: ${sunoUrl}`);
const { data: html } = await axios.get(sunoUrl, { timeout: 30000 });
const $ = cheerio.load(html);
// Extract song title from og:title meta tag
const title = $("meta[property='og:title']").attr("content") || "unknown";
const songName = title.split('|')[0].trim().replace(" by @", "_by_");
const sanitizedName = sanitizeFilename(songName);
console.log(`Song name: ${songName}`);
// Extract cover image URL from og:image meta tag
const coverUrl = $("meta[property='og:image']").attr("content");
if (!coverUrl)
throw new Error("Cover image URL not found.");
const coverExt = getExtension(coverUrl);
const coverPath = path.join(process.cwd(), `${sanitizedName}_cover${coverExt}`);
console.log(`Downloading cover image: ${coverUrl}`);
const coverResult = await downloadFile(coverUrl, coverPath);
if (!coverResult.success) {
throw new Error(coverResult.error);
}
// Extract audio URL from og:audio meta tag
const audioUrl = $("meta[property='og:audio']").attr("content");
if (!audioUrl)
throw new Error("Audio file URL not found.");
const audioExt = getExtension(audioUrl);
const audioPath = path.join(process.cwd(), `${sanitizedName}${audioExt}`);
console.log(`Downloading audio file: ${audioUrl}`);
const audioResult = await downloadFile(audioUrl, audioPath);
if (!audioResult.success) {
throw new Error(audioResult.error);
}
console.log("Download complete!");
console.log(`Files saved as:\n- ${path.basename(coverPath)}\n- ${path.basename(audioPath)}`);
return {
coverPath: coverPath,
audioPath: audioPath
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error scraping Suno: ${errorMessage}`);
return { error: errorMessage };
}
};
// Validate URL format
const isValidUrl = (url) => {
try {
new URL(url);
return true;
}
catch {
return false;
}
};
// Process multiple URLs
const processUrls = async (urls) => {
console.log(`Found ${urls.length} URLs to process`);
for (const url of urls) {
const trimmedUrl = url.trim();
if (!trimmedUrl)
continue;
if (!isValidUrl(trimmedUrl)) {
console.error(`Invalid URL: ${trimmedUrl}`);
continue;
}
try {
const result = await scrapeSuno(trimmedUrl);
if (result.error) {
console.error(`Failed to process ${trimmedUrl}: ${result.error}`);
}
}
catch (error) {
console.error(`Error processing ${trimmedUrl}: ${error instanceof Error ? error.message : String(error)}`);
}
}
console.log("\nAll downloads completed!");
};
// CLI entry point
const main = async () => {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: suno <suno_url(s)>");
console.error("Multiple URLs should be separated by newlines");
process.exit(1);
}
// Join all arguments and split by newlines to handle multiple URLs
const urls = args.join(" ").split("\n").filter(url => url.trim());
if (urls.length === 0) {
console.error("No valid URLs provided");
process.exit(1);
}
await processUrls(urls);
};
// Handle unhandled rejections
process.on("unhandledRejection", (error) => {
console.error("Unhandled rejection:", error);
process.exit(1);
});
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});