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%
6.6 KB · 148 lines python
Raw Blame History
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