const http = require("http"); const { load } = require("cheerio"); const Anthropic = require("@anthropic-ai/sdk"); const OpenAI = require("openai"); const OPENAI_API_KEY="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"; const CLIENT_ID = "44e13265ab164d209a8665e7d0ae2237"; const CLIENT_SECRET = "cbd639db2c6b4c01981e8dd0cb00569b"; const REDIRECT_URI = "http://127.0.0.1:8888/callback"; // --- Rate Limiter --- function createRateLimiter(maxPerSecond) { const minInterval = 1000 / maxPerSecond; let lastCall = 0; return async function throttle() { const now = Date.now(); const elapsed = now - lastCall; if (elapsed < minInterval) { await new Promise((resolve) => setTimeout(resolve, minInterval - elapsed)); } lastCall = Date.now(); }; } const spotifyThrottle = createRateLimiter(2); async function spotifyFetch(url, options = {}) { await spotifyThrottle(); return fetch(url, options); } // --- LLM Providers --- const EXTRACTION_PROMPT = `Extract all music artist or band names from this web page text. The page is about concerts, festivals, or music venues. Return ONLY a JSON array of artist names, nothing else. Example: ["Artist 1", "Artist 2"] If no artist names are found, return an empty array: [] Page text: `; async function extractWithAnthropic(text) { const client = new Anthropic(); const message = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 1024, messages: [{ role: "user", content: EXTRACTION_PROMPT + text }], }); return message.content[0].text.trim(); } async function extractWithOpenAI(text) { const client = new OpenAI({ apiKey: process.env['OPENAI_API_KEY'] || OPENAI_API_KEY, // This is the default and can be omitted }); const completion = await client.chat.completions.create({ model: "gpt-4o-mini", max_tokens: 1024, messages: [{ role: "user", content: EXTRACTION_PROMPT + text }], }); return completion.choices[0].message.content.trim(); } const LLM_PROVIDERS = { anthropic: extractWithAnthropic, openai: extractWithOpenAI, }; // --- Spotify Auth --- async function getAccessToken() { const response = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`), }, body: "grant_type=client_credentials", }); const data = await response.json(); return data.access_token; } async function getUserAccessToken() { const scopes = "playlist-modify-public playlist-modify-private"; const authUrl = `https://accounts.spotify.com/authorize?response_type=code` + `&client_id=${CLIENT_ID}` + `&scope=${encodeURIComponent(scopes)}` + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`; console.log("\nOpen this URL in your browser to authorize:\n"); console.log(authUrl + "\n"); const code = await new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL(req.url, `http://127.0.0.1:8888`); const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); if (error) { res.writeHead(200, { "Content-Type": "text/html" }); res.end("
You can close this tab.
"); server.close(); reject(new Error(`Authorization denied: ${error}`)); return; } if (code) { res.writeHead(200, { "Content-Type": "text/html" }); res.end("You can close this tab.
"); server.close(); resolve(code); } }); server.listen(8888, () => { console.log("Waiting for authorization..."); }); }); const response = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`), }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, }), }); const data = await response.json(); if (!response.ok) { throw new Error(`Token exchange failed: ${JSON.stringify(data)}`); } return data.access_token; } // --- Spotify API --- async function searchArtist(token, artistName) { const response = await spotifyFetch( `https://api.spotify.com/v1/search?q=${encodeURIComponent(artistName)}&type=artist&limit=1`, { headers: { Authorization: `Bearer ${token}` } } ); //console.log(`Server response for "${artistName}" on Spotify:`, response.headers, response.status, response.statusText, await response.text()); const data = await response.json(); const artist = data.artists.items[0]; if (!artist) { return null; } return artist; } async function getTracksByArtist(token, artistName) { const response = await spotifyFetch( `https://api.spotify.com/v1/search?q=${encodeURIComponent(`artist:${artistName}`)}&type=track%2Cshow&limit=10`, { headers: { Authorization: `Bearer ${token}` } } ); const data = await response.json(); if (!response.ok) { throw new Error(`Spotify API error: ${JSON.stringify(data)}`); } return data.tracks.items; } async function getTopTracks(token, artistName, topN = 3) { const artist = await searchArtist(token, artistName); if (!artist) { return { artist: null, tracks: [] }; } const tracks = await getTracksByArtist(token, artistName); const topTracks = tracks .filter((t) => t.artists.some((a) => a.id === artist.id)) .slice(0, topN) .map((t) => ({ name: t.name, album: t.album.name, uri: t.uri })); return { artist: artist.name, tracks: topTracks }; } // --- Playlist Creation --- function playlistNameFromUrl(url) { return url .replace(/^https?:\/\//, "") .replace(/[<>:"\/\\|?*]+/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, ""); } async function createPlaylist(token, name, trackUris) { // Use POST /me/playlists (Feb 2026: /users/{id}/playlists removed) const response = await spotifyFetch( `https://api.spotify.com/v1/me/playlists`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, description: `Generated from ${name}`, public: false, }), } ); const playlist = await response.json(); if (!response.ok) { throw new Error(`Failed to create playlist: ${JSON.stringify(playlist)}`); } // Use POST /playlists/{id}/items (Feb 2026: /playlists/{id}/tracks removed) // Spotify allows max 100 tracks per request for (let i = 0; i < trackUris.length; i += 100) { const batch = trackUris.slice(i, i + 100); const addResponse = await spotifyFetch( `https://api.spotify.com/v1/playlists/${playlist.id}/items`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ uris: batch }), } ); if (!addResponse.ok) { const err = await addResponse.json(); throw new Error(`Failed to add tracks: ${JSON.stringify(err)}`); } } return playlist; } // --- Web Scraping with LLM --- async function scrapeArtists(url, provider, log = console.log) { log(`Fetching ${url}...`); const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status}`); } const html = await response.text(); const $ = load(html); $("script, style, nav, footer, header, noscript, svg").remove(); const textContent = $("body").text().replace(/\s+/g, " ").trim(); const maxChars = 20000; const truncated = textContent.length > maxChars ? textContent.slice(0, maxChars) + "..." : textContent; log(`Extracting artist names with ${provider}...`); const extract = LLM_PROVIDERS[provider]; const responseText = await extract(truncated); const jsonString = responseText.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, ""); return JSON.parse(jsonString); } // --- Main --- function parseArgs() { const args = process.argv.slice(2); const parsed = { provider: "anthropic", url: null, artist: null, topN: 3, createPlaylist: false, }; const llmIndex = args.indexOf("--llm"); if (llmIndex !== -1) { parsed.provider = args[llmIndex + 1]; if (!LLM_PROVIDERS[parsed.provider]) { console.error( `Unknown provider "${parsed.provider}". Available: ${Object.keys(LLM_PROVIDERS).join(", ")}` ); process.exit(1); } } const urlIndex = args.indexOf("--url"); if (urlIndex !== -1) { parsed.url = args[urlIndex + 1]; } const topIndex = args.indexOf("--top"); if (topIndex !== -1) { parsed.topN = parseInt(args[topIndex + 1], 10); } if (args.includes("--create_playlist")) { if (!parsed.url) { console.error("--create_playlist can only be used with --url"); process.exit(1); } parsed.createPlaylist = true; } // Artist is any arg that isn't a flag or flag value const flagIndices = new Set(); for (const flag of ["--llm", "--url", "--top"]) { const idx = args.indexOf(flag); if (idx !== -1) { flagIndices.add(idx); flagIndices.add(idx + 1); } } const flagsWithoutValue = ["--create_playlist"]; for (const flag of flagsWithoutValue) { const idx = args.indexOf(flag); if (idx !== -1) flagIndices.add(idx); } const positional = args.filter((_, i) => !flagIndices.has(i)); if (positional.length > 0) { parsed.artist = positional[0]; } return parsed; } async function main() { const { provider, url, artist, topN, createPlaylist: shouldCreatePlaylist } = parseArgs(); if (url) { const artistNames = await scrapeArtists(url, provider, console.error); if (artistNames.length === 0) { console.log(JSON.stringify({ artists: [] })); return; } console.error(`Found ${artistNames.length} artist(s): ${artistNames.join(", ")}`); console.error("Looking up top tracks on Spotify..."); const token = await getAccessToken(); const artists = []; const allTrackUris = []; for (const name of artistNames) { const result = await getTopTracks(token, name, topN); if (result.artist) { artists.push(result); allTrackUris.push(...result.tracks.map((t) => t.uri)); } } const output = { artists }; if (shouldCreatePlaylist && allTrackUris.length > 0) { console.error(`Creating playlist with ${allTrackUris.length} tracks...`); const userToken = await getUserAccessToken(); const playlistName = playlistNameFromUrl(url); const playlist = await createPlaylist(userToken, playlistName, allTrackUris); output.playlist = { name: playlist.name, url: playlist.external_urls.spotify, }; } console.log(JSON.stringify(output, null, 2)); } else { const artistName = artist || "Radiohead"; const token = await getAccessToken(); const result = await getTopTracks(token, artistName, topN); console.log(JSON.stringify(result, null, 2)); } } if (require.main === module) { main().catch(console.error); } module.exports = { getAccessToken, searchArtist, getTracksByArtist, getTopTracks, scrapeArtists, createPlaylist, playlistNameFromUrl, LLM_PROVIDERS, };