# ============================================================================= # Groupe KA — connecteurs : chaîne de fetch anti-bot RÉSILIENTE (commune) # Auteur : Simon-Pierre Boucher # Fichier : connectors/_resilient.py # ----------------------------------------------------------------------------- # But : rendre les connecteurs durables dans le temps. Quand un site jusque-là # ouvert déploie un anti-bot (Cloudflare / Akamai / Incapsula / PerimeterX) ou # renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle # ESCALADE automatiquement à travers une chaîne de secours : # # 1. Direct — la session du connecteur (curl_cffi impersonate si # dispo, sinon requests) : rapide et gratuit. # 2. Oxylabs (résid.) — proxy résidentiel Canada (-cc-CA) : IP propre. # 3. Scrapfly (ASP) — bypass anti-bot géré + rendu JS optionnel. # 4. Bright Data — Web Unlocker : déblocage premium, dernier recours. # # Le premier backend qui renvoie un 200 non vide gagne. Si TOUS échouent, on # renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur # journalise l'échec comme avant — aucun changement de comportement en cas # d'échec total, aucun blocage silencieux. # # Conception : # - Aucun effet de bord à l'import ; toute brique non configurée est sautée. # - Les clés sont lues de os.environ, avec repli sur le .env de l'app puis # ~/.claude/.env, et acceptent les deux noms Scrapfly (SCRAPFLY_KEY / # SCRAPFLY_API_KEY). => aucune modif de .env nécessaire. # - `_ResilientResponse` imite requests.Response (.text/.content/.status_code/ # .url/.headers/.json()/.ok/.raise_for_status()) : les connecteurs existants # continuent de fonctionner sans modification. # - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses # sur un même hôte, on saute l'escalade payante pendant un temps de repos # (évite de brûler du quota Scrapfly/Bright Data sur une source morte). # ============================================================================= from __future__ import annotations import json as _json import os import time from pathlib import Path from urllib.parse import quote, urlsplit import requests # -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------ try: # pragma: no cover from curl_cffi import requests as _cffi # type: ignore _HAS_CFFI = True except Exception: # noqa: BLE001 _cffi = None _HAS_CFFI = False # Codes HTTP typiques d'un blocage anti-bot (≠ 401/404/410/500 « métier » : # 401 = auth manquante, 403/429 = bot bloqué, 5xx CF = challenge/edge). BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020} # Marqueurs de page-challenge (Cloudflare/Akamai/Incapsula/PerimeterX/DataDome/ # Sucuri). Sucuri Cloudproxy sert son challenge JS en 307 SANS Location # (vu sur lassonde.com 2026-08-31) : injecte un cookie via JS obfusqué. _CHALLENGE_MARKERS = ( "just a moment", "cf-browser-verification", "cf-challenge", "attention required", "access denied", "request unsuccessful", "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome", "captcha-delivery", "please enable javascript and cookies", "checking your browser", "ddos protection by", "sucuri_cloudproxy", "you are being redirected", ) _UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") # Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs) _COOLDOWN: dict[str, tuple[float, int]] = {} _COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos _COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort » # -- chargement paresseux des secrets ---------------------------------------- _ENV_CACHE: dict[str, str] | None = None def _load_env_files() -> dict[str, str]: """Parse les .env candidats une seule fois (repli si os.environ vide).""" global _ENV_CACHE if _ENV_CACHE is not None: return _ENV_CACHE out: dict[str, str] = {} candidates = [] # .env de l'app (remonte quelques niveaux depuis ce module) here = Path(__file__).resolve() for up in range(2, 6): try: candidates.append(here.parents[up] / ".env") except IndexError: break candidates.append(Path.home() / ".claude" / ".env") for path in candidates: try: if not path.is_file(): continue for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") k, v = k.strip(), v.strip().strip('"').strip("'") # ne pas écraser une valeur déjà trouvée (priorité app > global) if k and k not in out: out[k] = v except Exception: # noqa: BLE001 continue _ENV_CACHE = out return out def _secret(*names: str) -> str | None: """Cherche une clé dans os.environ puis dans les .env (par ordre de noms).""" for n in names: v = os.environ.get(n) if v: return v env = _load_env_files() for n in names: v = env.get(n) if v: return v return None # -- réponse compatible requests.Response ------------------------------------ class _ResilientResponse: """Imite le minimum utile d'une requests.Response pour les connecteurs.""" def __init__(self, url: str, status_code: int, text: str, headers: dict | None = None, via: str = "direct") -> None: self.url = url self.status_code = int(status_code or 0) self._text = text or "" self.headers = headers or {} self.encoding = "utf-8" self.via = via # backend gagnant (diagnostic) @property def text(self) -> str: return self._text @property def content(self) -> bytes: return self._text.encode("utf-8", errors="ignore") @property def ok(self) -> bool: return 200 <= self.status_code < 400 def json(self, **kw): return _json.loads(self._text) def raise_for_status(self): if 400 <= self.status_code < 600: raise requests.HTTPError( f"{self.status_code} via {self.via} pour {self.url}", response=self) # type: ignore[arg-type] return None def __repr__(self) -> str: # pragma: no cover return f"<_ResilientResponse [{self.status_code}] via {self.via}>" # -- détection de blocage ----------------------------------------------------- def is_blocked(resp) -> bool: """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier).""" if resp is None: return True code = getattr(resp, "status_code", 0) or 0 if code in BLOCK_STATUS: return True # 200 mais page-challenge servie — ou 307 « sans Location » de Sucuri # (requests ne peut pas la suivre : le corps EST le challenge JS) if code in (200, 307): try: body = (resp.text or "")[:4000].lower() except Exception: # noqa: BLE001 return False server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else "" if any(m in body for m in _CHALLENGE_MARKERS): return True if "cloudflare" in server and ("captcha" in body or "challenge" in body): return True return False def _host(url: str) -> str: try: return urlsplit(url).netloc.lower() except Exception: # noqa: BLE001 return url def _cooling(host: str) -> bool: until, _ = _COOLDOWN.get(host, (0.0, 0)) return time.time() < until def _note_failure(host: str) -> None: until, hits = _COOLDOWN.get(host, (0.0, 0)) hits += 1 if hits >= _COOLDOWN_HITS: _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0) else: _COOLDOWN[host] = (until, hits) def _note_success(host: str) -> None: _COOLDOWN.pop(host, None) # -- backends d'escalade ------------------------------------------------------ def _try_oxylabs(url: str, timeout: int, country: str, headers: dict | None) -> _ResilientResponse | None: endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777 user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX) pwd = _secret("OXYLABS_PROXY_PASS") if not (endpoint and user and pwd): return None cc = (country or "ca").upper() puser = f"{user}-cc-{cc}" proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" proxies = {"http": proxy, "https": proxy} hdrs = {"User-Agent": _UA} if headers: hdrs.update(headers) try: r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout, verify=False) # noqa: S501 (proxy MITM du CA Oxylabs) return _ResilientResponse(url, r.status_code, r.text, dict(r.headers), via="oxylabs") except Exception: # noqa: BLE001 return None def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool, headers: dict | None) -> _ResilientResponse | None: key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY") if not key: return None params = {"key": key, "url": url, "country": country or "ca", "asp": "true", "proxy_pool": "public_residential_pool"} if render_js: params["render_js"] = "true" if headers: for k, v in headers.items(): params[f"headers[{k}]"] = v try: r = requests.get("https://api.scrapfly.io/scrape", params=params, timeout=max(timeout, 180)) result = (r.json() or {}).get("result") or {} return _ResilientResponse( url, result.get("status_code") or 0, result.get("content") or "", (result.get("response_headers") or {}), via="scrapfly") except Exception: # noqa: BLE001 return None def _try_brightdata(url: str, timeout: int, headers: dict | None) -> _ResilientResponse | None: key = _secret("BRIGHTDATA_API_KEY") zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1" if not key: return None try: r = requests.post( "https://api.brightdata.com/request", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json={"zone": zone, "url": url, "format": "raw"}, timeout=max(timeout, 120)) return _ResilientResponse(url, r.status_code, r.text, dict(r.headers), via="brightdata") except Exception: # noqa: BLE001 return None # -- API publique ------------------------------------------------------------- def escalate(url: str, *, timeout: int = 30, country: str = "ca", render_js: bool = False, headers: dict | None = None, original=None): """Tente la chaîne de secours et renvoie la meilleure réponse. Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la dernière réponse tentée (ou `original`) pour préserver le comportement d'échec du connecteur. Respecte le coupe-circuit par hôte. """ host = _host(url) if _cooling(host): return original # source au repos : on ne brûle pas de quota payant last = original for backend in ( lambda: _try_oxylabs(url, timeout, country, headers), lambda: _try_scrapfly(url, timeout, country, render_js, headers), lambda: _try_brightdata(url, timeout, headers), ): resp = backend() if resp is None: continue last = resp if resp.status_code == 200 and resp.text and not is_blocked(resp): _note_success(host) return resp time.sleep(0.4) _note_failure(host) return last if last is not None else original def escalate_if_blocked(resp, url: str, *, timeout: int = 30, country: str = "ca", render_js: bool = False, headers: dict | None = None): """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot.""" if not is_blocked(resp): return resp better = escalate(url, timeout=timeout, country=country, render_js=render_js, headers=headers, original=resp) return better if better is not None else resp