fix: scrape Spotify OG tags as fallback for single track resolution

song_info returns GenericError for unauthenticated track lookups.
Fall back to scraping og:title/og:description/og:image from the public
Spotify track page — description format is "Song · Artist · Album · Year".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-13 01:48:37 +02:00
1 parent 4f52e7b883
commit c8f82ceba5
1 file changed
+44 -21
+44 -21
View File
@@ -4,7 +4,7 @@ Resolves Spotify URLs via the Spotify partner GraphQL API (api-partner.spotify.c
This is the same internal API the Spotify web player uses — separate rate-limit bucket This is the same internal API the Spotify web player uses — separate rate-limit bucket
from the public api.spotify.com/v1/ which gets throttled on server IPs. from the public api.spotify.com/v1/ which gets throttled on server IPs.
""" """
import sys, json, re, time import sys, json, re, time, html as _html, urllib.request
def eprint(*a): print(*a, file=sys.stderr) def eprint(*a): print(*a, file=sys.stderr)
@@ -65,34 +65,57 @@ def resolve(sp_type, sp_id):
if sp_type == 'track': if sp_type == 'track':
pub = sp_mod.Public() pub = sp_mod.Public()
# Try URI first, then full URL tr = None
raw = None # Try partner API first (works for some tracks)
for arg in [f'spotify:track:{sp_id}', url]: for arg in [f'spotify:track:{sp_id}', url]:
try: try:
r = pub.song_info(arg) r = pub.song_info(arg)
tu_type = ((r.get('data') or {}).get('trackUnion') or {}).get('__typename', '') tu_type = ((r.get('data') or {}).get('trackUnion') or {}).get('__typename', '')
if tu_type == 'GenericError': if tu_type == 'GenericError':
eprint(f'[spotapi] song_info({arg!r}) → GenericError, trying next') eprint(f'[spotapi] song_info({arg!r}) → GenericError')
continue continue
raw = r d = (r.get('data') or {})
break tu = d.get('trackUnion') or d
tr = _parse_track({'itemV2': {'data': {'trackUnion': tu}}})
if tr and tr.get('trackName'):
eprint(f'[spotapi] track resolved via song_info')
break
tr = None
except Exception as e: except Exception as e:
eprint(f'[spotapi] song_info({arg!r}) failed: {e}') eprint(f'[spotapi] song_info({arg!r}) failed: {e}')
if raw is None:
raise Exception(f'song_info returned GenericError for all inputs — track may require auth or be region-locked') # Fallback: scrape Open Graph tags from the public Spotify track page
eprint(f'[spotapi] track raw keys={list(raw.keys())}') if not tr:
d = (raw.get('data') or {}) if isinstance(raw, dict) else {} eprint(f'[spotapi] falling back to OG scrape for track {sp_id}')
tu = d.get('trackUnion') or d try:
tr = _parse_track({'itemV2': {'data': {'trackUnion': tu}}}) or { req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
'trackName': raw.get('name', ''), with urllib.request.urlopen(req, timeout=10) as resp:
'artistName': raw.get('artist', ''), page = resp.read().decode('utf-8', errors='replace')
'collectionName': raw.get('album', ''), og = {m.group(1): _html.unescape(m.group(2))
'artworkUrl100': raw.get('image', ''), for m in re.finditer(r'<meta\s+property="og:([^"]+)"\s+content="([^"]*)"', page)}
'releaseDate': raw.get('release_date', ''), # og:description format: "Song · Artist · Album · Year"
'trackNumber': raw.get('track_number'), desc_parts = [p.strip() for p in og.get('description', '').split('·')]
'trackExplicitness': 'notExplicit', track_name = og.get('title', '')
'durationMs': raw.get('duration_ms'), artist_name = desc_parts[1] if len(desc_parts) > 1 else ''
} album_name = desc_parts[2] if len(desc_parts) > 2 else ''
art_url = og.get('image', '')
eprint(f'[spotapi] OG scrape: name={track_name!r} artist={artist_name!r} album={album_name!r}')
if track_name:
tr = {
'trackName': track_name,
'artistName': artist_name,
'collectionName': album_name,
'artworkUrl100': art_url,
'releaseDate': '',
'trackNumber': None,
'trackExplicitness': 'notExplicit',
'durationMs': None,
}
except Exception as e:
eprint(f'[spotapi] OG scrape failed: {e}')
if not tr:
raise Exception(f'Track resolution failed: song_info returned GenericError and OG scrape found no title')
return {'type': 'track', 'name': tr.get('trackName', ''), return {'type': 'track', 'name': tr.get('trackName', ''),
'artist': tr.get('artistName', ''), 'artworkUrl': tr.get('artworkUrl100', ''), 'artist': tr.get('artistName', ''), 'artworkUrl': tr.get('artworkUrl100', ''),
'tracks': [tr]} 'tracks': [tr]}