checkpoint: working 10-artist batch with log-normalized ranking
This commit is contained in:
1 parent
332331e7ef
commit
5d441ffe7c
10 files changed
+35107
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,192 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from urllib.parse import urlparse
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError:
|
||||
build = None
|
||||
|
||||
class YouTubeCollector:
|
||||
def __init__(self, api_keys: list[str] = None, cache_db: str = ".cache/youtube_metrics.sqlite"):
|
||||
self.api_keys = [k.strip() for k in (api_keys or []) if k.strip()]
|
||||
self.cache_db = Path(cache_db)
|
||||
self.cache_db.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
self.client = None
|
||||
if self.api_keys and build:
|
||||
try:
|
||||
self.client = build("youtube", "v3", developerKey=self.api_keys[0], cache_discovery=False)
|
||||
except Exception as e:
|
||||
print(f"[Warning] Failed to initialize YouTube API client: {e}")
|
||||
|
||||
def _init_db(self):
|
||||
with sqlite3.connect(self.cache_db) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS channel_metrics (
|
||||
channel_id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
subscribers INTEGER,
|
||||
lifetime_views INTEGER,
|
||||
video_count INTEGER,
|
||||
recent_avg_views INTEGER,
|
||||
momentum_30d REAL,
|
||||
raw_json TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
def _get_cached(self, channel_id: str) -> Optional[Dict[str, Any]]:
|
||||
with sqlite3.connect(self.cache_db) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute("SELECT * FROM channel_metrics WHERE channel_id = ?", (channel_id,)).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
def _save_cache(self, metrics: Dict[str, Any]):
|
||||
with sqlite3.connect(self.cache_db) as conn:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO channel_metrics (
|
||||
channel_id, title, subscribers, lifetime_views, video_count,
|
||||
recent_avg_views, momentum_30d, raw_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
metrics["channel_id"],
|
||||
metrics["title"],
|
||||
metrics["subscribers"],
|
||||
metrics["lifetime_views"],
|
||||
metrics["video_count"],
|
||||
metrics["recent_avg_views"],
|
||||
metrics["momentum_30d"],
|
||||
json.dumps(metrics),
|
||||
datetime.now(timezone.utc).isoformat()
|
||||
))
|
||||
|
||||
def extract_channel_id(self, url: str) -> str:
|
||||
"""Extract UC... channel ID from standard YouTube URL."""
|
||||
if not url:
|
||||
return ""
|
||||
match = re.search(r"channel/(UC[a-zA-Z0-9_-]{22})", url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return url.strip().rstrip("/").split("/")[-1]
|
||||
|
||||
def get_channel_metrics(self, youtube_url: str, fetch_recent_videos: bool = False) -> Dict[str, Any]:
|
||||
channel_id = self.extract_channel_id(youtube_url)
|
||||
empty = {
|
||||
"channel_id": channel_id,
|
||||
"title": "",
|
||||
"subscribers": 0,
|
||||
"lifetime_views": 0,
|
||||
"video_count": 0,
|
||||
"recent_avg_views": 0,
|
||||
"momentum_30d": 0.0,
|
||||
"status": "unresolved"
|
||||
}
|
||||
if not channel_id:
|
||||
empty["status"] = "empty_url"
|
||||
return empty
|
||||
|
||||
# 1. Check local cache first (saves API quota)
|
||||
cached = self._get_cached(channel_id)
|
||||
if cached:
|
||||
cached["status"] = "cached"
|
||||
return cached
|
||||
|
||||
# 2. If client is not available (zero API keys), return baseline
|
||||
if not self.client:
|
||||
empty["status"] = "no_api_key"
|
||||
return empty
|
||||
|
||||
try:
|
||||
# 3. Call channels.list for statistics and contentDetails
|
||||
resp = self.client.channels().list(
|
||||
part="snippet,statistics,contentDetails",
|
||||
id=channel_id
|
||||
).execute()
|
||||
|
||||
items = resp.get("items") or []
|
||||
if not items:
|
||||
empty["status"] = "not_found"
|
||||
return empty
|
||||
|
||||
item = items[0]
|
||||
snippet = item.get("snippet", {})
|
||||
stats = item.get("statistics", {})
|
||||
content = item.get("contentDetails", {})
|
||||
|
||||
metrics = {
|
||||
"channel_id": channel_id,
|
||||
"title": snippet.get("title", ""),
|
||||
"subscribers": int(stats.get("subscriberCount") or 0),
|
||||
"lifetime_views": int(stats.get("viewCount") or 0),
|
||||
"video_count": int(stats.get("videoCount") or 0),
|
||||
"recent_avg_views": 0,
|
||||
"momentum_30d": 0.0,
|
||||
"status": "live_api"
|
||||
}
|
||||
|
||||
# 4. Optional: fetch recent video metrics for average views & momentum
|
||||
uploads_id = content.get("relatedPlaylists", {}).get("uploads")
|
||||
if fetch_recent_videos and uploads_id:
|
||||
recent_views, momentum = self._calc_recent_metrics(uploads_id, metrics["lifetime_views"])
|
||||
metrics["recent_avg_views"] = recent_views
|
||||
metrics["momentum_30d"] = momentum
|
||||
|
||||
self._save_cache(metrics)
|
||||
return metrics
|
||||
|
||||
except Exception as e:
|
||||
empty["status"] = f"error: {str(e)[:100]}"
|
||||
return empty
|
||||
|
||||
def _calc_recent_metrics(self, uploads_playlist_id: str, lifetime_views: int) -> tuple[int, float]:
|
||||
"""Fetch up to 10 latest uploads to compute average views and 30d momentum."""
|
||||
try:
|
||||
playlist_resp = self.client.playlistItems().list(
|
||||
part="contentDetails",
|
||||
playlistId=uploads_playlist_id,
|
||||
maxResults=10
|
||||
).execute()
|
||||
|
||||
video_ids = [i["contentDetails"]["videoId"] for i in playlist_resp.get("items", [])]
|
||||
if not video_ids:
|
||||
return 0, 0.0
|
||||
|
||||
video_resp = self.client.videos().list(
|
||||
part="statistics,snippet",
|
||||
id=",".join(video_ids)
|
||||
).execute()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
views = []
|
||||
views_30d = 0
|
||||
|
||||
for v in video_resp.get("items", []):
|
||||
v_views = int(v.get("statistics", {}).get("viewCount") or 0)
|
||||
views.append(v_views)
|
||||
pub_date_str = v.get("snippet", {}).get("publishedAt", "")
|
||||
if pub_date_str:
|
||||
pub_date = datetime.fromisoformat(pub_date_str.replace("Z", "+00:00"))
|
||||
if pub_date >= thirty_days_ago:
|
||||
views_30d += v_views
|
||||
|
||||
avg_views = int(sum(views) / len(views)) if views else 0
|
||||
momentum = round((views_30d / lifetime_views) * 100, 3) if lifetime_views > 0 else 0.0
|
||||
return avg_views, momentum
|
||||
except Exception:
|
||||
return 0, 0.0
|
||||
|
||||
if __name__ == "__main__":
|
||||
collector = YouTubeCollector()
|
||||
test_res = collector.get_channel_metrics("https://www.youtube.com/channel/UCBOxSirnH1-_-xKtqX051lg")
|
||||
print("Test run result:")
|
||||
print(test_res)
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Dict, Any
|
||||
|
||||
class ConfidenceScorer:
|
||||
"""
|
||||
Computes a weighted authenticity and quality score across multiple data sources:
|
||||
- YouTube footprint: 40%
|
||||
- MusicBrainz catalog: 35%
|
||||
- Wikidata entity: 25%
|
||||
"""
|
||||
def __init__(self, yt_weight: float = 0.40, mb_weight: float = 0.35, wd_weight: float = 0.25):
|
||||
self.w_yt = yt_weight
|
||||
self.w_mb = mb_weight
|
||||
self.w_wd = wd_weight
|
||||
|
||||
def calculate_score(
|
||||
self,
|
||||
yt_metrics: Dict[str, Any],
|
||||
mb_result: Dict[str, Any],
|
||||
wd_result: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
# 1. YouTube sub-score (0.0 to 1.0)
|
||||
yt_score = 0.0
|
||||
if yt_metrics.get("status") in ["live_api", "cached"]:
|
||||
subs = yt_metrics.get("subscribers", 0)
|
||||
views = yt_metrics.get("lifetime_views", 0)
|
||||
vids = yt_metrics.get("video_count", 0)
|
||||
|
||||
# Baseline 0.5 for a verified channel existence
|
||||
yt_score = 0.5
|
||||
if subs >= 1000:
|
||||
yt_score += 0.2
|
||||
elif subs > 0:
|
||||
yt_score += 0.1
|
||||
|
||||
if views >= 50000:
|
||||
yt_score += 0.2
|
||||
elif views > 0:
|
||||
yt_score += 0.1
|
||||
|
||||
if vids >= 5:
|
||||
yt_score += 0.1
|
||||
|
||||
# 2. MusicBrainz sub-score
|
||||
mb_score = mb_result.get("confidence", 0.0) if mb_result.get("verified") else 0.0
|
||||
|
||||
# 3. Wikidata sub-score
|
||||
wd_score = wd_result.get("confidence", 0.0) if wd_result.get("verified") else 0.0
|
||||
|
||||
# Aggregated composite score
|
||||
composite = (yt_score * self.w_yt) + (mb_score * self.w_mb) + (wd_score * self.w_wd)
|
||||
composite = round(min(1.0, max(0.0, composite)), 2)
|
||||
|
||||
# Confidence category
|
||||
if composite >= 0.75:
|
||||
tier = "High"
|
||||
elif composite >= 0.45:
|
||||
tier = "Medium"
|
||||
else:
|
||||
tier = "Low / Emerging"
|
||||
|
||||
return {
|
||||
"composite_confidence": composite,
|
||||
"confidence_tier": tier,
|
||||
"sub_scores": {
|
||||
"youtube": round(yt_score, 2),
|
||||
"musicbrainz": round(mb_score, 2),
|
||||
"wikidata": round(wd_score, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
scorer = ConfidenceScorer()
|
||||
dummy_yt = {"status": "live_api", "subscribers": 5000, "lifetime_views": 250000, "video_count": 18}
|
||||
dummy_mb = {"verified": True, "confidence": 1.0}
|
||||
dummy_wd = {"verified": True, "confidence": 1.0}
|
||||
print("Test Scorer Output:")
|
||||
print(scorer.calculate_score(dummy_yt, dummy_mb, dummy_wd))
|
||||
@@ -0,0 +1,59 @@
|
||||
import re
|
||||
import pandas as pd
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def clean_url(url: str) -> str:
|
||||
"""Normalize URLs by stripping query params, trailing slashes, and spaces."""
|
||||
if not isinstance(url, str) or not url.strip() or url.strip().lower() in ["nan", "none", ""]:
|
||||
return ""
|
||||
url = url.strip().rstrip("/")
|
||||
if not re.match(r"^https?://", url, re.I):
|
||||
url = "https://" + url
|
||||
parsed = urlparse(url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/")
|
||||
|
||||
def extract_channel_key(yt_url: str) -> str:
|
||||
"""Extract unique channel ID or handle to catch cross-name channel duplicates."""
|
||||
clean = clean_url(yt_url).lower()
|
||||
if not clean:
|
||||
return ""
|
||||
parts = [p for p in urlparse(clean).path.split("/") if p]
|
||||
if not parts:
|
||||
return ""
|
||||
return parts[-1]
|
||||
|
||||
def deduplicate_inventory(input_path: str = "artist_inventory_artists.csv") -> pd.DataFrame:
|
||||
print(f"Loading {input_path}...")
|
||||
df = pd.read_csv(input_path)
|
||||
initial_count = len(df)
|
||||
print(f"Initial raw rows: {initial_count}")
|
||||
|
||||
# 1. Clean strings and URLs
|
||||
for col in ["Name", "Location"]:
|
||||
df[col] = df[col].fillna("").astype(str).str.strip()
|
||||
|
||||
for col in ["Instagram", "Youtube", "TikTok", "Facebook"]:
|
||||
df[col] = df[col].apply(clean_url)
|
||||
|
||||
# 2. Generate normalized match keys
|
||||
df["clean_name"] = df["Name"].str.lower().str.replace(r"[^\w\s]", "", regex=True).str.strip()
|
||||
df["yt_key"] = df["Youtube"].apply(extract_channel_key)
|
||||
|
||||
# 3. Drop exact duplicates on Name + YouTube
|
||||
df_dedup = df.drop_duplicates(subset=["clean_name", "yt_key"], keep="first").copy()
|
||||
|
||||
# 4. Check for duplicate YouTube channels across different names
|
||||
yt_conflicts = df_dedup[df_dedup["yt_key"] != ""].duplicated(subset=["yt_key"], keep=False).sum()
|
||||
print(f"Rows with shared YouTube channels across different names: {yt_conflicts}")
|
||||
|
||||
dropped = initial_count - len(df_dedup)
|
||||
print(f"Deduplicated count: {len(df_dedup)} (Dropped {dropped} duplicate rows)")
|
||||
|
||||
# Clean temporary helper columns before returning
|
||||
df_dedup.drop(columns=["clean_name", "yt_key"], inplace=True)
|
||||
return df_dedup
|
||||
|
||||
if __name__ == "__main__":
|
||||
cleaned_df = deduplicate_inventory()
|
||||
cleaned_df.to_csv("cleaned_artists_inventory.csv", index=False)
|
||||
print("Saved cleaned data to cleaned_artists_inventory.csv")
|
||||
@@ -0,0 +1,83 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Dict, Any
|
||||
|
||||
class RankingEngine:
|
||||
"""
|
||||
Computes equitable artist rankings using Log Transformation (log1p)
|
||||
and Min-Max Scaling across skewed social/streaming distribution metrics.
|
||||
"""
|
||||
DEFAULT_WEIGHTS = {
|
||||
"norm_subscribers": 0.30,
|
||||
"norm_lifetime_views": 0.25,
|
||||
"norm_recent_avg_views": 0.25,
|
||||
"norm_video_count": 0.10,
|
||||
"norm_momentum_30d": 0.10
|
||||
}
|
||||
|
||||
def __init__(self, weights: Dict[str, float] = None):
|
||||
self.weights = weights or self.DEFAULT_WEIGHTS
|
||||
|
||||
def normalize_metrics(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Applies log10(1 + x) and Min-Max scaling to compress power-law tails."""
|
||||
df_scored = df.copy()
|
||||
|
||||
metrics_to_transform = [
|
||||
("yt_subscribers", "norm_subscribers"),
|
||||
("yt_lifetime_views", "norm_lifetime_views"),
|
||||
("yt_video_count", "norm_video_count"),
|
||||
("yt_recent_avg_views", "norm_recent_avg_views"),
|
||||
("yt_momentum_30d", "norm_momentum_30d")
|
||||
]
|
||||
|
||||
for raw_col, norm_col in metrics_to_transform:
|
||||
if raw_col not in df_scored.columns:
|
||||
df_scored[norm_col] = 0.0
|
||||
continue
|
||||
|
||||
# Step 1: Handle NaNs and negative values, then apply Log10(1 + x)
|
||||
series = pd.to_numeric(df_scored[raw_col], errors="coerce").fillna(0).clip(lower=0)
|
||||
log_series = np.log10(1 + series)
|
||||
|
||||
min_val = log_series.min()
|
||||
max_val = log_series.max()
|
||||
|
||||
# Step 2: Min-Max Normalization to scale [0.0, 1.0]
|
||||
if max_val > min_val:
|
||||
df_scored[norm_col] = (log_series - min_val) / (max_val - min_val)
|
||||
else:
|
||||
df_scored[norm_col] = 0.0
|
||||
|
||||
# Step 3: Compute weighted base score
|
||||
df_scored["base_rank_score"] = sum(
|
||||
df_scored[col] * weight for col, weight in self.weights.items()
|
||||
)
|
||||
|
||||
# Step 4: Multiply by verification confidence to penalize unverified or fake profiles
|
||||
# Artists with verified MusicBrainz/Wikidata and legitimate channels get full weight
|
||||
confidence = df_scored.get("composite_confidence", pd.Series(1.0, index=df_scored.index))
|
||||
# Ensure a minimum floor of 0.2 so emerging artists are ranked on data, not dropped completely
|
||||
confidence_multiplier = 0.2 + (0.8 * confidence)
|
||||
|
||||
# Step 5: Final HIFFI Score on a clean 0 to 100 scale
|
||||
df_scored["hiffi_score"] = (df_scored["base_rank_score"] * confidence_multiplier * 100).round(2)
|
||||
|
||||
# Sort and assign explicit rank ordinal
|
||||
df_scored = df_scored.sort_values(by="hiffi_score", ascending=False).reset_index(drop=True)
|
||||
df_scored["hiffi_rank"] = df_scored.index + 1
|
||||
|
||||
return df_scored
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test script with dummy mock distribution
|
||||
sample_data = pd.DataFrame([
|
||||
{"Name": "Global Superstar", "yt_subscribers": 15000000, "yt_lifetime_views": 8500000000, "yt_video_count": 120, "yt_recent_avg_views": 2500000, "yt_momentum_30d": 1.2, "composite_confidence": 0.95},
|
||||
{"Name": "Mid-tier Rising", "yt_subscribers": 250000, "yt_lifetime_views": 45000000, "yt_video_count": 45, "yt_recent_avg_views": 180000, "yt_momentum_30d": 5.4, "composite_confidence": 0.85},
|
||||
{"Name": "Emerging Talent", "yt_subscribers": 12000, "yt_lifetime_views": 850000, "yt_video_count": 14, "yt_recent_avg_views": 45000, "yt_momentum_30d": 12.8, "composite_confidence": 0.60},
|
||||
{"Name": "Dormant / Inactive", "yt_subscribers": 500, "yt_lifetime_views": 15000, "yt_video_count": 3, "yt_recent_avg_views": 50, "yt_momentum_30d": 0.0, "composite_confidence": 0.35}
|
||||
])
|
||||
|
||||
engine = RankingEngine()
|
||||
ranked = engine.normalize_metrics(sample_data)
|
||||
print("Ranked Test Artists with Log & Min-Max Transformation:")
|
||||
print(ranked[["hiffi_rank", "Name", "hiffi_score", "norm_subscribers", "norm_lifetime_views"]])
|
||||
@@ -0,0 +1,11 @@
|
||||
UID,Name,Youtube,Instagram,Location,yt_subscribers,yt_lifetime_views,yt_video_count,yt_recent_avg_views,yt_momentum_30d,yt_status,mb_verified,mb_id,wd_verified,wd_id,composite_confidence,confidence_tier,norm_subscribers,norm_lifetime_views,norm_video_count,norm_recent_avg_views,norm_momentum_30d,base_rank_score,hiffi_score,hiffi_rank
|
||||
9f76913bf5a8996b14c37563fdff9751,$ILKMONEY,https://www.youtube.com/channel/UCA1PZKy6XNQ-TsXWbQW2vyw,https://instagram.com/silkbooty,,46900,14878731,15,0,0.0,cached,True,309da629-06af-4453-85f4-9c1868a2fbbc,True,Q136428352,0.97,High,0.8776752758308687,0.9062496400532577,0.6717951158281678,0.0,0.0,0.5570445043453918,54.37,1
|
||||
4f9cc677d46d75f9709608f0668442fe,$hyfromdatre,https://www.youtube.com/channel/UCHRl8OQoAAUghnEJmsoiP5A,https://instagram.com/shyfromdatre,,210000,82140745,33,0,0.0,cached,True,db59266e-cd49-4f8c-98d2-e4b630251135,False,,0.75,High,1.0,1.0,0.8544331721773178,0.0,0.0,0.6354433172177318,50.84,2
|
||||
fb669f1ff32d3a7bbdef274872cbe0fd,+/-,https://www.youtube.com/channel/UCBOxSirnH1-_-xKtqX051lg,https://instagram.com/plusminusnyc,New York,739,164806,31,3477,0.0,cached,False,,False,Q260387,0.36,Low / Emerging,0.539104168005253,0.6591619178536015,0.8397438947852097,1.0,0.0,0.6604961193434973,32.23,3
|
||||
0f8e17a71adc4b60b61f9bf8f09605fb,$hoey,https://www.youtube.com/channel/UCkRNQjK0d70NqvLI57JmfeA,https://instagram.com/shoeyrussell,,594,653569,10,0,0.0,cached,False,1144f22d-fb1f-41a7-9822-05dab5edff25,False,,0.36,Low / Emerging,0.5213080735653491,0.7347588629481051,0.5810073162355173,0.0,0.0,0.3981828694301827,19.43,4
|
||||
405c893b17cdd7a486fc074f722671ab,$eLL,https://www.youtube.com/channel/UCVhJhIHih93-X-NzZeXX6bA,https://instagram.com/localcel_,,421,56299,61,0,0.0,cached,False,735f2b10-a267-4cd2-990f-f91702be9130,False,Q114274902,0.36,Low / Emerging,0.493273817829613,0.6002240439751241,1.0,0.0,0.0,0.39803815634266493,19.42,5
|
||||
e6351059c8846a51fe492ebb6365e4b0,$hamrock,https://www.youtube.com/channel/UCenklnIchLhgF2ELHfLqpmQ,,,125,33862,0,0,0.0,cached,True,771e21e8-78f6-4d06-a495-ddcaa08b469b,False,,0.63,Medium,0.3946417110342386,0.5723282415425062,0.0,0.0,0.0,0.2614745736958981,18.41,6
|
||||
c977cafbeabf67c0956bb0d1ce0188d7,$avage,https://www.youtube.com/channel/UCxtI7DyGJwUnAOywPSvf2OQ,https://instagram.com/mobshitsavage,,43300,0,0,0,0.0,cached,True,21b57a2b-9300-45b8-99bb-b970cb229f22,False,,0.63,Medium,0.8711584148945921,0.0,0.0,0.0,0.0,0.2613475244683776,18.4,7
|
||||
1cd4f7d2b1b902a677d0d6fba4096240,$'Edout,https://www.youtube.com/channel/UC6Sj8g3C4Qb9I3bl9xcQDXQ,https://instagram.com/Kid_soja,,21,12683,0,0,0.0,cached,False,,False,,0.28,Low / Emerging,0.2522297719906333,0.5184441001135359,0.0,0.0,0.0,0.20527995662557397,8.7,8
|
||||
33f12aeb1989989480885e6a4ed1637f,$ Eli,https://www.youtube.com/channel/UClmpQYORihn-nTO77x3XIpg,https://instagram.com/dollarsigneli,,0,0,0,0,0.0,not_found,False,,False,,0.0,Low / Emerging,0.0,0.0,0.0,0.0,0.0,0.0,0.0,9
|
||||
73502f922e4bfbad14f321aa0415f301,$hrames,https://www.youtube.com/channel/UCfwF7qNJylFoSIMi7KTKPdA,https://instagram.com/iPissGreatness,,0,0,0,0,0.0,cached,True,61c33b23-37a9-43a3-9ccd-9b12944ded5b,False,,0.55,Medium,0.0,0.0,0.0,0.0,0.0,0.0,0.0,10
|
||||
|
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
import argparse
|
||||
import pandas as pd
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from collectors.youtube_collector import YouTubeCollector
|
||||
from verifiers.musicbrainz_verifier import MusicBrainzVerifier
|
||||
from verifiers.wikidata_verifier import WikidataVerifier
|
||||
from core.confidence_scorer import ConfidenceScorer
|
||||
from core.ranking_engine import RankingEngine
|
||||
|
||||
def run_pipeline(input_csv: str = "cleaned_artists_inventory.csv", limit: int = 10, output_csv: str = "enriched_artists.csv"):
|
||||
load_dotenv()
|
||||
api_key = os.getenv("YOUTUBE_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
print("[Notice] No YOUTUBE_API_KEY found in .env. Running YouTube in offline fallback mode.")
|
||||
|
||||
print(f"Loading data from {input_csv}...")
|
||||
df = pd.read_csv(input_csv)
|
||||
|
||||
# Process only the selected sample batch limit
|
||||
batch_df = df.head(limit).copy()
|
||||
print(f"Processing sample batch of {len(batch_df)} artists...\n")
|
||||
|
||||
# Initialize all services
|
||||
yt_collector = YouTubeCollector(api_keys=[api_key] if api_key else None)
|
||||
mb_verifier = MusicBrainzVerifier()
|
||||
wd_verifier = WikidataVerifier()
|
||||
scorer = ConfidenceScorer()
|
||||
ranking_engine = RankingEngine()
|
||||
|
||||
results = []
|
||||
|
||||
for idx, row in batch_df.iterrows():
|
||||
name = str(row.get("Name", "")).strip()
|
||||
yt_url = str(row.get("Youtube", "")).strip()
|
||||
print(f"[{idx + 1}/{len(batch_df)}] Analyzing Artist: '{name}'")
|
||||
|
||||
# 1. Collect YouTube 5 metrics
|
||||
yt_data = yt_collector.get_channel_metrics(yt_url, fetch_recent_videos=False)
|
||||
|
||||
# 2. Verify against MusicBrainz
|
||||
mb_data = mb_verifier.verify_artist(name)
|
||||
|
||||
# 3. Verify against Wikidata
|
||||
wd_data = wd_verifier.verify_artist(name)
|
||||
|
||||
# 4. Compute composite confidence score
|
||||
score_data = scorer.calculate_score(yt_data, mb_data, wd_data)
|
||||
|
||||
# Merge results into record
|
||||
results.append({
|
||||
"UID": row.get("UID", ""),
|
||||
"Name": name,
|
||||
"Youtube": yt_url,
|
||||
"Instagram": row.get("Instagram", ""),
|
||||
"Location": row.get("Location", ""),
|
||||
"yt_subscribers": yt_data.get("subscribers", 0),
|
||||
"yt_lifetime_views": yt_data.get("lifetime_views", 0),
|
||||
"yt_video_count": yt_data.get("video_count", 0),
|
||||
"yt_recent_avg_views": yt_data.get("recent_avg_views", 0),
|
||||
"yt_momentum_30d": yt_data.get("momentum_30d", 0.0),
|
||||
"yt_status": yt_data.get("status", ""),
|
||||
"mb_verified": mb_data.get("verified", False),
|
||||
"mb_id": mb_data.get("mbid", ""),
|
||||
"wd_verified": wd_data.get("verified", False),
|
||||
"wd_id": wd_data.get("wikidata_id", ""),
|
||||
"composite_confidence": score_data.get("composite_confidence", 0.0),
|
||||
"confidence_tier": score_data.get("confidence_tier", "Low")
|
||||
})
|
||||
|
||||
raw_out_df = pd.DataFrame(results)
|
||||
|
||||
# 5. Apply Log-Transformation + Min-Max Scaling Ranking Engine
|
||||
print("\nApplying logarithmic normalization & Min-Max ranking...")
|
||||
out_df = ranking_engine.normalize_metrics(raw_out_df)
|
||||
|
||||
out_df.to_csv(output_csv, index=False)
|
||||
print(f"Pipeline run complete. Results saved to '{output_csv}'.\n")
|
||||
|
||||
# Display the final ranked overview table
|
||||
summary_cols = ["hiffi_rank", "Name", "hiffi_score", "yt_subscribers", "composite_confidence", "confidence_tier"]
|
||||
print(out_df[summary_cols].to_string(index=False))
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="HIFFI Artist Intelligence Pipeline")
|
||||
parser.add_argument("--limit", type=int, default=10, help="Number of records to test")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_pipeline(limit=args.limit)
|
||||
@@ -0,0 +1,109 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
class MusicBrainzVerifier:
|
||||
"""
|
||||
Cross-references artist names against MusicBrainz with local SQLite caching.
|
||||
Free, no API key required (Rate limit: 1 request/second).
|
||||
"""
|
||||
BASE_URL = "https://musicbrainz.org/ws/2/artist"
|
||||
|
||||
def __init__(self, db_path: str = ".cache/verifiers_cache.sqlite", app_name: str = "HiffiArtistIntelligence", version: str = "1.0"):
|
||||
self.headers = {
|
||||
"User-Agent": f"{app_name}/{version} ( contact@superlabs.co )"
|
||||
}
|
||||
self.last_call_time = 0.0
|
||||
self.db_path = db_path
|
||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS musicbrainz_cache (
|
||||
artist_name TEXT PRIMARY KEY,
|
||||
data_json TEXT,
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
def _get_cache(self, name: str) -> Dict[str, Any]:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT data_json FROM musicbrainz_cache WHERE artist_name = ?", (name.lower(),))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return json.loads(row[0])
|
||||
return None
|
||||
|
||||
def _set_cache(self, name: str, data: Dict[str, Any]):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO musicbrainz_cache (artist_name, data_json) VALUES (?, ?)",
|
||||
(name.lower(), json.dumps(data))
|
||||
)
|
||||
|
||||
def _rate_limit(self):
|
||||
elapsed = time.time() - self.last_call_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
self.last_call_time = time.time()
|
||||
|
||||
def verify_artist(self, name: str) -> Dict[str, Any]:
|
||||
if not name or not name.strip():
|
||||
return {"verified": False, "confidence": 0.0, "reason": "empty_name"}
|
||||
|
||||
cleaned_name = name.strip()
|
||||
|
||||
# Check local cache first
|
||||
cached = self._get_cache(cleaned_name)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
self._rate_limit()
|
||||
query = f'artist:"{cleaned_name}"'
|
||||
params = {"query": query, "fmt": "json", "limit": 3}
|
||||
|
||||
try:
|
||||
resp = requests.get(self.BASE_URL, headers=self.headers, params=params, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
result = {"verified": False, "confidence": 0.0, "status_code": resp.status_code}
|
||||
return result
|
||||
|
||||
data = resp.json()
|
||||
artists = data.get("artists", [])
|
||||
if not artists:
|
||||
result = {"verified": False, "confidence": 0.0, "reason": "not_found"}
|
||||
self._set_cache(cleaned_name, result)
|
||||
return result
|
||||
|
||||
best_match = artists[0]
|
||||
mb_name = best_match.get("name", "")
|
||||
match_score = fuzz.token_sort_ratio(cleaned_name.lower(), mb_name.lower())
|
||||
|
||||
confidence = round(match_score / 100.0, 2)
|
||||
is_verified = confidence >= 0.80
|
||||
|
||||
result = {
|
||||
"verified": is_verified,
|
||||
"confidence": confidence,
|
||||
"mbid": best_match.get("id"),
|
||||
"matched_name": mb_name,
|
||||
"disambiguation": best_match.get("disambiguation", ""),
|
||||
"country": best_match.get("country", ""),
|
||||
"type": best_match.get("type", "")
|
||||
}
|
||||
self._set_cache(cleaned_name, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"verified": False, "confidence": 0.0, "error": str(e)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
verifier = MusicBrainzVerifier()
|
||||
print("Testing MusicBrainz Caching...")
|
||||
print(verifier.verify_artist("Kendrick Lamar"))
|
||||
@@ -0,0 +1,105 @@
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
class WikidataVerifier:
|
||||
"""
|
||||
Checks if an artist has a Wikidata entity with local SQLite caching.
|
||||
"""
|
||||
SEARCH_URL = "https://www.wikidata.org/w/api.php"
|
||||
|
||||
def __init__(self, db_path: str = ".cache/verifiers_cache.sqlite"):
|
||||
self.headers = {
|
||||
"User-Agent": "HiffiArtistIntelligence/1.0 (contact@superlabs.co)"
|
||||
}
|
||||
self.db_path = db_path
|
||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wikidata_cache (
|
||||
artist_name TEXT PRIMARY KEY,
|
||||
data_json TEXT,
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
def _get_cache(self, name: str) -> Dict[str, Any]:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT data_json FROM wikidata_cache WHERE artist_name = ?", (name.lower(),))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return json.loads(row[0])
|
||||
return None
|
||||
|
||||
def _set_cache(self, name: str, data: Dict[str, Any]):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO wikidata_cache (artist_name, data_json) VALUES (?, ?)",
|
||||
(name.lower(), json.dumps(data))
|
||||
)
|
||||
|
||||
def verify_artist(self, name: str) -> Dict[str, Any]:
|
||||
if not name or not name.strip():
|
||||
return {"verified": False, "confidence": 0.0, "reason": "empty_name"}
|
||||
|
||||
cleaned_name = name.strip()
|
||||
|
||||
# Check local cache first
|
||||
cached = self._get_cache(cleaned_name)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
params = {
|
||||
"action": "wbsearchentities",
|
||||
"format": "json",
|
||||
"language": "en",
|
||||
"type": "item",
|
||||
"search": cleaned_name,
|
||||
"limit": 3
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(self.SEARCH_URL, headers=self.headers, params=params, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
result = {"verified": False, "confidence": 0.0, "status_code": resp.status_code}
|
||||
return result
|
||||
|
||||
results = resp.json().get("search", [])
|
||||
if not results:
|
||||
result = {"verified": False, "confidence": 0.0, "reason": "not_found"}
|
||||
self._set_cache(cleaned_name, result)
|
||||
return result
|
||||
|
||||
best = results[0]
|
||||
label = best.get("label", "")
|
||||
desc = best.get("description", "").lower()
|
||||
|
||||
music_keywords = ["musician", "singer", "rapper", "band", "record producer", "songwriter", "artist", "group"]
|
||||
is_music_entity = any(k in desc for k in music_keywords)
|
||||
|
||||
similarity = fuzz.token_sort_ratio(cleaned_name.lower(), label.lower()) / 100.0
|
||||
confidence = round(similarity * (1.0 if is_music_entity else 0.5), 2)
|
||||
|
||||
result = {
|
||||
"verified": confidence >= 0.75,
|
||||
"confidence": confidence,
|
||||
"wikidata_id": best.get("id"),
|
||||
"label": label,
|
||||
"description": best.get("description", "")
|
||||
}
|
||||
self._set_cache(cleaned_name, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"verified": False, "confidence": 0.0, "error": str(e)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
verifier = WikidataVerifier()
|
||||
print("Testing Wikidata Caching...")
|
||||
print(verifier.verify_artist("Kendrick Lamar"))
|
||||
Reference in new issue
Block a user