# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/twitch.py # Desc: Connecteur ENRICHISSEMENT Twitch — profil public par créateur SANS # clé via l'API GQL publique du site web officiel (Client-ID public), # abonnés inclus. Voie Helix officielle conservée si des clés # TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET apparaissent. Palier 2-3 (§9). # ============================================================================== """Enrichissement Twitch, créateur par créateur — sans clé (vague 3). Le site web officiel de Twitch consomme ``gql.twitch.tv/gql`` avec un Client-ID PUBLIC embarqué dans chaque page servie à n'importe quel visiteur (kimne78kx3ncx6brgo4mv6wki5h1ko) — aucune authentification. On ne lit que les champs publics du profil déjà rattaché (§15) : abonnés (followers.totalCount, que Helix ne fournit plus sans jeton de modérateur depuis 2023), description, avatar, statut partenaire/affilié, dernière diffusion (fraîcheur/dormance). Requêtes REGROUPÉES (20 profils par POST) → ~2 requêtes pour le catalogue Twitch actuel. Si des clés officielles apparaissent dans .env, la voie Helix (privilégiée §10) reprend le dessus pour l'existence/badge, avec le même complément GQL pour les abonnés. ⚠ Empreinte TLS : gql.twitch.tv accepte python-requests (vérifié 2026-08-18, HTTP 200) — contrairement à syndication.twitter.com (voir x_profil). Un repli curl-binaire est prévu au cas où un blocage apparaîtrait. """ from __future__ import annotations import json import os import subprocess import time from ..schema import Creator, now_iso from .base import USER_AGENT, BaseConnector TOKEN_URL = "https://id.twitch.tv/oauth2/token" USERS_URL = "https://api.twitch.tv/helix/users" GQL_URL = "https://gql.twitch.tv/gql" # Client-ID PUBLIC du client web officiel de Twitch (embarqué dans les pages # servies à tous les visiteurs — ce n'est PAS un secret) WEB_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" # champs publics du profil (§15 : rien de privé, rien de contourné) _GQL_FIELDS = ("id login displayName description profileImageURL(width: 300) " "followers { totalCount } roles { isPartner isAffiliate } " "lastBroadcast { startedAt game { displayName } } createdAt") def gql_user_query(handle: str) -> dict: """Requête GQL publique pour UN login (échappé via json.dumps).""" return {"query": "query { user(login: %s) { %s } }" % (json.dumps(handle), _GQL_FIELDS)} def parse_gql_user(entry: dict) -> dict | None: """Une réponse GQL → dict plat {followers, partner, affiliate, bio, avatar, last_stream, game} — None si le compte n'existe pas/plus.""" user = (entry.get("data") or {}).get("user") if not user: return None last = user.get("lastBroadcast") or {} roles = user.get("roles") or {} return { "followers": (user.get("followers") or {}).get("totalCount"), "partner": bool(roles.get("isPartner")), "affiliate": bool(roles.get("isAffiliate")), "bio": (user.get("description") or "").strip(), "avatar": user.get("profileImageURL") or "", "last_stream": last.get("startedAt"), "game": (last.get("game") or {}).get("displayName"), } class TwitchConnector(BaseConnector): source_id = "twitch" kind = "enrichment" request_delay = 1.0 max_profiles = 400 batch_size = 20 # profils par POST GQL (le site en groupe autant) def __init__(self) -> None: super().__init__() self.errors = 0 # -- voie officielle Helix (si clés fournies) ------------------------------ def _token(self, cid: str, secret: str) -> str: resp = self.post(TOKEN_URL, data={"client_id": cid, "client_secret": secret, "grant_type": "client_credentials"}) return resp.json()["access_token"] def _enrich_helix(self, pairs: list, cid: str, secret: str) -> list[Creator]: token = self._token(cid, secret) headers = {"Client-Id": cid, "Authorization": f"Bearer {token}"} enriched: list[Creator] = [] for cr, tw in pairs: try: resp = self.get(USERS_URL, params={"login": tw.handle}, headers=headers) except Exception: self.errors += 1 continue data = resp.json().get("data") or [] if not data: continue user = data[0] tw.verified = user.get("broadcaster_type") == "partner" tw.confidence = max(tw.confidence or 0, 0.90) tw.signal = tw.signal or "api_officielle" tw.last_checked = now_iso() if not cr.bio and user.get("description"): cr.bio = user["description"] if not cr.avatar_url and user.get("profile_image_url"): cr.avatar_url = user["profile_image_url"] enriched.append(cr) return enriched # -- voie publique GQL (sans clé) ------------------------------------------- def _gql(self, handles: list[str]) -> list[dict]: """POST groupé — repli curl-binaire si l'empreinte TLS était refusée.""" payload = [gql_user_query(h) for h in handles] try: resp = self.post(GQL_URL, json=payload, headers={"Client-Id": WEB_CLIENT_ID}) return resp.json() except Exception: pass # 4xx/blocage → repli curl (même pattern que x-profil) self._throttle() try: proc = subprocess.run( ["curl", "-sS", "--fail", "--max-time", str(self.timeout), "-A", USER_AGENT, "-H", f"Client-Id: {WEB_CLIENT_ID}", "-H", "Content-Type: application/json", "-d", json.dumps(payload), GQL_URL], capture_output=True, text=True, timeout=self.timeout + 5) finally: self._last_request = time.time() if proc.returncode != 0: raise RuntimeError(f"curl {proc.returncode}: gql.twitch.tv") return json.loads(proc.stdout) def _enrich_gql(self, pairs: list) -> list[Creator]: enriched: list[Creator] = [] for i in range(0, len(pairs), self.batch_size): batch = pairs[i:i + self.batch_size] try: replies = self._gql([tw.handle for _, tw in batch]) except Exception: self.errors += 1 continue if not isinstance(replies, list) or len(replies) != len(batch): self.errors += 1 continue for (cr, tw), entry in zip(batch, replies): info = parse_gql_user(entry if isinstance(entry, dict) else {}) tw.last_checked = now_iso() if info is None: # compte disparu/renommé : horodaté, sans plus enriched.append(cr) continue if info["followers"] is not None: tw.followers = int(info["followers"]) if info["partner"]: tw.verified = True tw.metrics.update({k: v for k, v in { "affiliate": info["affiliate"] or None, "last_stream": info["last_stream"], "game": info["game"], }.items() if v}) if not cr.bio and info["bio"]: cr.bio = info["bio"] if not cr.avatar_url and info["avatar"].startswith("http"): cr.avatar_url = info["avatar"] enriched.append(cr) return enriched def enrich(self, creators: list[Creator]) -> list[Creator]: pairs = [] for cr in creators: tw = next((a for a in cr.platforms if a.platform == "twitch"), None) if tw is not None: pairs.append((cr, tw)) pairs = pairs[:self.max_profiles] if not pairs: return [] cid = os.environ.get("TWITCH_CLIENT_ID", "") secret = os.environ.get("TWITCH_CLIENT_SECRET", "") if cid and secret: # voie officielle privilégiée (§10) quand disponible enriched = self._enrich_helix(pairs, cid, secret) extra = self._enrich_gql(pairs) # complément : abonnés (GQL public) seen = {id(c) for c in enriched} enriched += [c for c in extra if id(c) not in seen] return enriched return self._enrich_gql(pairs)