SPB Git forge

spb/resto-ka

Public

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

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%

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 1 mo ago (Aug 22, 2026) parent a46644b

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 163 # -- contrat --------------------------------------------------------------
164 164 def fetch(self) -> list[Restaurant]:
165 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