2026-10-24 09:48:37 +05:30
|
|
|
"""
|
|
|
|
|
collectors/kworb_spotify_collector.py
|
2026-10-24 11:20:10 +05:30
|
|
|
Audited public streaming telemetry collector.
|
|
|
|
|
Extracts verified Spotify Monthly Listeners and Daily Change (+/-) from Kworb.net tables.
|
|
|
|
|
Enforces strict null governance (unlisted artists remain None/NaN).
|
2026-10-26 12:39:08 +05:30
|
|
|
Includes stylized hip-hop stage name normalization and alias resolution.
|
2026-10-24 09:48:37 +05:30
|
|
|
"""
|
|
|
|
|
|
2026-10-26 12:39:08 +05:30
|
|
|
import re
|
2026-10-24 11:20:10 +05:30
|
|
|
from typing import Dict, Optional, Tuple
|
2026-10-26 12:39:08 +05:30
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
|
import requests
|
2026-10-24 09:48:37 +05:30
|
|
|
|
|
|
|
|
KWORB_LISTENERS_URLS = [
|
|
|
|
|
"https://kworb.net/spotify/listeners.html",
|
|
|
|
|
"https://kworb.net/spotify/listeners2.html"
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KworbStreamingCollector:
|
2026-10-26 12:39:08 +05:30
|
|
|
# Curated canonical alias map for stylized hip-hop stage names
|
|
|
|
|
STAGE_ALIASES: Dict[str, str] = {
|
|
|
|
|
"a$ap rocky": "asap rocky",
|
|
|
|
|
"a$ap ferg": "asap ferg",
|
|
|
|
|
"a$ap mob": "asap mob",
|
|
|
|
|
"joey bada$$": "joey badass",
|
|
|
|
|
"6ix9ine": "sixnine",
|
|
|
|
|
"curren$y": "currensy",
|
|
|
|
|
"ty dolla $ign": "ty dolla sign",
|
|
|
|
|
"kool g rap": "kool g rap",
|
|
|
|
|
"t-pain": "tpain",
|
|
|
|
|
"jay-z": "jay z",
|
|
|
|
|
"e-40": "e40",
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 09:48:37 +05:30
|
|
|
def __init__(self):
|
2026-10-26 12:39:08 +05:30
|
|
|
# Maps normalized artist_name -> (monthly_listeners, daily_change)
|
2026-10-24 11:20:10 +05:30
|
|
|
self.cached_metrics: Dict[str, Tuple[int, Optional[int]]] = {}
|
2026-10-24 09:48:37 +05:30
|
|
|
self.is_loaded = False
|
|
|
|
|
|
2026-10-26 12:39:08 +05:30
|
|
|
@classmethod
|
|
|
|
|
def normalize_artist_name(cls, name: str) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Normalizes artist names by resolving currency symbols, stripping
|
|
|
|
|
extraneous punctuation, and mapping known stage aliases.
|
|
|
|
|
"""
|
|
|
|
|
if not name:
|
|
|
|
|
return ""
|
|
|
|
|
norm = name.strip().lower()
|
|
|
|
|
if norm in cls.STAGE_ALIASES:
|
|
|
|
|
norm = cls.STAGE_ALIASES[norm]
|
|
|
|
|
|
|
|
|
|
# Standardize common hip-hop typography substitutions
|
|
|
|
|
norm = norm.replace("$", "s").replace("&", "and")
|
|
|
|
|
norm = re.sub(r"[^\w\s]", "", norm)
|
|
|
|
|
return re.sub(r"\s+", " ", norm).strip()
|
|
|
|
|
|
2026-10-24 09:48:37 +05:30
|
|
|
def load_aggregator_tables(self, max_pages: int = 2):
|
2026-10-24 11:20:10 +05:30
|
|
|
"""Scrapes and indexes artist listener counts and daily velocity from public tables."""
|
2026-10-24 09:48:37 +05:30
|
|
|
print(f"[KworbCollector] Indexing public streaming metrics across {max_pages} tables...")
|
|
|
|
|
for url in KWORB_LISTENERS_URLS[:max_pages]:
|
|
|
|
|
try:
|
|
|
|
|
res = requests.get(url, headers=HEADERS, timeout=12)
|
|
|
|
|
if res.status_code == 200:
|
|
|
|
|
soup = BeautifulSoup(res.text, "html.parser")
|
|
|
|
|
rows = soup.find_all("tr")
|
|
|
|
|
for row in rows:
|
|
|
|
|
cols = row.find_all("td")
|
2026-10-24 11:20:10 +05:30
|
|
|
if len(cols) >= 4:
|
2026-10-26 12:39:08 +05:30
|
|
|
# Col 1: Raw Artist Name
|
|
|
|
|
raw_artist = cols[1].get_text(strip=True)
|
|
|
|
|
artist_norm = self.normalize_artist_name(raw_artist)
|
2026-10-24 11:20:10 +05:30
|
|
|
|
|
|
|
|
# Col 2: Monthly Listeners
|
2026-10-24 09:48:37 +05:30
|
|
|
raw_listeners = cols[2].get_text(strip=True).replace(",", "")
|
|
|
|
|
try:
|
|
|
|
|
listeners = int(raw_listeners)
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
2026-10-24 11:20:10 +05:30
|
|
|
|
|
|
|
|
# Col 3: Daily +/- Change
|
|
|
|
|
raw_daily = cols[3].get_text(strip=True).replace(",", "").replace("+", "")
|
|
|
|
|
try:
|
|
|
|
|
daily_change = int(raw_daily)
|
|
|
|
|
except ValueError:
|
|
|
|
|
daily_change = None
|
|
|
|
|
|
2026-10-26 12:39:08 +05:30
|
|
|
if artist_norm:
|
|
|
|
|
self.cached_metrics[artist_norm] = (listeners, daily_change)
|
2026-10-24 09:48:37 +05:30
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[KworbCollector] Error reading {url}: {e}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
self.is_loaded = True
|
2026-10-24 11:20:10 +05:30
|
|
|
print(f"[KworbCollector] Loaded {len(self.cached_metrics):,} artist telemetry records.")
|
2026-10-24 09:48:37 +05:30
|
|
|
|
2026-10-24 11:20:10 +05:30
|
|
|
def get_artist_metrics(self, artist_name: str) -> Tuple[Optional[int], Optional[int]]:
|
2026-10-26 12:39:08 +05:30
|
|
|
"""
|
|
|
|
|
Returns (monthly_listeners, daily_change) or (None, None).
|
|
|
|
|
Searches both the direct normalization and alias substitutions.
|
|
|
|
|
"""
|
2026-10-24 09:48:37 +05:30
|
|
|
if not self.is_loaded:
|
|
|
|
|
self.load_aggregator_tables(max_pages=2)
|
2026-10-26 12:39:08 +05:30
|
|
|
norm_key = self.normalize_artist_name(artist_name)
|
|
|
|
|
return self.cached_metrics.get(norm_key, (None, None))
|
2026-10-24 11:20:10 +05:30
|
|
|
|
|
|
|
|
def get_monthly_listeners(self, artist_name: str) -> Optional[int]:
|
|
|
|
|
return self.get_artist_metrics(artist_name)[0]
|
|
|
|
|
|
|
|
|
|
def get_daily_change(self, artist_name: str) -> Optional[int]:
|
|
|
|
|
return self.get_artist_metrics(artist_name)[1]
|
2026-10-24 09:48:37 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
collector = KworbStreamingCollector()
|
2026-10-24 11:20:10 +05:30
|
|
|
collector.load_aggregator_tables(max_pages=2)
|
2026-10-24 09:48:37 +05:30
|
|
|
test_cohort = [
|
2026-10-24 11:20:10 +05:30
|
|
|
"Drake", "Kendrick Lamar", "SZA", "Future",
|
2026-10-26 12:39:08 +05:30
|
|
|
"Cardi B", "A$AP Rocky", "Joey Bada$$", "6ix9ine", "Ty Dolla $ign"
|
2026-10-24 09:48:37 +05:30
|
|
|
]
|
2026-10-26 12:39:08 +05:30
|
|
|
print("\nValidating Kworb Stream & Daily Velocity Extraction with Aliases:")
|
2026-10-24 11:20:10 +05:30
|
|
|
print(f"{'Artist':20} | {'Monthly Listeners':>18} | {'Daily +/- Trend':>16}")
|
|
|
|
|
print("-" * 60)
|
2026-10-24 09:48:37 +05:30
|
|
|
for artist in test_cohort:
|
2026-10-24 11:20:10 +05:30
|
|
|
listeners, daily = collector.get_artist_metrics(artist)
|
|
|
|
|
l_str = f"{listeners:,}" if listeners is not None else "Not in Top 5000"
|
|
|
|
|
d_str = f"{daily:+,}" if daily is not None else "N/A"
|
|
|
|
|
print(f"{artist:20} | {l_str:>18} | {d_str:>16}")
|