feat(pipeline): finalize audited multi-platform dataset with clean provenance and entity resolution

This commit is contained in:
Srilekha committed 2026-10-23 15:48:38 +05:30
1 parent ef0c0ca258
commit 4aa24d68c7
14 files changed
+2299 -169

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
"""
collectors/browser_collector.py
Multi-platform Signal Collector:
- Spotify: Direct static fetch via Kworb/Songstats mirror (instant, zero JavaScript required)
- Instagram: Headless Playwright meta-tag extraction
"""
import re
import requests
from typing import Dict, Any, Optional
from playwright.sync_api import sync_playwright, Page
def parse_shorthand_number(val_str: Optional[str]) -> Optional[int]:
"""Converts strings like '18.6M', '500K', '70,053,200' into standard integers."""
if not val_str:
return None
val_str = str(val_str).strip().upper().replace(",", "")
try:
if val_str.endswith("M"):
return int(float(val_str[:-1]) * 1_000_000)
elif val_str.endswith("K"):
return int(float(val_str[:-1]) * 1_000)
elif val_str.endswith("B"):
return int(float(val_str[:-1]) * 1_000_000_000)
return int(float(val_str))
except (ValueError, TypeError):
return None
class MultiPlatformSignalCollector:
def __init__(self, headless: bool = True):
self.headless = headless
self.session = requests.Session()
self.session.headers.update({
"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"
})
def fetch_spotify_listeners(self, spotify_url: str) -> Optional[int]:
"""
Fetches Spotify monthly listeners directly via public static mirror.
Bypasses client-side JavaScript, cookie walls, and 403 API gates.
"""
artist_id = spotify_url.split("?")[0].rstrip("/").split("/")[-1]
if not artist_id:
return None
# Strategy 1: Kworb static artist index
try:
url = f"https://kworb.net/spotify/artist/{artist_id}.html"
res = self.session.get(url, timeout=10)
if res.status_code == 200:
# Look for monthly listeners in the table/header
m = re.search(r"([\d,]+)\s+monthly listeners", res.text, re.IGNORECASE)
if m:
return parse_shorthand_number(m.group(1))
# Check table cells with large numbers
m2 = re.search(r"Monthly Listeners</td><td>([\d,]+)</td>", res.text, re.IGNORECASE)
if m2:
return parse_shorthand_number(m2.group(1))
except Exception:
pass
# Strategy 2: Direct public embed oEmbed query
try:
oembed_url = f"https://open.spotify.com/oembed?url=https://open.spotify.com/artist/{artist_id}"
res = self.session.get(oembed_url, timeout=10)
if res.status_code == 200:
pass # Verified artist exists
except Exception:
pass
return None
def fetch_instagram_followers(self, page: Page, ig_url: str) -> Optional[int]:
"""Extracts follower count via meta tags on Instagram."""
try:
page.goto(ig_url, timeout=25000, wait_until="domcontentloaded")
page.wait_for_timeout(2000)
content = page.content()
# Meta description check
meta_match = re.search(
r'content="([0-9.,KMBkmb]+)\s+Followers', content, re.IGNORECASE
)
if meta_match:
return parse_shorthand_number(meta_match.group(1))
# OpenGraph check
og_match = re.search(
r'property="og:description"\s+content="([0-9.,KMBkmb]+)\s+Followers',
content,
re.IGNORECASE,
)
if og_match:
return parse_shorthand_number(og_match.group(1))
# Rendered text fallback
body_text = page.inner_text("body")
m = re.search(r"([\d.,]+[KMBkmb]?)\s+followers", body_text, re.IGNORECASE)
if m:
return parse_shorthand_number(m.group(1))
except Exception:
pass
return None
def collect_artist(self, artist_info: Dict[str, str], page: Page) -> Dict[str, Any]:
"""Collects metrics for a single artist from both platforms."""
name = artist_info.get("name", "Unknown")
spotify_url = artist_info.get("spotify_url")
ig_url = artist_info.get("ig_url")
spotify_listeners = self.fetch_spotify_listeners(spotify_url) if spotify_url else None
ig_followers = self.fetch_instagram_followers(page, ig_url) if ig_url else None
return {
"name": name,
"spotify_monthly_listeners": spotify_listeners,
"instagram_followers": ig_followers,
"spotify_status": "SUCCESS" if spotify_listeners is not None else "FAILED",
"ig_status": "SUCCESS" if ig_followers is not None else "FAILED",
}
+81
View File
@@ -0,0 +1,81 @@
"""
collectors/instagram_collector.py
Pilot test to verify if public Instagram profile follower signals
can be collected via lightweight JSON/HTML parsing without credentials.
"""
import re
import json
import requests
from typing import Dict, Any
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-Language": "en-US,en;q=0.9",
}
class InstagramCollector:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(HEADERS)
def fetch_profile(self, artist_name: str, handle: str) -> Dict[str, Any]:
url = f"https://www.instagram.com/{handle}/"
try:
res = self.session.get(url, timeout=10)
if res.status_code == 404:
return {"artist_name": artist_name, "handle": handle, "status": "NOT_FOUND"}
# Instagram frequently returns 302 or redirects to login
if "login" in res.url or res.status_code == 429:
return {
"artist_name": artist_name,
"handle": handle,
"status": "BLOCKED",
"error": "Hit Instagram Login Wall / Rate limit"
}
text = res.text
# Try to grab meta description (e.g. "12M Followers, 50 Following, 100 Posts")
match = re.search(r'content="([0-9.,KMBkmb]+)\s+Followers,\s*([0-9.,KMBkmb]+)\s+Following,\s*([0-9.,]+)\s+Posts', text)
if match:
return {
"artist_name": artist_name,
"handle": handle,
"followers_raw": match.group(1),
"following_raw": match.group(2),
"posts_raw": match.group(3),
"status": "SUCCESS"
}
return {
"artist_name": artist_name,
"handle": handle,
"status": "FAILED",
"error": "Page loaded but follower meta string not present"
}
except Exception as e:
return {"artist_name": artist_name, "handle": handle, "status": "ERROR", "error": str(e)}
if __name__ == "__main__":
pilot = [
{"name": "Kendrick Lamar", "handle": "kendricklamar"},
{"name": "Drake", "handle": "champagnepapi"},
{"name": "Central Cee", "handle": "centralcee"},
{"name": "GloRilla", "handle": "glorillapimp"}
]
print("=" * 70)
print("TESTING INSTAGRAM SIGNAL AVAILABILITY (4 PILOT ARTISTS)")
print("=" * 70)
collector = InstagramCollector()
for item in pilot:
data = collector.fetch_profile(item["name"], item["handle"])
print(f"[{data['status']}] {data['artist_name']} (@{data['handle']}): {data.get('followers_raw', data.get('error'))}")
print("=" * 70)
+115
View File
@@ -0,0 +1,115 @@
"""
collectors/spotify_collector.py
Pulls verified Spotify artist followers and monthly listeners
using Spotify's public web client token.
Requires NO API keys, NO Premium subscription, and NO developer app.
"""
import re
import requests
from typing import Dict, Any, Optional
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"
)
class SpotifyPublicTokenCollector:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({"User-Agent": USER_AGENT})
self.bearer_token = self._get_anonymous_token()
def _get_anonymous_token(self) -> Optional[str]:
"""Fetches a temporary anonymous bearer token from Spotify's public endpoint."""
try:
res = self.session.get("https://open.spotify.com/get_access_token?reason=transport&productType=web_player", timeout=10)
if res.status_code == 200:
data = res.json()
return data.get("accessToken")
except Exception:
pass
return None
def fetch_artist(self, artist_name: str, spotify_id: str) -> Dict[str, Any]:
"""Fetches artist details via Spotify's internal web API."""
if not self.bearer_token:
self.bearer_token = self._get_anonymous_token()
if not self.bearer_token:
return {
"artist_name": artist_name,
"spotify_id": spotify_id,
"status": "FAILED",
"error": "Could not obtain public web token"
}
headers = {
"Authorization": f"Bearer {self.bearer_token}",
"User-Agent": USER_AGENT
}
# 1. Official endpoint using the client web token
api_url = f"https://api.spotify.com/v1/artists/{spotify_id}"
try:
res = self.session.get(api_url, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
followers = data.get("followers", {}).get("total", 0)
popularity = data.get("popularity", 0)
name = data.get("name", artist_name)
genres = data.get("genres", [])
return {
"artist_name": artist_name,
"matched_name": name,
"spotify_id": spotify_id,
"followers": followers,
"popularity": popularity,
"genres": ", ".join(genres[:4]),
"status": "SUCCESS"
}
else:
return {
"artist_name": artist_name,
"spotify_id": spotify_id,
"status": "FAILED",
"error": f"API returned status {res.status_code}: {res.text}"
}
except Exception as e:
return {
"artist_name": artist_name,
"spotify_id": spotify_id,
"status": "ERROR",
"error": str(e)
}
if __name__ == "__main__":
pilot_artists = [
{"name": "Kendrick Lamar", "spotify_id": "2YZyLoL8N0Wb9xBt1NhZWg"},
{"name": "Drake", "spotify_id": "3TVXtAsR1Inumwj472S9r4"},
{"name": "Central Cee", "spotify_id": "5H4ikTs84ffUwPVEMOzPt0"},
{"name": "GloRilla", "spotify_id": "2qoQgPAilUQnvEBEnfZQI8"},
]
print("=" * 70)
print("RUNNING ZERO-CREDENTIAL SPOTIFY WEB TOKEN COLLECTOR")
print("=" * 70)
collector = SpotifyPublicTokenCollector()
for item in pilot_artists:
res = collector.fetch_artist(item["name"], item["spotify_id"])
if res["status"] == "SUCCESS":
print(f"\n[OK] {res['artist_name']} (Matched: {res['matched_name']})")
print(f" Spotify ID: {res['spotify_id']}")
print(f" Followers: {res['followers']:,}")
print(f" Popularity: {res['popularity']}/100")
print(f" Genres: {res['genres'] or 'None listed'}")
else:
print(f"\n[FAIL] {item['name']}: {res.get('error')}")
print("\n" + "=" * 70)
+69
View File
@@ -0,0 +1,69 @@
"""
collectors/test_spotify_endpoints.py
Direct diagnostic to test Spotify OAuth2 Client Credentials flow
and probe endpoint permissions for Artist data, Top Tracks, and Search.
"""
import os
import requests
import base64
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "a4a008ec790d45fba6a37315a1a3d847")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "a5c67d9a63d340b6ae4a5bec83bca241")
def get_token(client_id: str, client_secret: str):
"""Requests a bearer token via standard OAuth2 Client Credentials."""
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
url = "https://accounts.spotify.com/api/token"
res = requests.post(
url,
data={"grant_type": "client_credentials"},
headers={"Authorization": f"Basic {auth_header}"},
timeout=10,
)
return res.status_code, res.json()
def test_endpoints(token: str):
headers = {"Authorization": f"Bearer {token}"}
pilot_artist_id = "3TVXtAsR1Inumwj472S9r4" # Drake
endpoints = {
"Artist Metadata (GET /v1/artists/{id})": f"https://api.spotify.com/v1/artists/{pilot_artist_id}",
"Artist Top Tracks (GET /v1/artists/{id}/top-tracks)": f"https://api.spotify.com/v1/artists/{pilot_artist_id}/top-tracks?market=US",
"Search Endpoint (GET /v1/search)": "https://api.spotify.com/v1/search?q=Drake&type=artist&limit=1",
}
results = {}
for name, url in endpoints.items():
res = requests.get(url, headers=headers, timeout=10)
results[name] = {
"status_code": res.status_code,
"response_preview": res.text[:300],
}
return results
if __name__ == "__main__":
print("=" * 80)
print("SPOTIFY API AUTHENTICATION & ENDPOINT PERMISSION DIAGNOSTIC")
print("=" * 80)
status, token_resp = get_token(CLIENT_ID, CLIENT_SECRET)
print(f"Token Endpoint Status: {status}")
if status == 200:
token = token_resp.get("access_token")
print(f"Bearer Token Acquired: {token[:15]}... [expires_in: {token_resp.get('expires_in')}s]")
print("-" * 80)
diagnostics = test_endpoints(token)
for ep, out in diagnostics.items():
print(f"\nEndpoint: {ep}")
print(f"Status: {out['status_code']}")
print(f"Payload: {out['response_preview']}")
else:
print(f"Failed to obtain token: {token_resp}")
print("\n" + "=" * 80)
+113
View File
@@ -0,0 +1,113 @@
"""
collectors/wikidata_resolver.py
Directly resolves canonical Wikidata Q-IDs, official Spotify Artist IDs (P1902),
and Instagram usernames (P2003) via Wikidata's public entity API.
"""
import requests
import pandas as pd
from typing import List, Dict, Any
WIKIDATA_SEARCH_URL = "https://www.wikidata.org/w/api.php"
HEADERS = {
"User-Agent": "HIFFIArtistIntelligence/1.0 (sri@superlabs.ai) python-requests"
}
def search_wikidata_qid(artist_name: str) -> str:
"""Finds the most relevant Wikidata Q-identifier for a music artist."""
params = {
"action": "wbsearchentities",
"format": "json",
"language": "en",
"type": "item",
"search": artist_name,
"limit": 5,
}
try:
res = requests.get(WIKIDATA_SEARCH_URL, params=params, headers=HEADERS, timeout=10)
if res.status_code == 200:
data = res.json().get("search", [])
for item in data:
desc = item.get("description", "").lower()
if any(k in desc for k in ["rapper", "singer", "musician", "artist", "record producer", "songwriter", "drill"]):
return item.get("id")
if data:
return data[0].get("id")
except Exception:
pass
return ""
def get_identifiers_for_qid(qid: str) -> Dict[str, str]:
"""
Extracts P1902 (Spotify ID) and P2003 (Instagram username) directly
from the Wikidata item JSON claim tree.
"""
if not qid:
return {"spotify_id": "N/A", "instagram_handle": "N/A"}
url = f"https://www.wikidata.org/wiki/Special:EntityData/{qid}.json"
try:
res = requests.get(url, headers=HEADERS, timeout=10)
if res.status_code == 200:
entities = res.json().get("entities", {})
entity = entities.get(qid, {})
claims = entity.get("claims", {})
# P1902: Spotify artist ID
spotify_id = "N/A"
if "P1902" in claims:
for statement in claims["P1902"]:
mainsnak = statement.get("mainsnak", {})
datavalue = mainsnak.get("datavalue", {})
val = datavalue.get("value")
if val and isinstance(val, str):
spotify_id = val
break
# P2003: Instagram username
instagram_handle = "N/A"
if "P2003" in claims:
for statement in claims["P2003"]:
mainsnak = statement.get("mainsnak", {})
datavalue = mainsnak.get("datavalue", {})
val = datavalue.get("value")
if val and isinstance(val, str):
instagram_handle = val
break
return {
"spotify_id": spotify_id,
"instagram_handle": instagram_handle
}
except Exception:
pass
return {"spotify_id": "N/A", "instagram_handle": "N/A"}
def resolve_pilot_cohort(artist_names: List[str]) -> pd.DataFrame:
rows = []
for name in artist_names:
qid = search_wikidata_qid(name)
if qid:
ids = get_identifiers_for_qid(qid)
rows.append({
"name": name,
"wikidata_qid": qid,
"wikidata_uri": f"https://www.wikidata.org/wiki/{qid}",
"spotify_id": ids["spotify_id"],
"instagram_handle": ids["instagram_handle"],
"status": "RESOLVED"
})
else:
rows.append({
"name": name,
"wikidata_qid": "N/A",
"wikidata_uri": "N/A",
"spotify_id": "N/A",
"instagram_handle": "N/A",
"status": "NOT_FOUND"
})
return pd.DataFrame(rows)