fix: replace Spotify embed scraper with anonymous token + real API

Uses open.spotify.com/get_access_token for an anonymous bearer token,
then paginates through the full Spotify API — removes the 100-track
embed limit for playlists and albums.
This commit is contained in:
Honey committed 2026-05-09 19:58:07 +02:00
1 parent 7dd22d9639
commit f218b66ad2
1 file changed
+81 -61
+81 -61
View File
@@ -67,39 +67,65 @@ function parseSpotifyUrl(url) {
return { type: m[1], id: m[2] }; return { type: m[1], id: m[2] };
} }
async function fetchSpotifyEmbed(type, id) { let _spTokenCache = null;
const r = await fetch(`https://open.spotify.com/embed/${type}/${id}`, { let _spTokenExpiry = 0;
async function fetchSpotifyToken() {
if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache;
const r = await fetch('https://open.spotify.com/get_access_token?reason=transport&productType=web_player', {
headers: { 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', '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': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
}, },
signal: AbortSignal.timeout(12000), signal: AbortSignal.timeout(10000),
}); });
if (!r.ok) throw new Error(`Spotify-Embed nicht erreichbar (${r.status})`); if (!r.ok) throw new Error(`Spotify-Token nicht abrufbar (${r.status})`);
const html = await r.text(); const d = await r.json();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.+?)<\/script>/s); if (!d.accessToken) throw new Error('Kein Spotify-Token erhalten');
if (!m) throw new Error('Spotify-Seite nicht parsebar — Struktur geändert?'); _spTokenCache = d.accessToken;
const nd = JSON.parse(m[1]); _spTokenExpiry = (d.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000;
let entity = nd?.props?.pageProps?.state?.data?.entity return _spTokenCache;
|| nd?.props?.pageProps?.data?.entity }
|| nd?.props?.pageProps?.entity
|| nd?.props?.pageProps?.state?.data; async function _spApi(path, token) {
if (!entity) throw new Error('Keine Metadaten in Spotify-Embed gefunden'); const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`;
if (entity.data && !entity.name && !entity.trackList) entity = entity.data; const r = await fetch(url, {
return entity; headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10000),
});
if (!r.ok) throw new Error(`Spotify API Fehler ${r.status}: ${path}`);
return r.json();
}
async function _spAllPlaylistTracks(id, token) {
const tracks = [];
let path = `/playlists/${id}/tracks?limit=100`;
while (path) {
const page = await _spApi(path, token);
for (const item of page.items || []) {
if (item.track?.name) tracks.push(item.track);
}
path = page.next || null;
}
return tracks;
}
async function _spAllAlbumTracks(id, token) {
const tracks = [];
let path = `/albums/${id}/tracks?limit=50`;
while (path) {
const page = await _spApi(path, token);
tracks.push(...(page.items || []));
path = page.next || null;
}
return tracks;
} }
function _spArtwork(entity) { function _spArtwork(entity) {
const sources = entity?.albumOfTrack?.coverArt?.sources // Real API: entity.images[] or entity.album.images[]
|| entity?.album?.coverArt?.sources const imgs = entity?.images || entity?.album?.images || [];
|| entity?.coverArt?.sources if (imgs.length) return imgs.sort((a, b) => (b.width || 0) - (a.width || 0))[0]?.url || '';
|| []; return '';
if (sources.length) return sources.sort((a, b) => (b.width || 0) - (a.width || 0))[0].url;
return entity?.albumOfTrack?.images?.[0]?.url
|| entity?.album?.images?.[0]?.url
|| entity?.images?.[0]?.url
|| '';
} }
function _spAllArtists(artistsRaw) { function _spAllArtists(artistsRaw) {
@@ -113,30 +139,22 @@ function _spArtistName(artistsRaw) {
} }
function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) { function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) {
let raw = t?.track || t; const trackName = t?.name || '';
const d = raw?.data || raw; const artist = _spAllArtists(t?.artists) || defaultAlbumArtist || '';
const trackName = d?.name || d?.title || ''; const art = _spArtwork(t) || defaultArt || '';
let artist = ''; const albumObj = t?.album || {};
if (d?.artists) { const album = albumObj?.name || defaultAlbum || '';
artist = _spAllArtists(d.artists); const date = albumObj?.release_date || '';
} else if (typeof d?.subtitle === 'string' && d.subtitle) {
artist = d.subtitle;
}
artist = artist || defaultAlbumArtist || '';
const art = _spArtwork(d) || defaultArt || '';
const albumObj = d?.albumOfTrack || d?.album || {};
const album = albumObj?.name || defaultAlbum || '';
const date = albumObj?.date?.isoString || albumObj?.release_date || '';
return { return {
trackName, trackName,
artistName: artist, artistName: artist,
collectionName: album, collectionName: album,
artworkUrl100: art, artworkUrl100: art,
releaseDate: date, releaseDate: date,
trackNumber: d?.trackNumber || d?.track_number, trackNumber: t?.track_number,
trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.isExplicit || d?.explicit ? 'explicit' : 'notExplicit', trackExplicitness: t?.explicit ? 'explicit' : 'notExplicit',
previewUrl: d?.previewUrl || d?.preview_url || d?.audioPreview?.url || null, previewUrl: t?.preview_url || null,
durationMs: d?.duration_ms || d?.durationMs || null, durationMs: t?.duration_ms || null,
}; };
} }
@@ -176,45 +194,47 @@ app.post('/api/spotify/resolve', async (req, res) => {
if (!url) return res.status(400).json({ error: 'url required' }); if (!url) return res.status(400).json({ error: 'url required' });
try { try {
const { type, id } = parseSpotifyUrl(url); const { type, id } = parseSpotifyUrl(url);
const entity = await fetchSpotifyEmbed(type, id); const token = await fetchSpotifyToken();
const artist = _spArtistName(entity.artists) || (typeof entity.subtitle === 'string' ? entity.subtitle : '');
const artUrl = _spArtwork(entity);
if (type === 'track') { if (type === 'track') {
const t = await _spApi(`/tracks/${id}`, token);
const artist = _spArtistName(t.artists);
const artUrl = _spArtwork(t);
return res.json({ return res.json({
type: 'track', type: 'track',
name: entity.name || entity.title || '', name: t.name || '',
artist, artist,
artworkUrl: artUrl, artworkUrl: artUrl,
tracks: [_spTrackItem(entity, entity.album?.name, artUrl, artist)], tracks: [_spTrackItem(t, t.album?.name, artUrl, artist)],
}); });
} }
if (type === 'album') { if (type === 'album') {
const trackList = entity.trackList || entity.tracks?.items || []; const album = await _spApi(`/albums/${id}`, token);
const artist = _spArtistName(album.artists);
const artUrl = _spArtwork(album);
const trackItems = await _spAllAlbumTracks(id, token);
return res.json({ return res.json({
type: 'album', type: 'album',
name: entity.name || entity.title || '', name: album.name || '',
artist, artist,
artworkUrl: artUrl, artworkUrl: artUrl,
tracks: trackList.map(t => _spTrackItem(t, entity.name || entity.title, artUrl, artist)), tracks: trackItems.map(t => _spTrackItem(t, album.name, artUrl, artist)),
}); });
} }
if (type === 'playlist') { if (type === 'playlist') {
const trackList = entity.trackList || entity.content?.items || entity.tracks?.items || []; const pl = await _spApi(`/playlists/${id}?fields=name,description,owner,images`, token);
const owner = entity.authors?.[0]?.name const owner = pl.owner?.display_name || '';
|| entity.ownerV2?.data?.name const artUrl = _spArtwork(pl);
|| entity.owner?.displayName const trackItems = await _spAllPlaylistTracks(id, token);
|| entity.owner?.display_name
|| (typeof entity.subtitle === 'string' ? entity.subtitle : '');
return res.json({ return res.json({
type: 'playlist', type: 'playlist',
name: entity.name || entity.title || '', name: pl.name || '',
artist: owner, artist: owner,
artworkUrl: artUrl, artworkUrl: artUrl,
description: entity.description || '', description: pl.description || '',
tracks: trackList.map(t => _spTrackItem(t, null, null, null)), tracks: trackItems.map(t => _spTrackItem(t, null, null, null)),
}); });
} }