|
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 |