Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/connectors/twitch_decouverte.py4# Desc: Connecteur DÉCOUVERTE — streamers Twitch québécois via l'API GQL5# publique du site officiel (Client-ID public, sans clé) : recherche6# de chaînes par termes QC + répertoire des diffusions en français.7# Palier 3 (§9). Filtre conservateur : marqueur québécois OBLIGATOIRE8# dans le nom, la bio ou le titre du stream.9# ==============================================================================10"""Découverte des streamers Twitch québécois — sans clé.1112Deux voies, mêmes conventions que l'enrichisseur `twitch` (vague 3) :13141. ``searchFor`` (la recherche du site officiel) sur une liste de termes15 québécois (province, régions, villes) → chaînes dont le profil PUBLIC16 mentionne le Québec, avec abonnés, bio, avatar, statut partenaire ;172. le répertoire des diffusions EN DIRECT en français (``streams``,18 languages: [FR]) → longue traîne de streamers QC actifs, paginé ; la19 couverture s'accumule à chaque passage (qui est en direct change chaque20 jour, comme youtube-recherche).2122On ne crée une fiche QUE si un marqueur québécois figure dans le nom, la23description du profil ou le titre du stream (jamais d'inférence, §7/§15).24Le compte Twitch est le compte SOURCE de la fiche → signal profil_source25(0.98) ; l'enrichissement rattache ensuite ses autres plateformes.26"""27from __future__ import annotations2829import json30import subprocess31import sys32import time3334from ..identity import account35from ..normalize import slugify36from ..schema import Creator37from .base import USER_AGENT, BaseConnector38from .twitch import GQL_URL, WEB_CLIENT_ID39from .youtube_recherche import is_quebec4041# champs publics du profil (§15) — mêmes que l'enrichisseur twitch42_USER_FIELDS = ("login displayName description createdAt "43 "followers { totalCount } profileImageURL(width: 300) "44 "roles { isPartner isAffiliate } "45 "lastBroadcast { startedAt game { displayName } }")4647# termes de recherche québécois (province, régions, villes, scène locale)48SEARCH_TERMS = [49 "québec", "québécois", "québécoise", "queb", "montréal", "mtl",50 "ville de québec", "rive-sud", "rive-nord", "saguenay", "gaspésie",51 "mauricie", "estrie", "outaouais", "abitibi", "témiscamingue",52 "lanaudière", "laurentides", "montérégie", "charlevoix", "beauce",53 "côte-nord", "bas-saint-laurent", "gatineau", "sherbrooke", "laval",54 "longueuil", "lévis", "trois-rivières", "chicoutimi", "rimouski",55 "drummondville", "granby", "victoriaville", "joliette", "terrebonne",56 "repentigny", "brossard", "shawinigan", "rouyn-noranda", "alma",57 "streamer québécois", "gamer québécois", "twitch québec",58]5960# jeu du stream → niche §6.1 (défaut : gaming — c'est Twitch)61_GAME_NICHE = {62 "Just Chatting": "lifestyle", "Music": "musique", "Art": "arts",63 "Food & Drink": "cuisine", "Sports": "sport-fitness",64 "ASMR": "lifestyle", "Talk Shows & Podcasts": "actualite-opinion",65 "Software and Game Development": "tech", "Science & Technology": "tech",66 "Fitness & Health": "sport-fitness", "Travel & Outdoors": "plein-air",67 "Makers & Crafting": "arts", "Beauty & Body Art": "beaute",68 "Politics": "actualite-opinion", "DJs": "musique",69}707172class TwitchDecouverteConnector(BaseConnector):73 source_id = "twitch-decouverte"74 kind = "discovery"75 request_delay = 1.076 search_limit = 25 # chaînes par page de recherche77 search_pages = 4 # pages par terme (cursor searchFor)78 stream_pages = 20 # pages du répertoire FR (30 streams/page)7980 def __init__(self) -> None:81 super().__init__()82 self.errors = 08384 # -- transport GQL (repli curl-binaire, même pattern que twitch.py) --------85 def _gql(self, query: str) -> dict:86 payload = {"query": query}87 try:88 resp = self.post(GQL_URL, json=payload,89 headers={"Client-Id": WEB_CLIENT_ID})90 return resp.json()91 except Exception:92 pass # 4xx/blocage TLS → repli curl93 self._throttle()94 try:95 proc = subprocess.run(96 ["curl", "-sS", "--fail", "--max-time", str(self.timeout),97 "-A", USER_AGENT, "-H", f"Client-Id: {WEB_CLIENT_ID}",98 "-H", "Content-Type: application/json",99 "-d", json.dumps(payload), GQL_URL],100 capture_output=True, text=True, timeout=self.timeout + 5)101 finally:102 self._last_request = time.time()103 if proc.returncode != 0:104 raise RuntimeError(f"curl {proc.returncode}: gql.twitch.tv")105 return json.loads(proc.stdout)106107 # -- fabrication d'une fiche -------------------------------------------------108 def _collect(self, user: dict, context: str, seen: set[str],109 creators: list[Creator], niche: str = "gaming") -> None:110 login = (user.get("login") or "").strip().lower()111 name = (user.get("displayName") or login).strip()112 bio = (user.get("description") or "").strip()113 if not login or login in seen:114 return115 # filtre conservateur : marqueur QC dans nom + bio + titre du stream116 if not is_quebec(f"{name} {bio} {context}"):117 return118 seen.add(login)119 last = user.get("lastBroadcast") or {}120 roles = user.get("roles") or {}121 game = (last.get("game") or {}).get("displayName")122 acc = account("twitch", login, "profil_source",123 followers=(user.get("followers") or {}).get("totalCount"),124 verified=bool(roles.get("isPartner")) or None)125 acc.metrics = {k: v for k, v in {126 "affiliate": bool(roles.get("isAffiliate")) or None,127 "last_stream": last.get("startedAt"),128 "game": game,129 }.items() if v}130 avatar = user.get("profileImageURL") or ""131 creators.append(Creator(132 source=self.source_id,133 external_id=slugify(name),134 display_name=name,135 bio=bio[:1200],136 niches=[_GAME_NICHE.get(game or "", niche)],137 creator_type="streamer",138 primary_platform="twitch",139 avatar_url=avatar if avatar.startswith("http") else None,140 platforms=[acc],141 source_ids=[f"{self.source_id}:{login}"],142 ))143144 # -- voie 1 : recherche de chaînes par termes QC -----------------------------145 def _search(self, seen: set[str], creators: list[Creator]) -> None:146 for term in SEARCH_TERMS:147 cursor = ""148 for _page in range(self.search_pages):149 cur = f', cursor: {json.dumps(cursor)}' if cursor else ""150 q = ("query { searchFor(userQuery: %s, platform: \"web\", "151 "target: {index: CHANNEL, limit: %d%s}) "152 "{ channels { cursor edges { item { ... on User { %s } } } } } }"153 % (json.dumps(term), self.search_limit, cur, _USER_FIELDS))154 try:155 data = self._gql(q)156 channels = ((data.get("data") or {}).get("searchFor")157 or {}).get("channels") or {}158 except Exception:159 self.errors += 1160 break161 edges = channels.get("edges") or []162 for edge in edges:163 user = edge.get("item") or {}164 if user.get("login"):165 self._collect(user, "", seen, creators)166 cursor = channels.get("cursor") or ""167 if not cursor or not edges:168 break169170 # -- voie 2 : répertoire des diffusions en français (longue traîne) ----------171 def _directory(self, seen: set[str], creators: list[Creator]) -> None:172 after = ""173 for _page in range(self.stream_pages):174 aft = f', after: {json.dumps(after)}' if after else ""175 q = ("query { streams(first: 30%s, options: {languages: [FR]}) "176 "{ edges { cursor node { title broadcaster { %s } } } "177 "pageInfo { hasNextPage } } }" % (aft, _USER_FIELDS))178 try:179 data = self._gql(q)180 streams = (data.get("data") or {}).get("streams") or {}181 except Exception:182 self.errors += 1183 return184 edges = streams.get("edges") or []185 for edge in edges:186 node = edge.get("node") or {}187 user = node.get("broadcaster") or {}188 if user.get("login"):189 self._collect(user, node.get("title") or "", seen, creators)190 if not edges or not (streams.get("pageInfo") or {}).get("hasNextPage"):191 return192 after = edges[-1].get("cursor") or ""193 if not after:194 return195196 def fetch(self) -> list[Creator]:197 creators: list[Creator] = []198 seen: set[str] = set()199 self._search(seen, creators)200 try:201 self._directory(seen, creators)202 except Exception as exc: # la voie 2 ne bloque jamais la voie 1203 self.errors += 1204 print(f"[crea-ka] ⚠ twitch-decouverte : répertoire FR en échec : "205 f"{exc}", file=sys.stderr)206 return creators207