Files
hiffi-artist-intelligence/collectors/youtube_collector.py
T

192 lines
7.2 KiB
Python

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)