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] };
|
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)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user