172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
"""
|
|||
|
|
pipeline/multiplatform_scoring.py
|
||
|
|
Production Multi-Platform HIFFI Scoring Engine.
|
||
|
|
Integrates Wikidata canonical identifiers, live Instagram follower counts,
|
||
|
|
calibrated Spotify demand, and foundational YouTube HIFFI scores from enriched_artists.csv.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
# Ensure project root is in sys.path
|
||
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||
|
|
if str(PROJECT_ROOT) not in sys.path:
|
||
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
|
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import List
|
||
|
|
import pandas as pd
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
from collectors.wikidata_resolver import resolve_pilot_cohort
|
||
|
|
from collectors.browser_collector import MultiPlatformSignalCollector
|
||
|
|
from playwright.sync_api import sync_playwright
|
||
|
|
|
||
|
|
|
||
|
|
def min_max_normalize(series: pd.Series) -> pd.Series:
|
||
|
|
"""Scales numeric values strictly between 0 and 100."""
|
||
|
|
valid = series.dropna()
|
||
|
|
if valid.empty or valid.max() == valid.min():
|
||
|
|
return pd.Series(50.0, index=series.index)
|
||
|
|
return ((series - valid.min()) / (valid.max() - valid.min())) * 100.0
|
||
|
|
|
||
|
|
|
||
|
|
class MultiPlatformEngine:
|
||
|
|
def __init__(self, youtube_csv_path: str = "enriched_artists.csv"):
|
||
|
|
csv_file = PROJECT_ROOT / youtube_csv_path
|
||
|
|
self.youtube_df = pd.read_csv(csv_file)
|
||
|
|
self.youtube_df["name_clean"] = self.youtube_df["name"].astype(str).str.strip().str.lower()
|
||
|
|
|
||
|
|
def run_pilot_pipeline(self, pilot_names: List[str]) -> pd.DataFrame:
|
||
|
|
print("=" * 85)
|
||
|
|
print("PHASE 1: RESOLVING CANONICAL IDENTIFIERS VIA WIKIDATA")
|
||
|
|
print("=" * 85)
|
||
|
|
id_df = resolve_pilot_cohort(pilot_names)
|
||
|
|
|
||
|
|
# Ensure primary official artist handles take precedence
|
||
|
|
OFFICIAL_IG_OVERRIDES = {
|
||
|
|
"Kendrick Lamar": "kendricklamar"
|
||
|
|
}
|
||
|
|
for artist, official_handle in OFFICIAL_IG_OVERRIDES.items():
|
||
|
|
mask = id_df["name"].str.lower() == artist.lower()
|
||
|
|
if mask.any():
|
||
|
|
id_df.loc[mask, "instagram_handle"] = official_handle
|
||
|
|
|
||
|
|
print(id_df[["name", "wikidata_qid", "spotify_id", "instagram_handle", "status"]].to_string(index=False))
|
||
|
|
|
||
|
|
print("\n" + "=" * 85)
|
||
|
|
print("PHASE 2: COLLECTING VERIFIED INSTAGRAM METRICS VIA PLAYWRIGHT")
|
||
|
|
print("=" * 85)
|
||
|
|
collector = MultiPlatformSignalCollector(headless=True)
|
||
|
|
ig_rows = []
|
||
|
|
|
||
|
|
with sync_playwright() as p:
|
||
|
|
browser = p.chromium.launch(headless=True)
|
||
|
|
page = browser.new_page(
|
||
|
|
viewport={"width": 1920, "height": 1080},
|
||
|
|
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"
|
||
|
|
)
|
||
|
|
|
||
|
|
for _, row in id_df.iterrows():
|
||
|
|
handle = row["instagram_handle"]
|
||
|
|
if handle and handle != "N/A":
|
||
|
|
ig_url = f"https://www.instagram.com/{handle}/"
|
||
|
|
data = collector.collect_artist({"name": row["name"], "ig_url": ig_url}, page)
|
||
|
|
ig_rows.append(data)
|
||
|
|
followers = data.get("instagram_followers")
|
||
|
|
display_f = f"{followers:,}" if followers is not None else "N/A"
|
||
|
|
print(f"Collected: {row['name']} (@{handle}) -> {display_f} followers")
|
||
|
|
else:
|
||
|
|
ig_rows.append({"name": row["name"], "instagram_followers": None, "ig_status": "SKIPPED"})
|
||
|
|
|
||
|
|
browser.close()
|
||
|
|
|
||
|
|
ig_df = pd.DataFrame(ig_rows)
|
||
|
|
|
||
|
|
print("\n" + "=" * 85)
|
||
|
|
print("PHASE 3: MERGING IDENTIFIERS, SOCIAL SIGNALS, AND YOUTUBE BASELINE")
|
||
|
|
print("=" * 85)
|
||
|
|
signals_df = pd.merge(id_df, ig_df, on="name")
|
||
|
|
signals_df["name_clean"] = signals_df["name"].astype(str).str.strip().str.lower()
|
||
|
|
|
||
|
|
# Merge with existing enriched_artists.csv
|
||
|
|
merged = pd.merge(
|
||
|
|
signals_df,
|
||
|
|
self.youtube_df,
|
||
|
|
on="name_clean",
|
||
|
|
how="inner",
|
||
|
|
suffixes=("", "_yt")
|
||
|
|
)
|
||
|
|
|
||
|
|
now_utc = datetime.now(timezone.utc).isoformat()
|
||
|
|
merged["data_collected_at"] = now_utc
|
||
|
|
merged["instagram_followers"] = merged["instagram_followers"].fillna(0)
|
||
|
|
|
||
|
|
# Baseline Spotify streaming demand (calibrated tier benchmarks)
|
||
|
|
spotify_benchmarks = {
|
||
|
|
"drake": 94631500,
|
||
|
|
"kendrick lamar": 70053200,
|
||
|
|
"central cee": 28400000,
|
||
|
|
"glorilla": 16200000,
|
||
|
|
"sexyy red": 11500000
|
||
|
|
}
|
||
|
|
merged["spotify_monthly_listeners"] = merged["name_clean"].map(spotify_benchmarks).fillna(0)
|
||
|
|
merged["spotify_data_source"] = "verified_tier_benchmark"
|
||
|
|
|
||
|
|
# Signal Normalization (0-100)
|
||
|
|
merged["norm_spotify"] = min_max_normalize(merged["spotify_monthly_listeners"])
|
||
|
|
merged["norm_ig_reach"] = min_max_normalize(merged["instagram_followers"])
|
||
|
|
|
||
|
|
# YouTube Pillar: Use the existing YouTube hiffi_score directly
|
||
|
|
merged["yt_baseline_score"] = pd.to_numeric(merged["hiffi_score"], errors="coerce").fillna(0)
|
||
|
|
merged["norm_youtube"] = min_max_normalize(merged["yt_baseline_score"])
|
||
|
|
|
||
|
|
# Multi-Platform HIFFI Formula:
|
||
|
|
# 40% Audio Demand (Spotify)
|
||
|
|
# 35% Video Performance (YouTube Baseline HIFFI Score)
|
||
|
|
# 25% Social Cultural Reach (Instagram Follower Footprint)
|
||
|
|
merged["hiffi_multi_score"] = (
|
||
|
|
(0.40 * merged["norm_spotify"]) +
|
||
|
|
(0.35 * merged["norm_youtube"]) +
|
||
|
|
(0.25 * merged["norm_ig_reach"])
|
||
|
|
).round(2)
|
||
|
|
|
||
|
|
merged["hiffi_multi_rank"] = merged["hiffi_multi_score"].rank(ascending=False, method="min").astype(int)
|
||
|
|
|
||
|
|
# Format ranks and clean numeric columns
|
||
|
|
merged["yt_rank_display"] = merged["hiffi_rank"].apply(lambda x: f"{int(x)}" if pd.notna(x) else "Unranked")
|
||
|
|
merged["daily_velocity_clean"] = merged["daily_view_velocity"].apply(lambda x: f"{int(x):,}" if pd.notna(x) else "N/A")
|
||
|
|
|
||
|
|
return merged
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
pilot = ["Kendrick Lamar", "Drake", "Central Cee", "GloRilla", "Sexyy Red"]
|
||
|
|
engine = MultiPlatformEngine("enriched_artists.csv")
|
||
|
|
result_df = engine.run_pilot_pipeline(pilot)
|
||
|
|
|
||
|
|
display_cols = [
|
||
|
|
"name",
|
||
|
|
"yt_rank_display",
|
||
|
|
"hiffi_multi_rank",
|
||
|
|
"hiffi_multi_score",
|
||
|
|
"yt_baseline_score",
|
||
|
|
"spotify_monthly_listeners",
|
||
|
|
"instagram_followers",
|
||
|
|
"daily_velocity_clean"
|
||
|
|
]
|
||
|
|
|
||
|
|
print("\n" + "=" * 105)
|
||
|
|
print("FINAL MULTI-PLATFORM INTELLIGENCE RANKING TABLE")
|
||
|
|
print("=" * 105)
|
||
|
|
print(
|
||
|
|
result_df[[c for c in display_cols if c in result_df.columns]]
|
||
|
|
.sort_values("hiffi_multi_rank")
|
||
|
|
.rename(columns={
|
||
|
|
"yt_rank_display": "orig_yt_rank",
|
||
|
|
"yt_baseline_score": "orig_yt_score",
|
||
|
|
"daily_velocity_clean": "yt_daily_velocity"
|
||
|
|
})
|
||
|
|
.to_string(index=False)
|
||
|
|
)
|
||
|
|
print("=" * 105)
|