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/kick_decouverte.py4# Desc: Connecteur DÉCOUVERTE — streamers Kick québécois via l'API publique5# de recherche du site (kick.com/api/search) + fiche v2 par chaîne6# (liens sociaux AUTO-DÉCLARÉS par le créateur → cross_link 0.90).7# Mode d'accès : Scrapfly ASP sans render_js (Cloudflare bloque les8# requêtes directes — 403). Palier 4 (§9/§10).9# ==============================================================================10"""Découverte des streamers Kick québécois.1112``kick.com/api/search?searched_word=…`` retourne pour chaque chaîne : slug,13abonnés, bio, photo de profil. ``kick.com/api/v2/channels/{slug}`` ajoute le14badge vérifié et surtout les LIENS SOCIAUX que le créateur a lui-même remplis15dans son profil (instagram/twitter/youtube/tiktok/facebook/discord) → signal16cross_link (0.90), la meilleure matière première du rattachement (§12.1).1718Filtre conservateur : marqueur québécois OBLIGATOIRE dans le pseudo, le slug19ou la bio (§7/§15). La fiche v2 n'est demandée QUE pour les chaînes retenues20(économie Scrapfly, §10 — chaque requête passe par l'ASP anti-bot).21"""22from __future__ import annotations2324import json25import sys2627from ..identity import account28from ..normalize import platform_from_url, slugify29from ..schema import Creator30from .base import BaseConnector31from .youtube_recherche import is_quebec3233SEARCH_URL = "https://kick.com/api/search?searched_word={term}"34CHANNEL_URL = "https://kick.com/api/v2/channels/{slug}"3536SEARCH_TERMS = [37 "quebec", "quebecois", "quebecoise", "queb", "montreal", "mtl",38 "saguenay", "gaspesie", "mauricie", "estrie", "outaouais", "abitibi",39 "gatineau", "sherbrooke", "levis", "longueuil", "rimouski", "chicoutimi",40 "trois-rivieres", "drummondville", "lanaudiere", "laurentides",41 "monteregie", "beauce", "charlevoix", "riv-nord", "rive-sud",42]4344# clés du profil v2 → plateformes §6.2 (liens auto-déclarés par le créateur)45_SOCIAL_KEYS = ("instagram", "twitter", "youtube", "tiktok", "facebook",46 "discord")474849class KickDecouverteConnector(BaseConnector):50 source_id = "kick-decouverte"51 kind = "discovery"52 request_delay = 1.553 max_details = 120 # fiches v2 par passage (budget Scrapfly, §10)5455 def __init__(self) -> None:56 super().__init__()57 self.errors = 05859 def _json(self, url: str) -> dict | list | None:60 """GET via Scrapfly ASP (sans render_js — l'API répond en JSON)."""61 result = self.scrapfly(url, render_js=False, asp=True)62 if (result.get("status_code") or 0) != 200:63 return None64 try:65 return json.loads(result.get("content") or "")66 except Exception:67 return None6869 def _socials(self, user: dict) -> list:70 """Liens sociaux auto-déclarés du profil v2 → comptes cross_link."""71 accounts = []72 for key in _SOCIAL_KEYS:73 raw = (user.get(key) or "").strip()74 if not raw:75 continue76 # le champ contient tantôt une URL, tantôt un simple handle77 if "://" in raw or "." in raw.split("/")[0]:78 parsed = platform_from_url(raw)79 if parsed:80 accounts.append(account(parsed[0], parsed[1], "cross_link"))81 else:82 platform = "x" if key == "twitter" else key83 accounts.append(account(platform, raw, "cross_link"))84 return accounts8586 def fetch(self) -> list[Creator]:87 # 1) recherche par termes QC → candidats (slug, bio, abonnés, avatar)88 candidates: dict[str, dict] = {}89 for term in SEARCH_TERMS:90 try:91 data = self._json(SEARCH_URL.format(term=term))92 except Exception:93 self.errors += 194 continue95 for ch in (data or {}).get("channels") or []:96 slug = (ch.get("slug") or "").strip().lower()97 user = ch.get("user") or {}98 name = (user.get("username") or slug).strip()99 bio = (user.get("bio") or "").strip()100 if not slug or slug in candidates or ch.get("is_banned"):101 continue102 # filtre conservateur : marqueur QC requis (pseudo/slug/bio)103 if not is_quebec(f"{name} {slug} {bio}"):104 continue105 candidates[slug] = {106 "name": name, "bio": bio,107 "followers": ch.get("followers_count"),108 "avatar": user.get("profilePic") or user.get("profile_pic"),109 }110 # 2) fiche v2 pour les retenus → badge + liens sociaux auto-déclarés111 creators: list[Creator] = []112 for slug, cand in list(candidates.items())[:self.max_details]:113 verified, socials = None, []114 try:115 detail = self._json(CHANNEL_URL.format(slug=slug))116 except Exception:117 self.errors += 1118 detail = None119 if isinstance(detail, dict):120 verified = bool(detail.get("verified")) or None121 user = detail.get("user") or {}122 socials = self._socials(user)123 cand["bio"] = (user.get("bio") or cand["bio"] or "").strip()124 cand["avatar"] = user.get("profile_pic") or cand["avatar"]125 if detail.get("followers_count") is not None:126 cand["followers"] = detail["followers_count"]127 acc = account("kick", slug, "profil_source",128 followers=cand["followers"], verified=verified)129 avatar = cand.get("avatar") or ""130 creators.append(Creator(131 source=self.source_id,132 external_id=slugify(cand["name"]),133 display_name=cand["name"],134 bio=(cand["bio"] or "")[:1200],135 niches=["gaming"],136 creator_type="streamer",137 primary_platform="kick",138 avatar_url=avatar if avatar.startswith("http") else None,139 platforms=[acc, *socials],140 source_ids=[f"{self.source_id}:{slug}"],141 ))142 dropped = len(candidates) - min(len(candidates), self.max_details)143 if dropped > 0:144 print(f"[crea-ka] kick-decouverte : {dropped} candidat(s) au-delà "145 f"du budget v2 ({self.max_details}) — repris au prochain "146 f"passage", file=sys.stderr)147 return creators148