feat(backend): switch Spotify resolve to embed-page scraping

Removes the Spotify Web API token approach (blocked on non-Premium
accounts and from datacenter IPs). Now fetches open.spotify.com/embed
pages and parses __NEXT_DATA__ JSON — works without any auth from any IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-27 01:47:06 +02:00
1 parent 6f419d24ff
commit 487dbf12b4
1 file changed
+82 -68
+82 -68
View File
@@ -388,42 +388,66 @@ function enqueue(task) {
}
// ─── SPOTIFY ──────────────────────────────────────────────────────────────────
let _spTok = null, _spTokExp = 0;
// Uses Spotify's public web-player token — no account or Premium required
async function getSpotifyToken() {
if (_spTok && Date.now() < _spTokExp - 60000) return _spTok;
const r = await fetch(
'https://open.spotify.com/get_access_token?reason=transport&productType=web_player',
{
headers: {
'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',
Accept: 'application/json',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
Cookie: 'sp_t=1',
Referer: 'https://open.spotify.com/',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
},
signal: AbortSignal.timeout(8000),
}
);
if (!r.ok) throw new Error(`Spotify token fehlgeschlagen (${r.status}) — Server-IP möglicherweise von Spotify geblockt`);
const d = await r.json();
if (!d.accessToken) throw new Error('Kein Spotify-Token erhalten');
_spTok = d.accessToken;
_spTokExp = d.accessTokenExpirationTimestampMs || (Date.now() + 3600000);
return _spTok;
}
// Scrapes Spotify's public embed pages — no token, no account, no Premium needed.
// Embed pages serve __NEXT_DATA__ JSON to anyone (used for website embeds worldwide).
function parseSpotifyUrl(url) {
// Handles locale prefixes: open.spotify.com/intl-de/track/... or open.spotify.com/track/...
const m = url.match(/open\.spotify\.com\/(?:[a-z]{2,5}-[a-z]{2,5}\/)?(?:intl-[a-z-]+\/)?(track|album|playlist)\/([A-Za-z0-9]+)/);
// Handles locale prefixes: /intl-de/track/... and plain /track/...
const m = url.match(/open\.spotify\.com\/(?:[a-z]{2,8}-[a-z]{2,8}\/)?(?:intl-[a-z-]+\/)?(track|album|playlist)\/([A-Za-z0-9]+)/);
if (!m) throw new Error('Ungültige Spotify-URL — erwartet: open.spotify.com/track|album|playlist/...');
return { type: m[1], id: m[2] };
}
async function fetchSpotifyEmbed(type, id) {
const r = await fetch(`https://open.spotify.com/embed/${type}/${id}`, {
headers: {
'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',
Accept: 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
signal: AbortSignal.timeout(12000),
});
if (!r.ok) throw new Error(`Spotify-Embed nicht erreichbar (${r.status})`);
const html = await r.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.+?)<\/script>/s);
if (!m) throw new Error('Spotify-Seite nicht parsebar — Struktur geändert?');
const nd = JSON.parse(m[1]);
const entity = nd?.props?.pageProps?.state?.data?.entity
|| nd?.props?.pageProps?.data?.entity
|| nd?.props?.pageProps?.entity;
if (!entity) throw new Error('Keine Metadaten in Spotify-Embed gefunden');
return entity;
}
function _spArtwork(entity) {
const sources = entity?.album?.coverArt?.sources || entity?.coverArt?.sources || [];
if (sources.length) return sources.sort((a, b) => (b.width || 0) - (a.width || 0))[0].url;
return entity?.album?.images?.[0]?.url || entity?.images?.[0]?.url || '';
}
function _spArtistName(artistsArr) {
const a = artistsArr?.[0];
return a?.profile?.name || a?.name || '';
}
function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) {
const track = t?.track || t;
const artist = _spArtistName(track?.artists) || defaultAlbumArtist || '';
const art = _spArtwork(track) || defaultArt || '';
const album = track?.album?.name || defaultAlbum || '';
const date = track?.album?.date?.isoString || track?.album?.release_date || '';
return {
trackName: track?.name || '',
artistName: artist,
collectionName: album,
artworkUrl100: art,
releaseDate: date,
trackNumber: track?.trackNumber || track?.track_number,
trackExplicitness: track?.contentRating?.label === 'EXPLICIT' || track?.explicit ? 'explicit' : 'notExplicit',
previewUrl: track?.previewUrl || track?.preview_url || null,
};
}
// ─── ROUTES ──────────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => {
@@ -435,52 +459,42 @@ app.post('/api/spotify/resolve', async (req, res) => {
if (!url) return res.status(400).json({ error: 'url required' });
try {
const { type, id } = parseSpotifyUrl(url);
const tok = await getSpotifyToken();
const spH = { Authorization: `Bearer ${tok}` };
const spFetch = (u) => fetch(u, { headers: spH, signal: AbortSignal.timeout(10000) });
const entity = await fetchSpotifyEmbed(type, id);
const artist = _spArtistName(entity.artists);
const artUrl = _spArtwork(entity);
if (type === 'track') {
const r = await spFetch(`https://api.spotify.com/v1/tracks/${id}`);
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
const d = await r.json();
return res.json({ type: 'track', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: d.album?.images?.[0]?.url || '',
tracks: [{ trackName: d.name, artistName: d.artists?.[0]?.name || '', collectionName: d.album?.name || '',
artworkUrl100: d.album?.images?.[0]?.url || '', releaseDate: d.album?.release_date || '',
trackNumber: d.track_number, trackExplicitness: d.explicit ? 'explicit' : 'notExplicit', previewUrl: d.preview_url || null }] });
return res.json({
type: 'track',
name: entity.name,
artist,
artworkUrl: artUrl,
tracks: [_spTrackItem(entity, entity.album?.name, artUrl, artist)],
});
}
if (type === 'album') {
const r = await spFetch(`https://api.spotify.com/v1/albums/${id}`);
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
const d = await r.json();
const art = d.images?.[0]?.url || '';
return res.json({ type: 'album', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: art,
tracks: (d.tracks?.items || []).map(t => ({ trackName: t.name, artistName: t.artists?.[0]?.name || d.artists?.[0]?.name || '',
collectionName: d.name, artworkUrl100: art, releaseDate: d.release_date || '',
trackNumber: t.track_number, trackExplicitness: t.explicit ? 'explicit' : 'notExplicit', previewUrl: t.preview_url || null })) });
const trackList = entity.trackList || entity.tracks?.items || [];
return res.json({
type: 'album',
name: entity.name,
artist,
artworkUrl: artUrl,
tracks: trackList.map(t => _spTrackItem(t, entity.name, artUrl, artist)),
});
}
if (type === 'playlist') {
const r = await spFetch(`https://api.spotify.com/v1/playlists/${id}?fields=name,description,images,owner,tracks.total`);
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
const pl = await r.json();
const tracks = [];
let nextUrl = `https://api.spotify.com/v1/playlists/${id}/tracks?limit=100&fields=next,items(is_local,track(name,artists,album,track_number,explicit,preview_url))`;
while (nextUrl) {
const tr = await spFetch(nextUrl);
if (!tr.ok) break;
const td = await tr.json();
for (const item of (td.items || [])) {
if (!item?.track || item.is_local) continue;
tracks.push({ trackName: item.track.name, artistName: item.track.artists?.[0]?.name || '',
collectionName: item.track.album?.name || '', artworkUrl100: item.track.album?.images?.[0]?.url || '',
releaseDate: item.track.album?.release_date || '', trackNumber: item.track.track_number,
trackExplicitness: item.track.explicit ? 'explicit' : 'notExplicit', previewUrl: item.track.preview_url || null });
}
nextUrl = td.next || null;
}
return res.json({ type: 'playlist', name: pl.name, artist: pl.owner?.display_name || '',
artworkUrl: pl.images?.[0]?.url || '', description: pl.description || '', tracks });
const trackList = entity.trackList || [];
const owner = entity.ownerV2?.data?.name || entity.owner?.displayName || entity.owner?.display_name || '';
return res.json({
type: 'playlist',
name: entity.name,
artist: owner,
artworkUrl: artUrl,
description: entity.description || '',
tracks: trackList.map(t => _spTrackItem(t, null, null, null)),
});
}
res.status(400).json({ error: `Unbekannter Typ: ${type}` });