# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/youtube.py # Desc: Connecteur ENRICHISSEMENT YouTube — stats de chaîne par créateur. # Mode d'accès : YouTube Data API v3 (OFFICIELLE, privilégiée §10) si # YOUTUBE_API_KEY est définie ; sinon voie SANS clé complète (vague 3) : # page publique de la chaîne + InnerTube « browse » (l'API interne que # la page elle-même appelle, clé publique embarquée). Palier 2-3 (§9). # ============================================================================== """Enrichissement YouTube, créateur par créateur. Voie privilégiée : l'API officielle `channels?forHandle=` (fiable, conforme, quota 10 000 unités/jour). Sans clé, voie publique complète (vague 3) : 1. page ``youtube.com/@handle`` (ytInitialData) → abonnés, nb de vidéos, description (→ bio si vide), bannière, avatar, id de chaîne UC…, et le jeton du panneau « À propos » ; 2. InnerTube ``browse`` avec ce jeton (la requête que le navigateur fait en cliquant « …plus ») → vues totales, nb de vidéos exact, pays affiché ; 3. page ``/videos`` → date approximative de la dernière vidéo (« il y a X ») → fraîcheur/dormance (metrics.last_video + dormant >12 mois). Cap 300 chaînes/passage avec ROTATION (metrics.yt_checked, re-visite 14 j, jamais-lues d'abord) : les ~4 200 comptes YouTube convergent en ~2 semaines. Signal : `api_officielle` (0.95) quand confirmé par l'API — le handle interrogé est celui déjà rattaché ; la voie publique ne touche pas au score. """ from __future__ import annotations import json import os import re from datetime import datetime, timedelta, timezone from ..normalize import parse_count from ..schema import Creator, now_iso from .base import BaseConnector API_URL = ("https://www.googleapis.com/youtube/v3/channels" "?part=statistics,snippet&forHandle={h}&key={key}") PAGE_URL = "https://www.youtube.com/@{h}" CHANNEL_URL = "https://www.youtube.com/channel/{h}" BROWSE_URL = "https://www.youtube.com/youtubei/v1/browse" _YTID_RE = re.compile(r"var ytInitialData = (\{.*?\});", re.S) _KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"') _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"') _UCID_RE = re.compile(r"UC[0-9A-Za-z_-]{22}") _SUBS_RES = ( re.compile(r'"subscriberCountText"[^}]*?"(?:simpleText|content)"\s*:\s*"([^"]+)"'), re.compile(r'"subscriberCount"\s*:\s*"?(\d+)"?'), re.compile(r'"content"\s*:\s*"([\d.,\s]+[kKmMbB]?\s*(?:subscribers|abonn[ée]s))"'), ) _VIDEOS_RE = re.compile(r'"content"\s*:\s*"([\d.,\s]+[kKmM]?)\s*videos?"') # première vidéo de l'onglet /videos (tri chronologique) : « 3 weeks ago » _AGO_RE = re.compile(r'"(?:content|simpleText)":"(?:Streamed )?' r'(\d+ (?:second|minute|hour|day|week|month|year)s? ago)"') _AGO_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400, "week": 7 * 86400, "month": 30 * 86400, "year": 365 * 86400} # au-delà de 12 mois sans vidéo : chaîne dormante (métrique publique, comme # balados-itunes — la fiche reste active, le créateur vit peut-être ailleurs) DORMANT_AFTER_DAYS = 365 def ago_to_iso(text: str, now: datetime | None = None) -> str | None: """« 3 weeks ago » / « Streamed 1 year ago » → date ISO approximative.""" m = re.search(r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago", text or "") if not m: return None seconds = int(m.group(1)) * _AGO_UNITS[m.group(2)] at = (now or datetime.now(timezone.utc)) - timedelta(seconds=seconds) return at.strftime("%Y-%m-%d") def _walk_token(node) -> str | None: """Premier jeton continuationCommand d'un sous-arbre JSON.""" if isinstance(node, dict): cmd = node.get("continuationCommand") if isinstance(cmd, dict) and cmd.get("token"): return cmd["token"] for v in node.values(): tok = _walk_token(v) if tok: return tok elif isinstance(node, list): for item in node: tok = _walk_token(item) if tok: return tok return None def parse_channel_page(html: str) -> dict | None: """Page publique de la chaîne → {followers, videos, description, avatar, banner, channel_id, about_token, key, ver} (champs manquants = None).""" m = _YTID_RE.search(html) if not m: return None try: data = json.loads(m.group(1)) except ValueError: return None meta = (data.get("metadata") or {}).get("channelMetadataRenderer") or {} out: dict = {"description": (meta.get("description") or "").strip(), "channel_id": meta.get("externalId"), "followers": None, "videos": None, "avatar": None, "banner": None, "about_token": None} thumbs = ((meta.get("avatar") or {}).get("thumbnails")) or [] if thumbs: out["avatar"] = max(thumbs, key=lambda t: t.get("width") or 0).get("url") header = json.dumps((data.get("header") or {}), ensure_ascii=False) for rx in _SUBS_RES: mm = rx.search(header) if mm: out["followers"] = parse_count(mm.group(1)) break mm = _VIDEOS_RE.search(header) if mm: out["videos"] = parse_count(mm.group(1)) vm = ((((data.get("header") or {}).get("pageHeaderRenderer") or {}) .get("content") or {}).get("pageHeaderViewModel") or {}) sources = ((((vm.get("banner") or {}).get("imageBannerViewModel") or {}) .get("image") or {}).get("sources")) or [] if sources: out["banner"] = max(sources, key=lambda s: s.get("width") or 0).get("url") # jeton du panneau « À propos » : sous l'aperçu de description du header # (c'est LA requête que fait le navigateur au clic sur « …plus ») out["about_token"] = _walk_token(vm.get("description")) or \ _walk_token(vm.get("attribution")) key_m, ver_m = _KEY_RE.search(html), _VER_RE.search(html) out["key"] = key_m.group(1) if key_m else None out["ver"] = ver_m.group(1) if ver_m else None return out def parse_about(text: str) -> dict | None: """Réponse browse du panneau À propos → {views, videos, country} ou None.""" if "aboutChannelViewModel" not in (text or ""): return None out: dict = {} mm = re.search(r'"viewCountText"\s*:\s*"([^"]+)"', text) if mm: out["views"] = parse_count(mm.group(1)) mm = re.search(r'"videoCountText"\s*:\s*"([^"]+)"', text) if mm: out["videos"] = parse_count(mm.group(1)) mm = re.search(r'"country"\s*:\s*(?:\{"content":)?"([^"]+)"', text) if mm: out["country"] = mm.group(1) return out class YouTubeConnector(BaseConnector): source_id = "youtube" kind = "enrichment" request_delay = 1.0 max_profiles = 1500 # voie API officielle (1 requête légère/chaîne) max_profiles_page = 300 # voie publique (2-3 requêtes/chaîne) — rotation revisit_days = 14 def __init__(self) -> None: super().__init__() self.errors = 0 # -- voie API officielle ----------------------------------------------------- def _via_api(self, handle: str, key: str) -> dict | None: resp = self.get(API_URL.format(h=handle, key=key)) items = resp.json().get("items") or [] if not items: return None stats = items[0].get("statistics") or {} return {"followers": parse_count(stats.get("subscriberCount")), "views": parse_count(stats.get("viewCount")), "videos": parse_count(stats.get("videoCount")), "confirmed": True} # -- voie publique sans clé (vague 3) ----------------------------------------- def _channel_url(self, acc) -> str: if _UCID_RE.fullmatch(acc.handle): return CHANNEL_URL.format(h=acc.handle) return PAGE_URL.format(h=acc.handle) def _browse_about(self, key: str, ver: str, token: str) -> dict | None: resp = self.post( BROWSE_URL, params={"key": key}, json={"context": {"client": {"clientName": "WEB", "clientVersion": ver, "hl": "en", "gl": "CA"}}, "continuation": token}, headers={"Accept-Language": "en-CA,en;q=0.8"}) return parse_about(resp.text) def _last_video(self, base_url: str) -> str | None: html = self.get(base_url.rstrip("/") + "/videos", headers={"Accept-Language": "en-CA,en;q=0.8"}).text m = _YTID_RE.search(html) if not m: return None mm = _AGO_RE.search(m.group(1)) return ago_to_iso(mm.group(1)) if mm else None def _via_page(self, acc) -> dict | None: base = self._channel_url(acc) html = self.get(base, headers={"Accept-Language": "en-CA,en;q=0.8"}).text page = parse_channel_page(html) if page is None: return None info: dict = {"followers": page["followers"], "videos": page["videos"], "description": page["description"], "avatar": page["avatar"], "banner": page["banner"], "confirmed": False} if page["about_token"] and page["key"] and page["ver"]: try: # vues totales + nb de vidéos exact « si affichés » about = self._browse_about(page["key"], page["ver"], page["about_token"]) or {} info.update({k: v for k, v in about.items() if v is not None}) except Exception: self.errors += 1 if info.get("videos"): # date de la dernière vidéo (fraîcheur/dormance) try: info["last_video"] = self._last_video(base) except Exception: self.errors += 1 return info def _needs_visit(self, metrics: dict) -> bool: raw = metrics.get("yt_checked") if not raw: return True try: checked = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return True return (datetime.now(timezone.utc) - checked > timedelta(days=self.revisit_days)) def enrich(self, creators: list[Creator]) -> list[Creator]: key = os.environ.get("YOUTUBE_API_KEY", "") if key: return self._enrich_api(creators, key) return self._enrich_page(creators) def _enrich_api(self, creators: list[Creator], key: str) -> list[Creator]: enriched: list[Creator] = [] fetched = 0 for cr in creators: yt = next((a for a in cr.platforms if a.platform == "youtube"), None) if yt is None or fetched >= self.max_profiles: continue try: info = self._via_api(yt.handle, key) except Exception: self.errors += 1 continue fetched += 1 if not info or info.get("followers") is None: continue yt.followers = info["followers"] yt.last_checked = now_iso() extra = {k: info.get(k) for k in ("views", "videos")} yt.metrics.update({k: v for k, v in extra.items() if v is not None}) # existence confirmée par l'API officielle → signal renforcé yt.confidence = max(yt.confidence or 0, 0.90) yt.signal = yt.signal or "api_officielle" enriched.append(cr) return enriched def _enrich_page(self, creators: list[Creator]) -> list[Creator]: # rotation : jamais-lues d'abord, puis les plus anciennes re-visites ; # l'appelant (ingest.load_active) trie déjà par portée décroissante candidates = [] for cr in creators: yt = next((a for a in cr.platforms if a.platform == "youtube"), None) if yt is not None and self._needs_visit(yt.metrics): candidates.append((cr, yt)) candidates.sort(key=lambda t: bool(t[1].metrics.get("yt_checked"))) enriched: list[Creator] = [] fetched = 0 for cr, yt in candidates: if fetched >= self.max_profiles_page: break fetched += 1 # le cap borne les TENTATIVES réseau try: info = self._via_page(yt) except Exception: self.errors += 1 continue yt.metrics["yt_checked"] = now_iso() if info is None: # chaîne fermée/renommée : horodatée, sans plus enriched.append(cr) continue if info.get("followers") is not None: yt.followers = info["followers"] yt.last_checked = now_iso() dormant = None if info.get("last_video"): try: last = datetime.strptime(info["last_video"], "%Y-%m-%d") dormant = (datetime.now(timezone.utc) - last.replace(tzinfo=timezone.utc) > timedelta(days=DORMANT_AFTER_DAYS)) or None except ValueError: pass yt.metrics.update({k: v for k, v in { "videos": info.get("videos"), "views": info.get("views"), "last_video": info.get("last_video"), "dormant": dormant, "country": info.get("country"), "banner": info.get("banner"), }.items() if v is not None}) if not cr.bio and info.get("description"): cr.bio = info["description"][:1200] if not cr.avatar_url and (info.get("avatar") or "").startswith("http"): cr.avatar_url = info["avatar"] enriched.append(cr) return enriched