fix: OG scrape fallback — parse JSON-LD first, fix artist field index

JSON-LD byArtist gives the correct artist list. As secondary fallback,
OG desc format is 'Artist · Song · Year' not 'Song · Artist · Album',
so use desc_parts[0] not desc_parts[1] for artist.
Also handle both attribute orderings in meta tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-23 21:27:54 +02:00
1 parent d785a4c09a
commit 98ad8ed579
1 file changed
+35 -9
+35 -9
View File
@@ -84,22 +84,48 @@ def resolve(sp_type, sp_id):
except Exception as e:
eprint(f'[spotapi] song_info({arg!r}) failed: {e}')
# Fallback: scrape Open Graph tags from the public Spotify track page
# Fallback: scrape the public Spotify track page
if not tr:
eprint(f'[spotapi] falling back to OG scrape for track {sp_id}')
eprint(f'[spotapi] falling back to page scrape for track {sp_id}')
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=10) as resp:
page = resp.read().decode('utf-8', errors='replace')
og = {m.group(1): _html.unescape(m.group(2))
for m in re.finditer(r'<meta\s+property="og:([^"]+)"\s+content="([^"]*)"', page)}
# og:description format: "Song · Artist · Album · Year"
track_name, artist_name, album_name, art_url = '', '', '', ''
# 1) JSON-LD structured data — most reliable, always has byArtist
ld_m = re.search(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', page, re.DOTALL | re.IGNORECASE)
if ld_m:
try:
ld = json.loads(ld_m.group(1))
track_name = ld.get('name', '')
by_artist = ld.get('byArtist', [])
if isinstance(by_artist, list):
artist_name = ', '.join(a.get('name', '') for a in by_artist if a.get('name'))
elif isinstance(by_artist, dict):
artist_name = by_artist.get('name', '')
in_album = ld.get('inAlbum') or {}
album_name = in_album.get('name', '') if isinstance(in_album, dict) else ''
eprint(f'[spotapi] JSON-LD: name={track_name!r} artist={artist_name!r} album={album_name!r}')
except Exception as e:
eprint(f'[spotapi] JSON-LD parse failed: {e}')
# 2) OG tags — artist is desc_parts[0], then type ("Song"), then year
if not track_name:
og = {}
for m in re.finditer(r'<meta\s+(?:property|name)=["\']og:([^"\']+)["\']\s+content=["\']([^"\']*)["\']', page):
og[m.group(1)] = _html.unescape(m.group(2))
for m in re.finditer(r'<meta\s+content=["\']([^"\']*)["\'][^>]+(?:property|name)=["\']og:([^"\']+)["\']', page):
og[m.group(2)] = _html.unescape(m.group(1))
desc_parts = [p.strip() for p in og.get('description', '').split('·')]
track_name = og.get('title', '')
artist_name = desc_parts[1] if len(desc_parts) > 1 else ''
album_name = desc_parts[2] if len(desc_parts) > 2 else ''
# Real format: "Artist · Song · Year · N songs." — artist is index 0
artist_name = desc_parts[0] if desc_parts else ''
album_name = ''
art_url = og.get('image', '')
eprint(f'[spotapi] OG scrape: name={track_name!r} artist={artist_name!r} album={album_name!r}')
eprint(f'[spotapi] OG scrape: name={track_name!r} artist={artist_name!r}')
if track_name:
tr = {
'trackName': track_name,
@@ -112,7 +138,7 @@ def resolve(sp_type, sp_id):
'durationMs': None,
}
except Exception as e:
eprint(f'[spotapi] OG scrape failed: {e}')
eprint(f'[spotapi] page scrape failed: {e}')
if not tr:
raise Exception(f'Track resolution failed: song_info returned GenericError and OG scrape found no title')