# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/kick_decouverte.py # Desc: Connecteur DÉCOUVERTE — streamers Kick québécois via l'API publique # de recherche du site (kick.com/api/search) + fiche v2 par chaîne # (liens sociaux AUTO-DÉCLARÉS par le créateur → cross_link 0.90). # Mode d'accès : Scrapfly ASP sans render_js (Cloudflare bloque les # requêtes directes — 403). Palier 4 (§9/§10). # ============================================================================== """Découverte des streamers Kick québécois. ``kick.com/api/search?searched_word=…`` retourne pour chaque chaîne : slug, abonnés, bio, photo de profil. ``kick.com/api/v2/channels/{slug}`` ajoute le badge vérifié et surtout les LIENS SOCIAUX que le créateur a lui-même remplis dans son profil (instagram/twitter/youtube/tiktok/facebook/discord) → signal cross_link (0.90), la meilleure matière première du rattachement (§12.1). Filtre conservateur : marqueur québécois OBLIGATOIRE dans le pseudo, le slug ou la bio (§7/§15). La fiche v2 n'est demandée QUE pour les chaînes retenues (économie Scrapfly, §10 — chaque requête passe par l'ASP anti-bot). """ from __future__ import annotations import json import sys from ..identity import account from ..normalize import platform_from_url, slugify from ..schema import Creator from .base import BaseConnector from .youtube_recherche import is_quebec SEARCH_URL = "https://kick.com/api/search?searched_word={term}" CHANNEL_URL = "https://kick.com/api/v2/channels/{slug}" SEARCH_TERMS = [ "quebec", "quebecois", "quebecoise", "queb", "montreal", "mtl", "saguenay", "gaspesie", "mauricie", "estrie", "outaouais", "abitibi", "gatineau", "sherbrooke", "levis", "longueuil", "rimouski", "chicoutimi", "trois-rivieres", "drummondville", "lanaudiere", "laurentides", "monteregie", "beauce", "charlevoix", "riv-nord", "rive-sud", ] # clés du profil v2 → plateformes §6.2 (liens auto-déclarés par le créateur) _SOCIAL_KEYS = ("instagram", "twitter", "youtube", "tiktok", "facebook", "discord") class KickDecouverteConnector(BaseConnector): source_id = "kick-decouverte" kind = "discovery" request_delay = 1.5 max_details = 120 # fiches v2 par passage (budget Scrapfly, §10) def __init__(self) -> None: super().__init__() self.errors = 0 def _json(self, url: str) -> dict | list | None: """GET via Scrapfly ASP (sans render_js — l'API répond en JSON).""" result = self.scrapfly(url, render_js=False, asp=True) if (result.get("status_code") or 0) != 200: return None try: return json.loads(result.get("content") or "") except Exception: return None def _socials(self, user: dict) -> list: """Liens sociaux auto-déclarés du profil v2 → comptes cross_link.""" accounts = [] for key in _SOCIAL_KEYS: raw = (user.get(key) or "").strip() if not raw: continue # le champ contient tantôt une URL, tantôt un simple handle if "://" in raw or "." in raw.split("/")[0]: parsed = platform_from_url(raw) if parsed: accounts.append(account(parsed[0], parsed[1], "cross_link")) else: platform = "x" if key == "twitter" else key accounts.append(account(platform, raw, "cross_link")) return accounts def fetch(self) -> list[Creator]: # 1) recherche par termes QC → candidats (slug, bio, abonnés, avatar) candidates: dict[str, dict] = {} for term in SEARCH_TERMS: try: data = self._json(SEARCH_URL.format(term=term)) except Exception: self.errors += 1 continue for ch in (data or {}).get("channels") or []: slug = (ch.get("slug") or "").strip().lower() user = ch.get("user") or {} name = (user.get("username") or slug).strip() bio = (user.get("bio") or "").strip() if not slug or slug in candidates or ch.get("is_banned"): continue # filtre conservateur : marqueur QC requis (pseudo/slug/bio) if not is_quebec(f"{name} {slug} {bio}"): continue candidates[slug] = { "name": name, "bio": bio, "followers": ch.get("followers_count"), "avatar": user.get("profilePic") or user.get("profile_pic"), } # 2) fiche v2 pour les retenus → badge + liens sociaux auto-déclarés creators: list[Creator] = [] for slug, cand in list(candidates.items())[:self.max_details]: verified, socials = None, [] try: detail = self._json(CHANNEL_URL.format(slug=slug)) except Exception: self.errors += 1 detail = None if isinstance(detail, dict): verified = bool(detail.get("verified")) or None user = detail.get("user") or {} socials = self._socials(user) cand["bio"] = (user.get("bio") or cand["bio"] or "").strip() cand["avatar"] = user.get("profile_pic") or cand["avatar"] if detail.get("followers_count") is not None: cand["followers"] = detail["followers_count"] acc = account("kick", slug, "profil_source", followers=cand["followers"], verified=verified) avatar = cand.get("avatar") or "" creators.append(Creator( source=self.source_id, external_id=slugify(cand["name"]), display_name=cand["name"], bio=(cand["bio"] or "")[:1200], niches=["gaming"], creator_type="streamer", primary_platform="kick", avatar_url=avatar if avatar.startswith("http") else None, platforms=[acc, *socials], source_ids=[f"{self.source_id}:{slug}"], )) dropped = len(candidates) - min(len(candidates), self.max_details) if dropped > 0: print(f"[crea-ka] kick-decouverte : {dropped} candidat(s) au-delà " f"du budget v2 ({self.max_details}) — repris au prochain " f"passage", file=sys.stderr) return creators