SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

connecteurs: chaine de fetch anti-bot resiliente (Oxylabs->Scrapfly->Bright Data)

Auto-escalade de BaseConnector.get() sur blocage (403/429/503/challenge CF)
ou coupure reseau, via connectors/_resilient.py. Cles lues de l env avec repli
sur ~/.claude/.env (accepte SCRAPFLY_KEY et SCRAPFLY_API_KEY). Reponse
compatible requests.Response; coupe-circuit par hote. Aucun connecteur modifie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 22, 2026) parent a46644b Browse files

2 changed files +386 −0

added restoka/connectors/_resilient.py +318 −0
@@ -0,0 +1,318 @@
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
modified restoka/connectors/base.py +68 −0
@@ -163,3 +163,71 @@ class BaseConnector:
163 # -- contrat -------------------------------------------------------------- 163 # -- contrat --------------------------------------------------------------
164 def fetch(self) -> list[Restaurant]: 164 def fetch(self) -> list[Restaurant]:
165 raise NotImplementedError 165 raise NotImplementedError
166+
167+
168+# =============================================================================
169+# Résilience anti-bot (Groupe KA) — auto-escalade de get() sans toucher au corps.
170+# Ajouté par l'orchestrateur KA : enrobe BaseConnector.get pour qu'un blocage
171+# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne
172+# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir
173+# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).
174+# =============================================================================
175+if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):
176+ import requests as _ka_requests # noqa: E402
177+ from . import _resilient as _kar # noqa: E402
178+
179+ _ka_orig_get = BaseConnector.get
180+
181+ def _ka_full_url(url, kw):
182+ try:
183+ return _ka_requests.Request("GET", url,
184+ params=kw.get("params")).prepare().url
185+ except Exception: # noqa: BLE001
186+ return url
187+
188+ def _ka_resilient_get(self, url, **kw):
189+ timeout = getattr(self, "timeout", 30)
190+ headers = kw.get("headers")
191+ try:
192+ return _ka_orig_get(self, url, **kw)
193+ except _ka_requests.HTTPError as exc:
194+ r = getattr(exc, "response", None)
195+ if r is not None and _kar.is_blocked(r):
196+ target = getattr(r, "url", None) or _ka_full_url(url, kw)
197+ better = _kar.escalate_if_blocked(
198+ r, target, timeout=timeout, headers=headers)
199+ if better is not None and getattr(better, "status_code", 0) == 200:
200+ return better
201+ raise
202+ except (_ka_requests.ConnectionError, _ka_requests.Timeout):
203+ better = _kar.escalate(_ka_full_url(url, kw),
204+ timeout=timeout, headers=headers)
205+ if better is not None and getattr(better, "status_code", 0) == 200:
206+ return better
207+ raise
208+
209+ def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw):
210+ """Fetch anti-bot explicite : force la chaîne de secours au besoin.
211+
212+ Comme get() mais tente d'abord le direct puis escalade même sur 200-
213+ challenge, avec rendu JS optionnel. Renvoie une réponse compatible
214+ requests (.text/.content/.status_code/.json()...).
215+ """
216+ timeout = getattr(self, "timeout", 30)
217+ headers = kw.get("headers")
218+ try:
219+ resp = _ka_orig_get(self, url, **kw)
220+ except _ka_requests.HTTPError as exc:
221+ resp = getattr(exc, "response", None)
222+ except (_ka_requests.ConnectionError, _ka_requests.Timeout):
223+ resp = None
224+ target = _ka_full_url(url, kw)
225+ if resp is not None and getattr(resp, "url", None):
226+ target = resp.url
227+ return _kar.escalate_if_blocked(resp, target, timeout=timeout,
228+ country=country, render_js=render_js,
229+ headers=headers)
230+
231+ BaseConnector.get = _ka_resilient_get
232+ BaseConnector.get_resilient = _ka_get_resilient
233+ BaseConnector._KA_RESILIENT_WRAPPED = True
166 234
Menu

Theme