feat(governance): implement publication eligibility gates, proportional dynamic redistribution, CI workflow, and 20/20 QA suite
HIFFI QA & Governance Pipeline / governance-tests (push) Successful in 1m43s
HIFFI QA & Governance Pipeline / governance-tests (push) Successful in 1m43s
This commit is contained in:
1 parent
05da5050df
commit
0b36fff7b1
12 files changed
+1325
-806
No files matched your search
@@ -2,13 +2,14 @@
|
||||
collectors/instagram_verifier.py
|
||||
Automated candidate matching and verification engine for Instagram profiles.
|
||||
Replaces brittle static handle overrides with multi-signal evidence verification.
|
||||
Includes Levenshtein token similarity, bio music heuristics, and review queue integration.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -16,6 +17,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
OVERRIDES_PATH = PROJECT_ROOT / "data" / "handle_overrides.json"
|
||||
REVIEW_QUEUE_PATH = PROJECT_ROOT / "data" / "review_queue.csv"
|
||||
|
||||
|
||||
def pd_isna(val) -> bool:
|
||||
@@ -26,11 +28,34 @@ def pd_isna(val) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def levenshtein_ratio(s1: str, s2: str) -> float:
|
||||
"""Computes Levenshtein similarity ratio [0.0, 1.0] in pure Python."""
|
||||
if not s1 and not s2:
|
||||
return 1.0
|
||||
if not s1 or not s2:
|
||||
return 0.0
|
||||
m, n = len(s1), len(s2)
|
||||
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
||||
for i in range(m + 1):
|
||||
dp[i][0] = i
|
||||
for j in range(n + 1):
|
||||
dp[0][j] = j
|
||||
for i in range(1, m + 1):
|
||||
for j in range(1, n + 1):
|
||||
if s1[i - 1] == s2[j - 1]:
|
||||
dp[i][j] = dp[i - 1][j - 1]
|
||||
else:
|
||||
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
||||
distance = dp[m][n]
|
||||
return 1.0 - (distance / max(m, n))
|
||||
|
||||
|
||||
class InstagramCandidateVerifier:
|
||||
"""
|
||||
Evaluates Instagram account candidates using name matching, bio link analysis,
|
||||
and follower plausibility heuristics. Emits confidence scores and audit evidence.
|
||||
"""
|
||||
MUSIC_BIO_KEYWORDS = [
|
||||
"booking", "management", "mgmt", "records", "stream",
|
||||
"album", "single", "out now", "tour", "official", "ent", "music"
|
||||
]
|
||||
|
||||
def __init__(self, overrides_path: Optional[Path] = None):
|
||||
self.overrides_path = overrides_path or OVERRIDES_PATH
|
||||
self.manual_overrides: Dict[str, object] = {}
|
||||
@@ -145,16 +170,21 @@ class InstagramCandidateVerifier:
|
||||
confidence += 0.30
|
||||
evidence.append("Substring containment between artist name and handle")
|
||||
else:
|
||||
# Token Overlap Check
|
||||
artist_tokens = self.extract_tokens(clean_artist)
|
||||
handle_tokens = self.extract_tokens(handle)
|
||||
overlap = [t for t in artist_tokens if t in clean_handle or any(t in ht for ht in handle_tokens)]
|
||||
if overlap:
|
||||
confidence += 0.25
|
||||
evidence.append(f"Token overlap identified: {overlap}")
|
||||
# Fuzzy Levenshtein & Token Overlap Check
|
||||
sim_score = levenshtein_ratio(clean_artist_norm, clean_handle)
|
||||
if sim_score >= 0.82:
|
||||
confidence += 0.30
|
||||
evidence.append(f"High Levenshtein similarity ({sim_score:.2f})")
|
||||
else:
|
||||
confidence += 0.10
|
||||
evidence.append("Candidate handle linked from cohort")
|
||||
artist_tokens = self.extract_tokens(clean_artist)
|
||||
handle_tokens = self.extract_tokens(handle)
|
||||
overlap = [t for t in artist_tokens if t in clean_handle or any(t in ht for ht in handle_tokens)]
|
||||
if overlap:
|
||||
confidence += 0.25
|
||||
evidence.append(f"Token overlap identified: {overlap}")
|
||||
else:
|
||||
confidence += 0.10
|
||||
evidence.append("Candidate handle linked from cohort")
|
||||
|
||||
# Profile Display Name Alignment
|
||||
clean_profile = self.clean_text(profile_name)
|
||||
@@ -170,13 +200,18 @@ class InstagramCandidateVerifier:
|
||||
confidence += 0.15
|
||||
evidence.append(f"Handle correlates with YouTube channel '{youtube_channel_name}'")
|
||||
|
||||
# Cross-Linked External Proof (Linktree / Spotify / etc.)
|
||||
# Cross-Linked External Proof (Linktree / Spotify / Apple)
|
||||
links = bio_links or []
|
||||
bio_lower = bio_raw.lower()
|
||||
if any("linktr.ee" in l.lower() or "beacons.ai" in l.lower() or "spotify.com" in l.lower() for l in links) or \
|
||||
any(domain in bio_lower for domain in ["linktr.ee", "spotify.com", "music.apple.com", "official"]):
|
||||
any(domain in bio_lower for domain in ["linktr.ee", "spotify.com", "music.apple.com"]):
|
||||
confidence += 0.25
|
||||
evidence.append("Bio contains cross-platform music or Linktree link")
|
||||
evidence.append("Bio contains cross-platform music link")
|
||||
|
||||
# Music Bio Metadata Heuristics
|
||||
if any(kw in bio_lower for kw in self.MUSIC_BIO_KEYWORDS):
|
||||
confidence += 0.15
|
||||
evidence.append("Bio contains industry music metadata")
|
||||
|
||||
# Follower Count Credibility Gate
|
||||
has_valid_followers = False
|
||||
@@ -205,7 +240,7 @@ class InstagramCandidateVerifier:
|
||||
else:
|
||||
status = "REJECTED_LOW_CONFIDENCE"
|
||||
|
||||
# Preserve follower signals for both verified and review accounts; only strip on rejection
|
||||
# Preserve follower signals for verified and review accounts; drop on low confidence rejection
|
||||
retained_followers = follower_count if status in ("MANUAL_VERIFIED", "AUTOMATED_VERIFIED", "UNCERTAIN_NEEDS_REVIEW") else None
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,11 +3,13 @@ collectors/kworb_spotify_collector.py
|
||||
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).
|
||||
Includes stylized hip-hop stage name normalization and alias resolution.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
from typing import Dict, Optional, Tuple
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
|
||||
KWORB_LISTENERS_URLS = [
|
||||
"https://kworb.net/spotify/listeners.html",
|
||||
@@ -23,11 +25,43 @@ HEADERS = {
|
||||
|
||||
|
||||
class KworbStreamingCollector:
|
||||
# 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",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# Maps lowercase artist_name -> (monthly_listeners, daily_change)
|
||||
# Maps normalized artist_name -> (monthly_listeners, daily_change)
|
||||
self.cached_metrics: Dict[str, Tuple[int, Optional[int]]] = {}
|
||||
self.is_loaded = False
|
||||
|
||||
@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()
|
||||
|
||||
def load_aggregator_tables(self, max_pages: int = 2):
|
||||
"""Scrapes and indexes artist listener counts and daily velocity from public tables."""
|
||||
print(f"[KworbCollector] Indexing public streaming metrics across {max_pages} tables...")
|
||||
@@ -40,8 +74,9 @@ class KworbStreamingCollector:
|
||||
for row in rows:
|
||||
cols = row.find_all("td")
|
||||
if len(cols) >= 4:
|
||||
# Col 1: Artist
|
||||
artist_name = cols[1].get_text(strip=True).lower()
|
||||
# Col 1: Raw Artist Name
|
||||
raw_artist = cols[1].get_text(strip=True)
|
||||
artist_norm = self.normalize_artist_name(raw_artist)
|
||||
|
||||
# Col 2: Monthly Listeners
|
||||
raw_listeners = cols[2].get_text(strip=True).replace(",", "")
|
||||
@@ -57,7 +92,8 @@ class KworbStreamingCollector:
|
||||
except ValueError:
|
||||
daily_change = None
|
||||
|
||||
self.cached_metrics[artist_name] = (listeners, daily_change)
|
||||
if artist_norm:
|
||||
self.cached_metrics[artist_norm] = (listeners, daily_change)
|
||||
except Exception as e:
|
||||
print(f"[KworbCollector] Error reading {url}: {e}")
|
||||
continue
|
||||
@@ -66,10 +102,14 @@ class KworbStreamingCollector:
|
||||
print(f"[KworbCollector] Loaded {len(self.cached_metrics):,} artist telemetry records.")
|
||||
|
||||
def get_artist_metrics(self, artist_name: str) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""Returns (monthly_listeners, daily_change) or (None, None)."""
|
||||
"""
|
||||
Returns (monthly_listeners, daily_change) or (None, None).
|
||||
Searches both the direct normalization and alias substitutions.
|
||||
"""
|
||||
if not self.is_loaded:
|
||||
self.load_aggregator_tables(max_pages=2)
|
||||
return self.cached_metrics.get(artist_name.strip().lower(), (None, None))
|
||||
norm_key = self.normalize_artist_name(artist_name)
|
||||
return self.cached_metrics.get(norm_key, (None, None))
|
||||
|
||||
def get_monthly_listeners(self, artist_name: str) -> Optional[int]:
|
||||
return self.get_artist_metrics(artist_name)[0]
|
||||
@@ -83,9 +123,9 @@ if __name__ == "__main__":
|
||||
collector.load_aggregator_tables(max_pages=2)
|
||||
test_cohort = [
|
||||
"Drake", "Kendrick Lamar", "SZA", "Future",
|
||||
"Cardi B", "Russ Millions", "Larry June", "Rylo Rodriguez"
|
||||
"Cardi B", "A$AP Rocky", "Joey Bada$$", "6ix9ine", "Ty Dolla $ign"
|
||||
]
|
||||
print("\nValidating Kworb Stream & Daily Velocity Extraction:")
|
||||
print("\nValidating Kworb Stream & Daily Velocity Extraction with Aliases:")
|
||||
print(f"{'Artist':20} | {'Monthly Listeners':>18} | {'Daily +/- Trend':>16}")
|
||||
print("-" * 60)
|
||||
for artist in test_cohort:
|
||||
|
||||
Reference in new issue
Block a user