diff --git a/adapters/spotify.js b/adapters/spotify.js new file mode 100644 index 0000000..3b07c7c --- /dev/null +++ b/adapters/spotify.js @@ -0,0 +1,92 @@ +export const ID = "spotify"; + +const URL_RE = /^https?:\/\/open\.spotify\.com\/track\/([A-Za-z0-9]{22})/; + +const CHROME_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"; + +export function detect({ url, jsonld, meta }) { + const m = url.match(URL_RE); + if (!m) return null; + const id = m[1]; + return { + adapter: ID, + kind: "track", + id, + url, + meta: meta || {}, + jsonld: jsonld || [], + }; +} + +export async function extract(ctx) { + const tools = []; + const product = { type: "track", id: ctx.id, url: ctx.url }; + + const ogTitle = ctx.meta["og:title"] || ""; + const ogDescription = ctx.meta["og:description"] || ""; + const ogImage = ctx.meta["og:image"] || null; + const ogAudio = ctx.meta["og:audio"] || null; + const musicDuration = ctx.meta["music:duration"] || null; + const musicMusician = ctx.meta["music:musician"] || null; + + let trackName = ogTitle; + let artists = []; + if (ogTitle.includes(" - ")) { + const parts = ogTitle.split(" - "); + trackName = parts[0].trim(); + artists = parts.slice(1).map((a) => a.trim()); + } else if (ogTitle.includes(" · ")) { + const parts = ogTitle.split(" · "); + trackName = parts[0].trim(); + artists = parts.slice(1).map((a) => a.trim()); + } + + if (musicMusician) { + const musicianNames = Array.isArray(musicMusician) ? musicMusician : [musicMusician]; + for (const m of musicianNames) { + const name = typeof m === "string" ? m : m?.name; + if (name && !artists.includes(name)) artists.push(name); + } + } + + const trackInfo = { + name: trackName || null, + artists: artists.length ? artists : null, + album: ogDescription ? ogDescription.replace(/^from the album /i, "").replace(/\.$/, "") : null, + duration_seconds: musicDuration ? Number(musicDuration) : null, + preview_url: ogAudio || null, + image: ogImage, + spotify_url: ctx.url, + }; + product.name = trackInfo.name; + product.artists = trackInfo.artists; + + tools.push({ + name: "get_track", + description: `Get track details for "${trackInfo.name || ctx.id}"${trackInfo.artists ? ` by ${trackInfo.artists.join(", ")}` : ""}.`, + inputSchema: { type: "object", properties: {}, required: [] }, + result: trackInfo, + }); + + if (ogAudio) { + tools.push({ + name: "get_preview", + description: `Get the 30-second preview URL for "${trackInfo.name || ctx.id}".`, + inputSchema: { type: "object", properties: {}, required: [] }, + result: { preview_url: ogAudio, track: trackInfo.name }, + }); + } + + tools.push({ + name: "view_track", + description: `Return a direct link to "${trackInfo.name || ctx.id}" on Spotify.`, + inputSchema: { type: "object", properties: {}, required: [] }, + result: { url: ctx.url, name: trackInfo.name }, + }); + + return { product, variants: [], tools }; +} + +export const actions = {}; diff --git a/worker/src/engine.ts b/worker/src/engine.ts index 273db8b..e1dd786 100644 --- a/worker/src/engine.ts +++ b/worker/src/engine.ts @@ -16,6 +16,7 @@ import * as defillama from "../../adapters/defillama.js"; import * as dexscreener from "../../adapters/dexscreener.js"; import * as pyth from "../../adapters/pyth.js"; import * as chainlink from "../../adapters/chainlink.js"; +import * as spotify from "../../adapters/spotify.js"; import { fetchAndParse } from "./html"; import { resolveTokenForUrl } from "./token_resolver"; import { loadProviderToken } from "./token_vault"; @@ -137,6 +138,18 @@ export async function resolveTools( } } + const spotifyCtx = spotify.detect({ url, jsonld: [], meta: {} }); + if (spotifyCtx) { + try { + const data = await spotify.extract(spotifyCtx); + const payload: ToolsPayload = { adapter: "spotify", tools: data.tools, product: data.product }; + ctx.waitUntil(writeCache(env, url, payload, 3600)); + return { ok: true, payload, from: "live" }; + } catch { + // fall through + } + } + const openapiCtx = openapi.detect({ url }); if (openapiCtx) { try { @@ -279,6 +292,19 @@ export async function executeTool( } } + const spCtx = spotify.detect({ url, jsonld: [], meta: {} }); + if (spCtx) { + try { + const data = await spotify.extract(spCtx); + const tool = data.tools.find((t: any) => t.name === toolName); + if (!tool) return { ok: false, status: 404, body: { error: `tool ${toolName} not found` } }; + if (tool.result !== undefined) return { ok: true, value: tool.result }; + return { ok: false, status: 400, body: { error: "spotify adapter is read-only" } }; + } catch (err: any) { + return { ok: false, status: 500, body: { ok: false, error: String(err?.message || err) } }; + } + } + for (const { name, mod } of CRYPTO_ADAPTERS) { const cctx = (mod as any).detect({ url }); if (!cctx) continue;