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:
1 parent
7dd22d9639
commit
f218b66ad2
1 file changed
+81
-61
+81
-61
@@ -67,39 +67,65 @@ function parseSpotifyUrl(url) {
|
||||
return { type: m[1], id: m[2] };
|
||||
}
|
||||
|
||||
async function fetchSpotifyEmbed(type, id) {
|
||||
const r = await fetch(`https://open.spotify.com/embed/${type}/${id}`, {
|
||||
let _spTokenCache = null;
|
||||
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: {
|
||||
'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',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(12000),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
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]);
|
||||
let entity = nd?.props?.pageProps?.state?.data?.entity
|
||||
|| nd?.props?.pageProps?.data?.entity
|
||||
|| nd?.props?.pageProps?.entity
|
||||
|| nd?.props?.pageProps?.state?.data;
|
||||
if (!entity) throw new Error('Keine Metadaten in Spotify-Embed gefunden');
|
||||
if (entity.data && !entity.name && !entity.trackList) entity = entity.data;
|
||||
return entity;
|
||||
if (!r.ok) throw new Error(`Spotify-Token nicht abrufbar (${r.status})`);
|
||||
const d = await r.json();
|
||||
if (!d.accessToken) throw new Error('Kein Spotify-Token erhalten');
|
||||
_spTokenCache = d.accessToken;
|
||||
_spTokenExpiry = (d.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000;
|
||||
return _spTokenCache;
|
||||
}
|
||||
|
||||
async function _spApi(path, token) {
|
||||
const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`;
|
||||
const r = await fetch(url, {
|
||||
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) {
|
||||
const sources = entity?.albumOfTrack?.coverArt?.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?.albumOfTrack?.images?.[0]?.url
|
||||
|| entity?.album?.images?.[0]?.url
|
||||
|| entity?.images?.[0]?.url
|
||||
|| '';
|
||||
// Real API: entity.images[] or entity.album.images[]
|
||||
const imgs = entity?.images || entity?.album?.images || [];
|
||||
if (imgs.length) return imgs.sort((a, b) => (b.width || 0) - (a.width || 0))[0]?.url || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _spAllArtists(artistsRaw) {
|
||||
@@ -113,30 +139,22 @@ function _spArtistName(artistsRaw) {
|
||||
}
|
||||
|
||||
function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) {
|
||||
let raw = t?.track || t;
|
||||
const d = raw?.data || raw;
|
||||
const trackName = d?.name || d?.title || '';
|
||||
let artist = '';
|
||||
if (d?.artists) {
|
||||
artist = _spAllArtists(d.artists);
|
||||
} 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 || '';
|
||||
const trackName = t?.name || '';
|
||||
const artist = _spAllArtists(t?.artists) || defaultAlbumArtist || '';
|
||||
const art = _spArtwork(t) || defaultArt || '';
|
||||
const albumObj = t?.album || {};
|
||||
const album = albumObj?.name || defaultAlbum || '';
|
||||
const date = albumObj?.release_date || '';
|
||||
return {
|
||||
trackName,
|
||||
artistName: artist,
|
||||
collectionName: album,
|
||||
artworkUrl100: art,
|
||||
releaseDate: date,
|
||||
trackNumber: d?.trackNumber || d?.track_number,
|
||||
trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.isExplicit || d?.explicit ? 'explicit' : 'notExplicit',
|
||||
previewUrl: d?.previewUrl || d?.preview_url || d?.audioPreview?.url || null,
|
||||
durationMs: d?.duration_ms || d?.durationMs || null,
|
||||
trackNumber: t?.track_number,
|
||||
trackExplicitness: t?.explicit ? 'explicit' : 'notExplicit',
|
||||
previewUrl: t?.preview_url || 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' });
|
||||
try {
|
||||
const { type, id } = parseSpotifyUrl(url);
|
||||
const entity = await fetchSpotifyEmbed(type, id);
|
||||
const artist = _spArtistName(entity.artists) || (typeof entity.subtitle === 'string' ? entity.subtitle : '');
|
||||
const artUrl = _spArtwork(entity);
|
||||
const token = await fetchSpotifyToken();
|
||||
|
||||
if (type === 'track') {
|
||||
const t = await _spApi(`/tracks/${id}`, token);
|
||||
const artist = _spArtistName(t.artists);
|
||||
const artUrl = _spArtwork(t);
|
||||
return res.json({
|
||||
type: 'track',
|
||||
name: entity.name || entity.title || '',
|
||||
name: t.name || '',
|
||||
artist,
|
||||
artworkUrl: artUrl,
|
||||
tracks: [_spTrackItem(entity, entity.album?.name, artUrl, artist)],
|
||||
tracks: [_spTrackItem(t, t.album?.name, artUrl, artist)],
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
type: 'album',
|
||||
name: entity.name || entity.title || '',
|
||||
name: album.name || '',
|
||||
artist,
|
||||
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') {
|
||||
const trackList = entity.trackList || entity.content?.items || entity.tracks?.items || [];
|
||||
const owner = entity.authors?.[0]?.name
|
||||
|| entity.ownerV2?.data?.name
|
||||
|| entity.owner?.displayName
|
||||
|| entity.owner?.display_name
|
||||
|| (typeof entity.subtitle === 'string' ? entity.subtitle : '');
|
||||
const pl = await _spApi(`/playlists/${id}?fields=name,description,owner,images`, token);
|
||||
const owner = pl.owner?.display_name || '';
|
||||
const artUrl = _spArtwork(pl);
|
||||
const trackItems = await _spAllPlaylistTracks(id, token);
|
||||
return res.json({
|
||||
type: 'playlist',
|
||||
name: entity.name || entity.title || '',
|
||||
name: pl.name || '',
|
||||
artist: owner,
|
||||
artworkUrl: artUrl,
|
||||
description: entity.description || '',
|
||||
tracks: trackList.map(t => _spTrackItem(t, null, null, null)),
|
||||
description: pl.description || '',
|
||||
tracks: trackItems.map(t => _spTrackItem(t, null, null, null)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user