SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
9.4 KB · 207 lines python
Raw Blame History
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