SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
12.5 KB · 327 lines python
Raw Blame History
1# =============================================================================2# Groupe KA — connecteurs : chaîne de fetch anti-bot RÉSILIENTE (commune)3# Auteur : Simon-Pierre Boucher <contact@spboucher.ai>4# Fichier : connectors/_resilient.py5# -----------------------------------------------------------------------------6# But : rendre les connecteurs durables dans le temps. Quand un site jusque-là7# ouvert déploie un anti-bot (Cloudflare / Akamai / Incapsula / PerimeterX) ou8# renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle9# ESCALADE automatiquement à travers une chaîne de secours :10#11#   1. Direct            — la session du connecteur (curl_cffi impersonate si12#                          dispo, sinon requests) : rapide et gratuit.13#   2. Oxylabs (résid.)  — proxy résidentiel Canada (-cc-CA) : IP propre.14#   3. Scrapfly (ASP)    — bypass anti-bot géré + rendu JS optionnel.15#   4. Bright Data       — Web Unlocker : déblocage premium, dernier recours.16#17# Le premier backend qui renvoie un 200 non vide gagne. Si TOUS échouent, on18# renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur19# journalise l'échec comme avant — aucun changement de comportement en cas20# d'échec total, aucun blocage silencieux.21#22# Conception :23#   - Aucun effet de bord à l'import ; toute brique non configurée est sautée.24#   - Les clés sont lues de os.environ, avec repli sur le .env de l'app puis25#     ~/.claude/.env, et acceptent les deux noms Scrapfly (SCRAPFLY_KEY /26#     SCRAPFLY_API_KEY). => aucune modif de .env nécessaire.27#   - `_ResilientResponse` imite requests.Response (.text/.content/.status_code/28#     .url/.headers/.json()/.ok/.raise_for_status()) : les connecteurs existants29#     continuent de fonctionner sans modification.30#   - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses31#     sur un même hôte, on saute l'escalade payante pendant un temps de repos32#     (évite de brûler du quota Scrapfly/Bright Data sur une source morte).33# =============================================================================34from __future__ import annotations3536import json as _json37import os38import time39from pathlib import Path40from urllib.parse import quote, urlsplit4142import requests4344# -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------45try:  # pragma: no cover46    from curl_cffi import requests as _cffi  # type: ignore47    _HAS_CFFI = True48except Exception:  # noqa: BLE00149    _cffi = None50    _HAS_CFFI = False5152# Codes HTTP typiques d'un blocage anti-bot (≠ 401/404/410/500 « métier » :53# 401 = auth manquante, 403/429 = bot bloqué, 5xx CF = challenge/edge).54BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020}5556# Marqueurs de page-challenge (Cloudflare/Akamai/Incapsula/PerimeterX/DataDome/57# SiteGround sgcaptcha — ce dernier répond 202 + meta refresh vers58# /.well-known/sgcaptcha/ et l'en-tête `sg-captcha: challenge`).59_CHALLENGE_MARKERS = (60    "just a moment", "cf-browser-verification", "cf-challenge",61    "attention required", "access denied", "request unsuccessful",62    "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome",63    "captcha-delivery", "please enable javascript and cookies",64    "checking your browser", "ddos protection by", "sgcaptcha",65)6667_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "68       "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")6970# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs)71_COOLDOWN: dict[str, tuple[float, int]] = {}72_COOLDOWN_HITS = 3           # nb d'échecs totaux avant repos73_COOLDOWN_SECONDS = 900.0    # 15 min de repos pour un hôte « mort »7475# -- chargement paresseux des secrets ----------------------------------------76_ENV_CACHE: dict[str, str] | None = None777879def _load_env_files() -> dict[str, str]:80    """Parse les .env candidats une seule fois (repli si os.environ vide)."""81    global _ENV_CACHE82    if _ENV_CACHE is not None:83        return _ENV_CACHE84    out: dict[str, str] = {}85    candidates = []86    # .env de l'app (remonte quelques niveaux depuis ce module)87    here = Path(__file__).resolve()88    for up in range(2, 6):89        try:90            candidates.append(here.parents[up] / ".env")91        except IndexError:92            break93    candidates.append(Path.home() / ".claude" / ".env")94    for path in candidates:95        try:96            if not path.is_file():97                continue98            for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():99                line = line.strip()100                if not line or line.startswith("#") or "=" not in line:101                    continue102                k, _, v = line.partition("=")103                k, v = k.strip(), v.strip().strip('"').strip("'")104                # ne pas écraser une valeur déjà trouvée (priorité app > global)105                if k and k not in out:106                    out[k] = v107        except Exception:  # noqa: BLE001108            continue109    _ENV_CACHE = out110    return out111112113def _secret(*names: str) -> str | None:114    """Cherche une clé dans os.environ puis dans les .env (par ordre de noms)."""115    for n in names:116        v = os.environ.get(n)117        if v:118            return v119    env = _load_env_files()120    for n in names:121        v = env.get(n)122        if v:123            return v124    return None125126127# -- réponse compatible requests.Response ------------------------------------128class _ResilientResponse:129    """Imite le minimum utile d'une requests.Response pour les connecteurs."""130131    def __init__(self, url: str, status_code: int, text: str,132                 headers: dict | None = None, via: str = "direct") -> None:133        self.url = url134        self.status_code = int(status_code or 0)135        self._text = text or ""136        self.headers = headers or {}137        self.encoding = "utf-8"138        self.via = via  # backend gagnant (diagnostic)139140    @property141    def text(self) -> str:142        return self._text143144    @property145    def content(self) -> bytes:146        return self._text.encode("utf-8", errors="ignore")147148    @property149    def ok(self) -> bool:150        return 200 <= self.status_code < 400151152    def json(self, **kw):153        return _json.loads(self._text)154155    def raise_for_status(self):156        if 400 <= self.status_code < 600:157            raise requests.HTTPError(158                f"{self.status_code} via {self.via} pour {self.url}",159                response=self)  # type: ignore[arg-type]160        return None161162    def __repr__(self) -> str:  # pragma: no cover163        return f"<_ResilientResponse [{self.status_code}] via {self.via}>"164165166# -- détection de blocage -----------------------------------------------------167def is_blocked(resp) -> bool:168    """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier)."""169    if resp is None:170        return True171    code = getattr(resp, "status_code", 0) or 0172    if code in BLOCK_STATUS:173        return True174    headers = getattr(resp, "headers", None) or {}175    try:176        if "challenge" in str(headers.get("sg-captcha", "")).lower():177            return True                       # SiteGround sgcaptcha (202)178    except Exception:  # noqa: BLE001179        pass180    # 200/202 mais page-challenge servie181    if code in (200, 202):182        try:183            body = (resp.text or "")[:4000].lower()184        except Exception:  # noqa: BLE001185            return False186        server = str(headers.get("Server", "")).lower()187        if any(m in body for m in _CHALLENGE_MARKERS):188            return True189        if "cloudflare" in server and ("captcha" in body or "challenge" in body):190            return True191    return False192193194def _host(url: str) -> str:195    try:196        return urlsplit(url).netloc.lower()197    except Exception:  # noqa: BLE001198        return url199200201def _cooling(host: str) -> bool:202    until, _ = _COOLDOWN.get(host, (0.0, 0))203    return time.time() < until204205206def _note_failure(host: str) -> None:207    until, hits = _COOLDOWN.get(host, (0.0, 0))208    hits += 1209    if hits >= _COOLDOWN_HITS:210        _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0)211    else:212        _COOLDOWN[host] = (until, hits)213214215def _note_success(host: str) -> None:216    _COOLDOWN.pop(host, None)217218219# -- backends d'escalade ------------------------------------------------------220def _try_oxylabs(url: str, timeout: int, country: str,221                 headers: dict | None) -> _ResilientResponse | None:222    endpoint = _secret("OXYLABS_PROXY")            # pr.oxylabs.io:7777223    user = _secret("OXYLABS_PROXY_USER")           # customer-...  (sans -cc-XX)224    pwd = _secret("OXYLABS_PROXY_PASS")225    if not (endpoint and user and pwd):226        return None227    cc = (country or "ca").upper()228    puser = f"{user}-cc-{cc}"229    proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"230    proxies = {"http": proxy, "https": proxy}231    hdrs = {"User-Agent": _UA}232    if headers:233        hdrs.update(headers)234    try:235        r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout,236                         verify=False)  # noqa: S501 (proxy MITM du CA Oxylabs)237        return _ResilientResponse(url, r.status_code, r.text,238                                  dict(r.headers), via="oxylabs")239    except Exception:  # noqa: BLE001240        return None241242243def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool,244                  headers: dict | None) -> _ResilientResponse | None:245    key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY")246    if not key:247        return None248    params = {"key": key, "url": url, "country": country or "ca",249              "asp": "true", "proxy_pool": "public_residential_pool"}250    if render_js:251        params["render_js"] = "true"252    if headers:253        for k, v in headers.items():254            params[f"headers[{k}]"] = v255    try:256        r = requests.get("https://api.scrapfly.io/scrape", params=params,257                         timeout=max(timeout, 180))258        result = (r.json() or {}).get("result") or {}259        return _ResilientResponse(260            url, result.get("status_code") or 0, result.get("content") or "",261            (result.get("response_headers") or {}), via="scrapfly")262    except Exception:  # noqa: BLE001263        return None264265266def _try_brightdata(url: str, timeout: int,267                    headers: dict | None) -> _ResilientResponse | None:268    key = _secret("BRIGHTDATA_API_KEY")269    zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1"270    if not key:271        return None272    try:273        r = requests.post(274            "https://api.brightdata.com/request",275            headers={"Authorization": f"Bearer {key}",276                     "Content-Type": "application/json"},277            json={"zone": zone, "url": url, "format": "raw"},278            timeout=max(timeout, 120))279        return _ResilientResponse(url, r.status_code, r.text,280                                  dict(r.headers), via="brightdata")281    except Exception:  # noqa: BLE001282        return None283284285# -- API publique -------------------------------------------------------------286def escalate(url: str, *, timeout: int = 30, country: str = "ca",287             render_js: bool = False, headers: dict | None = None,288             original=None):289    """Tente la chaîne de secours et renvoie la meilleure réponse.290291    Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la292    dernière réponse tentée (ou `original`) pour préserver le comportement293    d'échec du connecteur. Respecte le coupe-circuit par hôte.294    """295    host = _host(url)296    if _cooling(host):297        return original  # source au repos : on ne brûle pas de quota payant298299    last = original300    for backend in (301        lambda: _try_oxylabs(url, timeout, country, headers),302        lambda: _try_scrapfly(url, timeout, country, render_js, headers),303        lambda: _try_brightdata(url, timeout, headers),304    ):305        resp = backend()306        if resp is None:307            continue308        last = resp309        if resp.status_code == 200 and resp.text and not is_blocked(resp):310            _note_success(host)311            return resp312        time.sleep(0.4)313314    _note_failure(host)315    return last if last is not None else original316317318def escalate_if_blocked(resp, url: str, *, timeout: int = 30,319                        country: str = "ca", render_js: bool = False,320                        headers: dict | None = None):321    """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot."""322    if not is_blocked(resp):323        return resp324    better = escalate(url, timeout=timeout, country=country,325                      render_js=render_js, headers=headers, original=resp)326    return better if better is not None else resp327