Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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_CHALLENGE_MARKERS = (58 "just a moment", "cf-browser-verification", "cf-challenge",59 "attention required", "access denied", "request unsuccessful",60 "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome",61 "captcha-delivery", "please enable javascript and cookies",62 "checking your browser", "ddos protection by",63 # SiteGround : challenge JS servi en HTTP 202 (meta-refresh vers64 # /.well-known/sgcaptcha/ puis page « Robot Challenge Screen »)65 "sgcaptcha", "robot challenge screen",66)6768_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "69 "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")7071# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs)72_COOLDOWN: dict[str, tuple[float, int]] = {}73_COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos74_COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort »7576# -- chargement paresseux des secrets ----------------------------------------77_ENV_CACHE: dict[str, str] | None = None787980def _load_env_files() -> dict[str, str]:81 """Parse les .env candidats une seule fois (repli si os.environ vide)."""82 global _ENV_CACHE83 if _ENV_CACHE is not None:84 return _ENV_CACHE85 out: dict[str, str] = {}86 candidates = []87 # .env de l'app (remonte quelques niveaux depuis ce module)88 here = Path(__file__).resolve()89 for up in range(2, 6):90 try:91 candidates.append(here.parents[up] / ".env")92 except IndexError:93 break94 candidates.append(Path.home() / ".claude" / ".env")95 for path in candidates:96 try:97 if not path.is_file():98 continue99 for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():100 line = line.strip()101 if not line or line.startswith("#") or "=" not in line:102 continue103 k, _, v = line.partition("=")104 k, v = k.strip(), v.strip().strip('"').strip("'")105 # ne pas écraser une valeur déjà trouvée (priorité app > global)106 if k and k not in out:107 out[k] = v108 except Exception: # noqa: BLE001109 continue110 _ENV_CACHE = out111 return out112113114def _secret(*names: str) -> str | None:115 """Cherche une clé dans os.environ puis dans les .env (par ordre de noms)."""116 for n in names:117 v = os.environ.get(n)118 if v:119 return v120 env = _load_env_files()121 for n in names:122 v = env.get(n)123 if v:124 return v125 return None126127128# -- réponse compatible requests.Response ------------------------------------129class _ResilientResponse:130 """Imite le minimum utile d'une requests.Response pour les connecteurs."""131132 def __init__(self, url: str, status_code: int, text: str,133 headers: dict | None = None, via: str = "direct") -> None:134 self.url = url135 self.status_code = int(status_code or 0)136 self._text = text or ""137 self.headers = headers or {}138 self.encoding = "utf-8"139 self.via = via # backend gagnant (diagnostic)140141 @property142 def text(self) -> str:143 return self._text144145 @property146 def content(self) -> bytes:147 return self._text.encode("utf-8", errors="ignore")148149 @property150 def ok(self) -> bool:151 return 200 <= self.status_code < 400152153 def json(self, **kw):154 return _json.loads(self._text)155156 def raise_for_status(self):157 if 400 <= self.status_code < 600:158 raise requests.HTTPError(159 f"{self.status_code} via {self.via} pour {self.url}",160 response=self) # type: ignore[arg-type]161 return None162163 def __repr__(self) -> str: # pragma: no cover164 return f"<_ResilientResponse [{self.status_code}] via {self.via}>"165166167# -- détection de blocage -----------------------------------------------------168def is_blocked(resp) -> bool:169 """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier)."""170 if resp is None:171 return True172 code = getattr(resp, "status_code", 0) or 0173 if code in BLOCK_STATUS:174 return True175 # 200 (ou 202 SiteGround) mais page-challenge servie176 if code in (200, 202):177 try:178 body = (resp.text or "")[:4000].lower()179 except Exception: # noqa: BLE001180 return False181 server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else ""182 if any(m in body for m in _CHALLENGE_MARKERS):183 return True184 if "cloudflare" in server and ("captcha" in body or "challenge" in body):185 return True186 return False187188189def _host(url: str) -> str:190 try:191 return urlsplit(url).netloc.lower()192 except Exception: # noqa: BLE001193 return url194195196def _cooling(host: str) -> bool:197 until, _ = _COOLDOWN.get(host, (0.0, 0))198 return time.time() < until199200201def _note_failure(host: str) -> None:202 until, hits = _COOLDOWN.get(host, (0.0, 0))203 hits += 1204 if hits >= _COOLDOWN_HITS:205 _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0)206 else:207 _COOLDOWN[host] = (until, hits)208209210def _note_success(host: str) -> None:211 _COOLDOWN.pop(host, None)212213214# -- backends d'escalade ------------------------------------------------------215def _try_oxylabs(url: str, timeout: int, country: str,216 headers: dict | None) -> _ResilientResponse | None:217 endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777218 user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX)219 pwd = _secret("OXYLABS_PROXY_PASS")220 if not (endpoint and user and pwd):221 return None222 cc = (country or "ca").upper()223 puser = f"{user}-cc-{cc}"224 proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"225 proxies = {"http": proxy, "https": proxy}226 hdrs = {"User-Agent": _UA}227 if headers:228 hdrs.update(headers)229 try:230 r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout,231 verify=False) # noqa: S501 (proxy MITM du CA Oxylabs)232 return _ResilientResponse(url, r.status_code, r.text,233 dict(r.headers), via="oxylabs")234 except Exception: # noqa: BLE001235 return None236237238def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool,239 headers: dict | None) -> _ResilientResponse | None:240 key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY")241 if not key:242 return None243 params = {"key": key, "url": url, "country": country or "ca",244 "asp": "true", "proxy_pool": "public_residential_pool"}245 if render_js:246 params["render_js"] = "true"247 if headers:248 for k, v in headers.items():249 params[f"headers[{k}]"] = v250 try:251 r = requests.get("https://api.scrapfly.io/scrape", params=params,252 timeout=max(timeout, 180))253 result = (r.json() or {}).get("result") or {}254 return _ResilientResponse(255 url, result.get("status_code") or 0, result.get("content") or "",256 (result.get("response_headers") or {}), via="scrapfly")257 except Exception: # noqa: BLE001258 return None259260261def _try_brightdata(url: str, timeout: int,262 headers: dict | None) -> _ResilientResponse | None:263 key = _secret("BRIGHTDATA_API_KEY")264 zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1"265 if not key:266 return None267 try:268 r = requests.post(269 "https://api.brightdata.com/request",270 headers={"Authorization": f"Bearer {key}",271 "Content-Type": "application/json"},272 json={"zone": zone, "url": url, "format": "raw"},273 timeout=max(timeout, 120))274 return _ResilientResponse(url, r.status_code, r.text,275 dict(r.headers), via="brightdata")276 except Exception: # noqa: BLE001277 return None278279280# -- API publique -------------------------------------------------------------281def escalate(url: str, *, timeout: int = 30, country: str = "ca",282 render_js: bool = False, headers: dict | None = None,283 original=None):284 """Tente la chaîne de secours et renvoie la meilleure réponse.285286 Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la287 dernière réponse tentée (ou `original`) pour préserver le comportement288 d'échec du connecteur. Respecte le coupe-circuit par hôte.289 """290 host = _host(url)291 if _cooling(host):292 return original # source au repos : on ne brûle pas de quota payant293294 last = original295 for backend in (296 lambda: _try_oxylabs(url, timeout, country, headers),297 lambda: _try_scrapfly(url, timeout, country, render_js, headers),298 lambda: _try_brightdata(url, timeout, headers),299 ):300 resp = backend()301 if resp is None:302 continue303 last = resp304 if resp.status_code == 200 and resp.text and not is_blocked(resp):305 _note_success(host)306 return resp307 time.sleep(0.4)308309 _note_failure(host)310 return last if last is not None else original311312313def escalate_if_blocked(resp, url: str, *, timeout: int = 30,314 country: str = "ca", render_js: bool = False,315 headers: dict | None = None):316 """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot."""317 if not is_blocked(resp):318 return resp319 better = escalate(url, timeout=timeout, country=country,320 render_js=render_js, headers=headers, original=resp)321 return better if better is not None else resp322