| |
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.py |
| |
5 | +# ----------------------------------------------------------------------------- |
| |
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) ou |
| |
8 | +# renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle |
| |
9 | +# ESCALADE automatiquement à travers une chaîne de secours : |
| |
10 | +# |
| |
11 | +# 1. Direct — la session du connecteur (curl_cffi impersonate si |
| |
12 | +# 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, on |
| |
18 | +# renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur |
| |
19 | +# journalise l'échec comme avant — aucun changement de comportement en cas |
| |
20 | +# 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 puis |
| |
25 | +# ~/.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 existants |
| |
29 | +# continuent de fonctionner sans modification. |
| |
30 | +# - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses |
| |
31 | +# sur un même hôte, on saute l'escalade payante pendant un temps de repos |
| |
32 | +# (évite de brûler du quota Scrapfly/Bright Data sur une source morte). |
| |
33 | +# ============================================================================= |
| |
34 | +from __future__ import annotations |
| |
35 | + |
| |
36 | +import json as _json |
| |
37 | +import os |
| |
38 | +import time |
| |
39 | +from pathlib import Path |
| |
40 | +from urllib.parse import quote, urlsplit |
| |
41 | + |
| |
42 | +import requests |
| |
43 | + |
| |
44 | +# -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------ |
| |
45 | +try: # pragma: no cover |
| |
46 | + from curl_cffi import requests as _cffi # type: ignore |
| |
47 | + _HAS_CFFI = True |
| |
48 | +except Exception: # noqa: BLE001 |
| |
49 | + _cffi = None |
| |
50 | + _HAS_CFFI = False |
| |
51 | + |
| |
52 | +# 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). |
| |
54 | +BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020} |
| |
55 | + |
| |
56 | +# 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 | +) |
| |
64 | + |
| |
65 | +_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| |
66 | + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| |
67 | + |
| |
68 | +# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs) |
| |
69 | +_COOLDOWN: dict[str, tuple[float, int]] = {} |
| |
70 | +_COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos |
| |
71 | +_COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort » |
| |
72 | + |
| |
73 | +# -- chargement paresseux des secrets ---------------------------------------- |
| |
74 | +_ENV_CACHE: dict[str, str] | None = None |
| |
75 | + |
| |
76 | + |
| |
77 | +def _load_env_files() -> dict[str, str]: |
| |
78 | + """Parse les .env candidats une seule fois (repli si os.environ vide).""" |
| |
79 | + global _ENV_CACHE |
| |
80 | + if _ENV_CACHE is not None: |
| |
81 | + return _ENV_CACHE |
| |
82 | + out: dict[str, str] = {} |
| |
83 | + candidates = [] |
| |
84 | + # .env de l'app (remonte quelques niveaux depuis ce module) |
| |
85 | + here = Path(__file__).resolve() |
| |
86 | + for up in range(2, 6): |
| |
87 | + try: |
| |
88 | + candidates.append(here.parents[up] / ".env") |
| |
89 | + except IndexError: |
| |
90 | + break |
| |
91 | + candidates.append(Path.home() / ".claude" / ".env") |
| |
92 | + for path in candidates: |
| |
93 | + try: |
| |
94 | + if not path.is_file(): |
| |
95 | + continue |
| |
96 | + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): |
| |
97 | + line = line.strip() |
| |
98 | + if not line or line.startswith("#") or "=" not in line: |
| |
99 | + continue |
| |
100 | + k, _, v = line.partition("=") |
| |
101 | + k, v = k.strip(), v.strip().strip('"').strip("'") |
| |
102 | + # ne pas écraser une valeur déjà trouvée (priorité app > global) |
| |
103 | + if k and k not in out: |
| |
104 | + out[k] = v |
| |
105 | + except Exception: # noqa: BLE001 |
| |
106 | + continue |
| |
107 | + _ENV_CACHE = out |
| |
108 | + return out |
| |
109 | + |
| |
110 | + |
| |
111 | +def _secret(*names: str) -> str | None: |
| |
112 | + """Cherche une clé dans os.environ puis dans les .env (par ordre de noms).""" |
| |
113 | + for n in names: |
| |
114 | + v = os.environ.get(n) |
| |
115 | + if v: |
| |
116 | + return v |
| |
117 | + env = _load_env_files() |
| |
118 | + for n in names: |
| |
119 | + v = env.get(n) |
| |
120 | + if v: |
| |
121 | + return v |
| |
122 | + return None |
| |
123 | + |
| |
124 | + |
| |
125 | +# -- réponse compatible requests.Response ------------------------------------ |
| |
126 | +class _ResilientResponse: |
| |
127 | + """Imite le minimum utile d'une requests.Response pour les connecteurs.""" |
| |
128 | + |
| |
129 | + def __init__(self, url: str, status_code: int, text: str, |
| |
130 | + headers: dict | None = None, via: str = "direct") -> None: |
| |
131 | + self.url = url |
| |
132 | + self.status_code = int(status_code or 0) |
| |
133 | + self._text = text or "" |
| |
134 | + self.headers = headers or {} |
| |
135 | + self.encoding = "utf-8" |
| |
136 | + self.via = via # backend gagnant (diagnostic) |
| |
137 | + |
| |
138 | + @property |
| |
139 | + def text(self) -> str: |
| |
140 | + return self._text |
| |
141 | + |
| |
142 | + @property |
| |
143 | + def content(self) -> bytes: |
| |
144 | + return self._text.encode("utf-8", errors="ignore") |
| |
145 | + |
| |
146 | + @property |
| |
147 | + def ok(self) -> bool: |
| |
148 | + return 200 <= self.status_code < 400 |
| |
149 | + |
| |
150 | + def json(self, **kw): |
| |
151 | + return _json.loads(self._text) |
| |
152 | + |
| |
153 | + def raise_for_status(self): |
| |
154 | + if 400 <= self.status_code < 600: |
| |
155 | + raise requests.HTTPError( |
| |
156 | + f"{self.status_code} via {self.via} pour {self.url}", |
| |
157 | + response=self) # type: ignore[arg-type] |
| |
158 | + return None |
| |
159 | + |
| |
160 | + def __repr__(self) -> str: # pragma: no cover |
| |
161 | + return f"<_ResilientResponse [{self.status_code}] via {self.via}>" |
| |
162 | + |
| |
163 | + |
| |
164 | +# -- détection de blocage ----------------------------------------------------- |
| |
165 | +def is_blocked(resp) -> bool: |
| |
166 | + """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier).""" |
| |
167 | + if resp is None: |
| |
168 | + return True |
| |
169 | + code = getattr(resp, "status_code", 0) or 0 |
| |
170 | + if code in BLOCK_STATUS: |
| |
171 | + return True |
| |
172 | + # 200 mais page-challenge servie |
| |
173 | + if code == 200: |
| |
174 | + try: |
| |
175 | + body = (resp.text or "")[:4000].lower() |
| |
176 | + except Exception: # noqa: BLE001 |
| |
177 | + return False |
| |
178 | + server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else "" |
| |
179 | + if any(m in body for m in _CHALLENGE_MARKERS): |
| |
180 | + return True |
| |
181 | + if "cloudflare" in server and ("captcha" in body or "challenge" in body): |
| |
182 | + return True |
| |
183 | + return False |
| |
184 | + |
| |
185 | + |
| |
186 | +def _host(url: str) -> str: |
| |
187 | + try: |
| |
188 | + return urlsplit(url).netloc.lower() |
| |
189 | + except Exception: # noqa: BLE001 |
| |
190 | + return url |
| |
191 | + |
| |
192 | + |
| |
193 | +def _cooling(host: str) -> bool: |
| |
194 | + until, _ = _COOLDOWN.get(host, (0.0, 0)) |
| |
195 | + return time.time() < until |
| |
196 | + |
| |
197 | + |
| |
198 | +def _note_failure(host: str) -> None: |
| |
199 | + until, hits = _COOLDOWN.get(host, (0.0, 0)) |
| |
200 | + hits += 1 |
| |
201 | + if hits >= _COOLDOWN_HITS: |
| |
202 | + _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0) |
| |
203 | + else: |
| |
204 | + _COOLDOWN[host] = (until, hits) |
| |
205 | + |
| |
206 | + |
| |
207 | +def _note_success(host: str) -> None: |
| |
208 | + _COOLDOWN.pop(host, None) |
| |
209 | + |
| |
210 | + |
| |
211 | +# -- backends d'escalade ------------------------------------------------------ |
| |
212 | +def _try_oxylabs(url: str, timeout: int, country: str, |
| |
213 | + headers: dict | None) -> _ResilientResponse | None: |
| |
214 | + endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777 |
| |
215 | + user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX) |
| |
216 | + pwd = _secret("OXYLABS_PROXY_PASS") |
| |
217 | + if not (endpoint and user and pwd): |
| |
218 | + return None |
| |
219 | + cc = (country or "ca").upper() |
| |
220 | + puser = f"{user}-cc-{cc}" |
| |
221 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" |
| |
222 | + proxies = {"http": proxy, "https": proxy} |
| |
223 | + hdrs = {"User-Agent": _UA} |
| |
224 | + if headers: |
| |
225 | + hdrs.update(headers) |
| |
226 | + try: |
| |
227 | + r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout, |
| |
228 | + verify=False) # noqa: S501 (proxy MITM du CA Oxylabs) |
| |
229 | + return _ResilientResponse(url, r.status_code, r.text, |
| |
230 | + dict(r.headers), via="oxylabs") |
| |
231 | + except Exception: # noqa: BLE001 |
| |
232 | + return None |
| |
233 | + |
| |
234 | + |
| |
235 | +def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool, |
| |
236 | + headers: dict | None) -> _ResilientResponse | None: |
| |
237 | + key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY") |
| |
238 | + if not key: |
| |
239 | + return None |
| |
240 | + params = {"key": key, "url": url, "country": country or "ca", |
| |
241 | + "asp": "true", "proxy_pool": "public_residential_pool"} |
| |
242 | + if render_js: |
| |
243 | + params["render_js"] = "true" |
| |
244 | + if headers: |
| |
245 | + for k, v in headers.items(): |
| |
246 | + params[f"headers[{k}]"] = v |
| |
247 | + try: |
| |
248 | + r = requests.get("https://api.scrapfly.io/scrape", params=params, |
| |
249 | + timeout=max(timeout, 180)) |
| |
250 | + result = (r.json() or {}).get("result") or {} |
| |
251 | + return _ResilientResponse( |
| |
252 | + url, result.get("status_code") or 0, result.get("content") or "", |
| |
253 | + (result.get("response_headers") or {}), via="scrapfly") |
| |
254 | + except Exception: # noqa: BLE001 |
| |
255 | + return None |
| |
256 | + |
| |
257 | + |
| |
258 | +def _try_brightdata(url: str, timeout: int, |
| |
259 | + headers: dict | None) -> _ResilientResponse | None: |
| |
260 | + key = _secret("BRIGHTDATA_API_KEY") |
| |
261 | + zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1" |
| |
262 | + if not key: |
| |
263 | + return None |
| |
264 | + try: |
| |
265 | + r = requests.post( |
| |
266 | + "https://api.brightdata.com/request", |
| |
267 | + headers={"Authorization": f"Bearer {key}", |
| |
268 | + "Content-Type": "application/json"}, |
| |
269 | + json={"zone": zone, "url": url, "format": "raw"}, |
| |
270 | + timeout=max(timeout, 120)) |
| |
271 | + return _ResilientResponse(url, r.status_code, r.text, |
| |
272 | + dict(r.headers), via="brightdata") |
| |
273 | + except Exception: # noqa: BLE001 |
| |
274 | + return None |
| |
275 | + |
| |
276 | + |
| |
277 | +# -- API publique ------------------------------------------------------------- |
| |
278 | +def escalate(url: str, *, timeout: int = 30, country: str = "ca", |
| |
279 | + render_js: bool = False, headers: dict | None = None, |
| |
280 | + original=None): |
| |
281 | + """Tente la chaîne de secours et renvoie la meilleure réponse. |
| |
282 | + |
| |
283 | + Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la |
| |
284 | + dernière réponse tentée (ou `original`) pour préserver le comportement |
| |
285 | + d'échec du connecteur. Respecte le coupe-circuit par hôte. |
| |
286 | + """ |
| |
287 | + host = _host(url) |
| |
288 | + if _cooling(host): |
| |
289 | + return original # source au repos : on ne brûle pas de quota payant |
| |
290 | + |
| |
291 | + last = original |
| |
292 | + for backend in ( |
| |
293 | + lambda: _try_oxylabs(url, timeout, country, headers), |
| |
294 | + lambda: _try_scrapfly(url, timeout, country, render_js, headers), |
| |
295 | + lambda: _try_brightdata(url, timeout, headers), |
| |
296 | + ): |
| |
297 | + resp = backend() |
| |
298 | + if resp is None: |
| |
299 | + continue |
| |
300 | + last = resp |
| |
301 | + if resp.status_code == 200 and resp.text and not is_blocked(resp): |
| |
302 | + _note_success(host) |
| |
303 | + return resp |
| |
304 | + time.sleep(0.4) |
| |
305 | + |
| |
306 | + _note_failure(host) |
| |
307 | + return last if last is not None else original |
| |
308 | + |
| |
309 | + |
| |
310 | +def escalate_if_blocked(resp, url: str, *, timeout: int = 30, |
| |
311 | + country: str = "ca", render_js: bool = False, |
| |
312 | + headers: dict | None = None): |
| |
313 | + """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot.""" |
| |
314 | + if not is_blocked(resp): |
| |
315 | + return resp |
| |
316 | + better = escalate(url, timeout=timeout, country=country, |
| |
317 | + render_js=render_js, headers=headers, original=resp) |
| |
318 | + return better if better is not None else resp |