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%
8.4 KB · 207 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/connectors/base.py4# Desc:   Classe de base des connecteurs + backends de fetch (requests direct,5#         Firecrawl, Scrapfly) — calquée sur louka/connectors/base.py6# ==============================================================================7from __future__ import annotations89import json10import os11import time1213import requests1415USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "16              "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "17              "CreaKaBot/1.0 (+https://www.crea-ka.com/bot; contact@spboucher.ai)")1819FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"20SCRAPFLY_API = "https://api.scrapfly.io/scrape"212223class SkipSource(Exception):24    """Passage sauté VOLONTAIREMENT (ex. clés API absentes) — pas un échec.2526    Le pipeline (ingest.py) le journalise clairement, sans alerte de blocage27    ni run « 0 créateur » dans sync_log (§16, §18 monitoring).28    """293031class BaseConnector:32    """Un connecteur = un adaptateur propre à une source (CLAUDE.md §8).3334    Deux familles :35    - `kind = "discovery"`  : `fetch()` retourne la liste de fiches `Creator`36      découvertes (listes médias, hashtags, agences…).37    - `kind = "enrichment"` : `enrich(creators)` reçoit les fiches existantes38      et retourne celles qu'il a enrichies (profil, abonnés, liens).3940    Le pipeline (ingest.py) s'occupe du diff avec la base de données.41    Chaque connecteur documente en tête son MODE D'ACCÈS (API / Firecrawl /42    Scrapfly / link-in-bio) et sa famille (§10, §17).43    """4445    source_id: str = ""46    kind: str = "discovery"              # discovery | enrichment47    request_delay: float = 0.8           # politesse entre requêtes (§15 rate-limit)48    timeout: int = 304950    def __init__(self) -> None:51        self.session = requests.Session()52        self.session.headers["User-Agent"] = USER_AGENT53        self._last_request = 0.05455    # -- backends -------------------------------------------------------------56    def _throttle(self) -> None:57        wait = self.request_delay - (time.time() - self._last_request)58        if wait > 0:59            time.sleep(wait)6061    def get(self, url: str, **kw) -> requests.Response:62        """GET direct avec throttling poli."""63        self._throttle()64        resp = self.session.get(url, timeout=self.timeout, **kw)65        self._last_request = time.time()66        resp.raise_for_status()67        return resp6869    def post(self, url: str, **kw) -> requests.Response:70        """POST direct avec throttling poli (APIs officielles)."""71        self._throttle()72        resp = self.session.post(url, timeout=self.timeout, **kw)73        self._last_request = time.time()74        resp.raise_for_status()75        return resp7677    def get_rendered(self, url: str) -> str:78        """HTML rendu (JavaScript exécuté) via Firecrawl.7980        Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). Pour les81        pages publiques relativement ouvertes (agences, listes, blogues).82        """83        key = os.environ.get("FIRECRAWL_API_KEY")84        if not key:85            raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")86        resp = requests.post(87            FIRECRAWL_API,88            json={"url": url, "formats": ["html"]},89            headers={"Authorization": f"Bearer {key}"},90            timeout=90,91        )92        resp.raise_for_status()93        data = resp.json()94        return (data.get("data") or {}).get("html", "")9596    def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,97                 rendering_wait: int = 0, country: str = "ca",98                 wait_for_selector: str | None = None,99                 headers: dict | None = None) -> dict:100        """Appel Scrapfly — retourne le dict `result` (content, status_code…).101102        À MINIMISER (§10) : uniquement les plateformes hostiles au scraping,103        faute d'API officielle, dans le respect des CGU (§15).104        """105        key = os.environ.get("SCRAPFLY_KEY")106        if not key:107            raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")108        params: dict = {"key": key, "url": url, "country": country}109        if asp:110            params["asp"] = "true"111        if render_js:112            params["render_js"] = "true"113        if rendering_wait:114            params["rendering_wait"] = rendering_wait115        if wait_for_selector:116            params["wait_for_selector"] = wait_for_selector117        if headers:118            for k, v in headers.items():119                params[f"headers[{k}]"] = v120        self._throttle()121        resp = requests.get(SCRAPFLY_API, params=params, timeout=120)122        self._last_request = time.time()123        resp.raise_for_status()124        return resp.json().get("result") or {}125126    # -- interface ------------------------------------------------------------127    def fetch(self) -> list:128        """Découverte : liste complète des fiches `Creator` de la source."""129        raise NotImplementedError130131    def enrich(self, creators: list) -> list:132        """Enrichissement : retourne les fiches modifiées (sous-ensemble)."""133        raise NotImplementedError134135136def load_json(text: str):137    """json.loads tolérant (BOM, espaces)."""138    return json.loads(text.strip().lstrip(""))139140141# =============================================================================142# Résilience anti-bot (Groupe KA) — auto-escalade de get() sans toucher au corps.143# Ajouté par l'orchestrateur KA : enrobe BaseConnector.get pour qu'un blocage144# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne145# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir146# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).147# =============================================================================148if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):149    import requests as _ka_requests  # noqa: E402150    from . import _resilient as _kar  # noqa: E402151152    _ka_orig_get = BaseConnector.get153154    def _ka_full_url(url, kw):155        try:156            return _ka_requests.Request("GET", url,157                                        params=kw.get("params")).prepare().url158        except Exception:  # noqa: BLE001159            return url160161    def _ka_resilient_get(self, url, **kw):162        timeout = getattr(self, "timeout", 30)163        headers = kw.get("headers")164        try:165            return _ka_orig_get(self, url, **kw)166        except _ka_requests.HTTPError as exc:167            r = getattr(exc, "response", None)168            if r is not None and _kar.is_blocked(r):169                target = getattr(r, "url", None) or _ka_full_url(url, kw)170                better = _kar.escalate_if_blocked(171                    r, target, timeout=timeout, headers=headers)172                if better is not None and getattr(better, "status_code", 0) == 200:173                    return better174            raise175        except (_ka_requests.ConnectionError, _ka_requests.Timeout):176            better = _kar.escalate(_ka_full_url(url, kw),177                                   timeout=timeout, headers=headers)178            if better is not None and getattr(better, "status_code", 0) == 200:179                return better180            raise181182    def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw):183        """Fetch anti-bot explicite : force la chaîne de secours au besoin.184185        Comme get() mais tente d'abord le direct puis escalade même sur 200-186        challenge, avec rendu JS optionnel. Renvoie une réponse compatible187        requests (.text/.content/.status_code/.json()...).188        """189        timeout = getattr(self, "timeout", 30)190        headers = kw.get("headers")191        try:192            resp = _ka_orig_get(self, url, **kw)193        except _ka_requests.HTTPError as exc:194            resp = getattr(exc, "response", None)195        except (_ka_requests.ConnectionError, _ka_requests.Timeout):196            resp = None197        target = _ka_full_url(url, kw)198        if resp is not None and getattr(resp, "url", None):199            target = resp.url200        return _kar.escalate_if_blocked(resp, target, timeout=timeout,201                                        country=country, render_js=render_js,202                                        headers=headers)203204    BaseConnector.get = _ka_resilient_get205    BaseConnector.get_resilient = _ka_get_resilient206    BaseConnector._KA_RESILIENT_WRAPPED = True207