Files
hiffi-artist-intelligence/collectors/sync_remaining_spotify.py
T
Srilekha 78a46a8dbe
HIFFI Multi-Platform CI / test (3.12) (push) Successful in 50s
HIFFI Multi-Platform CI / test (3.11) (push) Successful in 50s
feat: finalize root api.py with X-Auth-Secret auth security and clean pure sales cohort
2026-11-04 16:49:39 +05:30

138 lines
4.8 KiB
Python

"""
collectors/sync_remaining_spotify.py
Pulls verified monthly listener metrics for the 353 non-charting artists
via Spotify's public Embed & Partner JSON endpoints.
"""
import re
import time
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("SpotifyTailSync")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT_ROOT / "data" / "hiffi_catalog.db"
HTTP_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,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9"
}
def extract_spotify_id(url):
m = re.search(r"artist/([0-9A-Za-z]{22})", str(url))
return m.group(1) if m else None
def get_spotify_web_token(session):
"""Fetches an ephemeral anonymous web client bearer token directly from Spotify."""
try:
url = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player"
r = session.get(url, timeout=6)
if r.status_code == 200:
return r.json().get("accessToken")
except Exception:
pass
return None
def fetch_monthly_listeners(session, sp_id, token=None):
# Strategy 1: Public Embed JSON resource
embed_url = f"https://open.spotify.com/embed/artist/{sp_id}"
try:
r = session.get(embed_url, timeout=6)
if r.status_code == 200:
# Pattern: "monthlyListeners":123456
m = re.search(r'\"monthlyListeners\":\s*(\d+)', r.text)
if m:
return int(m.group(1))
# Pattern: "123,456 monthly listeners"
m2 = re.search(r'([\d,]+)\s+monthly listeners', r.text, re.IGNORECASE)
if m2:
return int(m2.group(1).replace(",", ""))
except Exception:
pass
# Strategy 2: Anonymous partner GraphQL / metadata endpoint
if token:
try:
api_url = f"https://api-partner.spotify.com/pathfinder/v1/query"
params = {
"operationName": "queryArtistOverview",
"variables": f'{{"uris":["spotify:artist:{sp_id}"]}}',
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"35ac4265af99890eac3ff8492314508c82a647970977821c4fa48956828551a3"}}'
}
h = dict(HTTP_HEADERS)
h["Authorization"] = f"Bearer {token}"
res = session.get(api_url, params=params, headers=h, timeout=6)
if res.status_code == 200:
data = res.json()
count = (data.get("data", {})
.get("artists", [{}])[0]
.get("stats", {})
.get("monthlyListeners"))
if count:
return int(count)
except Exception:
pass
return None
def run():
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# Artists who were not on Kworb's top 2,500 chart
cur.execute("""
SELECT a.artist_id, a.artist_name, a.spotify_url, t.spotify_monthly_listeners
FROM artists a
JOIN telemetry_metrics t ON a.artist_id = t.artist_id
WHERE a.image_verified_flag = 1
AND (t.spotify_daily_change = 0 OR t.spotify_daily_change IS NULL)
""")
unresolved = cur.fetchall()
logger.info(f"Targeting {len(unresolved)} remaining non-charting artists...")
session = requests.Session()
session.headers.update(HTTP_HEADERS)
token = get_spotify_web_token(session)
if token:
logger.info("Acquired anonymous client access token for Spotify web service.")
now_iso = datetime.now(timezone.utc).isoformat()
updated = 0
for idx, (artist_id, name, sp_url, current_val) in enumerate(unresolved, 1):
sp_id = extract_spotify_id(sp_url)
if not sp_id:
continue
listeners = fetch_monthly_listeners(session, sp_id, token)
if listeners and listeners > 0:
cur.execute("""
UPDATE telemetry_metrics
SET spotify_monthly_listeners = ?, last_synced_at = ?
WHERE artist_id = ?
""", (listeners, now_iso, artist_id))
updated += 1
logger.info(f"[{idx}/{len(unresolved)}] {name} -> {listeners:,} monthly listeners")
else:
logger.debug(f"[{idx}/{len(unresolved)}] Could not resolve {name}")
if idx % 25 == 0:
conn.commit()
time.sleep(0.2)
else:
time.sleep(0.08)
conn.commit()
conn.close()
logger.info(f"Tail sync finished. Updated {updated} / {len(unresolved)} artists.")
if __name__ == "__main__":
run()