""" scripts/fast_sales_ingestion_17k.py High-throughput multithreaded commercial sales ingestion. Uses ThreadPoolExecutor (8-10 workers) to ingest 2,500-3,500 artists per hour. Safe SQLite transactions via threading Lock. """ import sqlite3 import json import time import re import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed import threading DB_PATH = "data/hiffi_catalog.db" WIKI_API_URL = "https://en.wikipedia.org/w/api.php" HEADERS = {"User-Agent": "HIFFI-FastScraper/1.0 (sri.lekha@superlabs.co)"} MAX_WORKERS = 8 db_lock = threading.Lock() def clean_artist_query(name: str) -> str: clean = re.sub(r'[,$\'\"]', ' ', name) return re.sub(r'\s+', ' ', clean).strip() def search_best_discography_title(artist_name: str) -> str: clean_name = clean_artist_query(artist_name) queries = [ f"{artist_name} albums discography", f"{clean_name} discography", f"{artist_name} discography", artist_name ] for q in queries: params = { "action": "opensearch", "search": q, "limit": "4", "namespace": "0", "format": "json" } url = f"{WIKI_API_URL}?{urllib.parse.urlencode(params)}" try: req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req, timeout=5) as resp: data = json.loads(resp.read().decode("utf-8")) titles = data[1] if len(data) > 1 else [] for t in titles: if "albums discography" in t.lower(): return t for t in titles: if "discography" in t.lower(): return t if titles and (q == artist_name or q == clean_name): return titles[0] except Exception: continue return f"{artist_name} discography" def fetch_wiki_content(exact_title: str) -> str: params = { "action": "query", "prop": "revisions", "rvprop": "content", "rvslots": "main", "titles": exact_title, "format": "json", "redirects": "1" } url = f"{WIKI_API_URL}?{urllib.parse.urlencode(params)}" req = urllib.request.Request(url, headers=HEADERS) try: with urllib.request.urlopen(req, timeout=6) as resp: data = json.loads(resp.read().decode("utf-8")) pages = data.get("query", {}).get("pages", {}) for page_id, page_data in pages.items(): if page_id != "-1": slots = page_data.get("revisions", [{}])[0].get("slots", {}) return slots.get("main", {}).get("*", "") except Exception: pass return "" def clean_wiki_markup(text: str) -> str: text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*/>', '', text, flags=re.IGNORECASE) text = re.sub(r'\[\[(?:[^|\]]*\|)?([^\]]+)\]\]', r'\1', text) return text def parse_studio_sales(wikitext: str): clean_text = clean_wiki_markup(wikitext) match = re.search( r'={2,4}\s*(?:Studio albums|Commercial mixtapes|Albums)\s*={2,4}.*?(?===={2,4}|\Z)', clean_text, re.DOTALL | re.IGNORECASE ) album_section = match.group(0) if match else clean_text pure_album_sales = 0.0 awards_count = 0 sales_matches = re.findall( r'(?:US:\s*|sales:\s*|US sales:\s*)([0-9]{1,3}(?:,[0-9]{3})+)', album_section, re.IGNORECASE ) seen_sales = set() for s in sales_matches: num = float(s.replace(",", "")) if 40_000 <= num <= 25_000_000 and num not in seen_sales: pure_album_sales += num seen_sales.add(num) multi_plat = re.findall(r'RIAA[:\s|=]*(\d+)[\xd7x\s]*Platinum', album_section, re.IGNORECASE) for m in multi_plat: mult = float(m) pure_album_sales += mult * 1_000_000 awards_count += int(mult) cleaned_multi = re.sub(r'\d+[\xd7x\s]*Platinum', '', album_section, flags=re.IGNORECASE) single_plat = len(re.findall(r'RIAA[:\s|=]*Platinum', cleaned_multi, re.IGNORECASE)) pure_album_sales += single_plat * 1_000_000 awards_count += single_plat gold = len(re.findall(r'RIAA[:\s|=]*Gold', album_section, re.IGNORECASE)) pure_album_sales += gold * 500_000 awards_count += gold if pure_album_sales == 0.0 and awards_count == 0: general_multi = re.findall(r'(\d+)[\xd7x]\s*Platinum', album_section, re.IGNORECASE) for m in general_multi[:10]: mult = float(m) pure_album_sales += mult * 1_000_000 awards_count += int(mult) return pure_album_sales, awards_count def process_single_artist(artist_row): aid = artist_row["artist_id"] name = artist_row["artist_name"] title = search_best_discography_title(name) wikitext = fetch_wiki_content(title) if title else "" if wikitext: sales_units, awards = parse_studio_sales(wikitext) prov = "WIKI_DISCOGRAPHY_AUDIT" if sales_units > 0 else "UNAUDITED" else: sales_units, awards = 0.0, 0 prov = "UNAUDITED" return (aid, sales_units, awards, prov, 99999, sales_units, 0.0, 0.0, 0.0) def run_fast_ingestion(limit=3000): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.cursor() rows = cur.execute(""" SELECT a.artist_id, a.artist_name FROM artists a LEFT JOIN commercial_sales cs ON a.artist_id = cs.artist_id WHERE cs.sales_id IS NULL LIMIT ? """, (limit,)).fetchall() total = len(rows) if total == 0: print("✓ All catalog artists already ingested.") conn.close() return print(f"🚀 Starting high-speed ingestion for {total} artists with {MAX_WORKERS} concurrent threads...") start_time = time.time() insert_sql = """ INSERT INTO commercial_sales ( artist_id, audited_sales_units, awards_count, provenance_flag, sales_rank, pure_album_sales, physical_singles_sales, digital_singles_sales, streaming_eas ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """ completed = 0 buffer = [] with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: futures = {executor.submit(process_single_artist, dict(r)): r for r in rows} for future in as_completed(futures): try: record = future.result() buffer.append(record) completed += 1 if len(buffer) >= 50: with db_lock: cur.executemany(insert_sql, buffer) conn.commit() elapsed = time.time() - start_time rate = round(completed / elapsed, 1) print(f" ⚡ Ingested {completed}/{total} | {rate} artists/sec | Elapsed: {int(elapsed)}s") buffer.clear() except Exception as e: pass if buffer: with db_lock: cur.executemany(insert_sql, buffer) conn.commit() conn.close() total_time = round(time.time() - start_time, 1) print(f"✓ Fast batch complete: {completed} artists processed in {total_time}s.") if __name__ == "__main__": import sys count = int(sys.argv[1]) if len(sys.argv) > 1 else 3000 run_fast_ingestion(count)