You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

403 lines
11 KiB
JavaScript

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("<h1>Authorization denied.</h1><p>You can close this tab.</p>");
server.close();
reject(new Error(`Authorization denied: ${error}`));
return;
}
if (code) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end("<h1>Authorized!</h1><p>You can close this tab.</p>");
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,
};