Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/connectors/base.py4# Desc: Classe de base des connecteurs + backends de fetch (requests direct,5# Firecrawl ou Scrapfly pour les sites JavaScript / anti-bot).6# Calquée sur louka/connectors/base.py — mêmes conventions.7# ==============================================================================8from __future__ import annotations910import json11import os12import time1314import requests1516from ..schema import Restaurant1718USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "19 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "20 "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)")2122FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"23SCRAPFLY_API = "https://api.scrapfly.io/scrape"242526class SkipSource(RuntimeError):27 """Levée par un connecteur qui ne peut pas tourner (clé API manquante…).2829 Le pipeline (ingest.run) consigne le motif et passe à la source suivante30 SANS marquer d'échec dans sync_log — ce n'est pas une erreur, la source31 est simplement en attente (ex. Yelp Fusion sans YELP_API_KEY)."""323334class BaseConnector:35 """Un connecteur = un adaptateur propre à une source (ou une plateforme).3637 Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne38 la liste complète des restaurants (avec menu) actuellement publiés par la39 source. Le pipeline (ingest.py) s'occupe du diff avec la base de données.40 """4142 source_id: str = ""43 request_delay: float = 0.6 # politesse entre requêtes44 timeout: int = 3045 use_detail_cache: bool = True # cache BD des payloads détail4647 def __init__(self) -> None:48 self.session = requests.Session()49 self.session.headers["User-Agent"] = USER_AGENT50 self._last_request = 0.051 self._detail_con = None5253 # -- backends -------------------------------------------------------------54 def _throttle(self) -> None:55 wait = self.request_delay - (time.time() - self._last_request)56 if wait > 0:57 time.sleep(wait)5859 def get(self, url: str, **kw) -> requests.Response:60 """GET direct avec throttling poli."""61 self._throttle()62 resp = self.session.get(url, timeout=self.timeout, **kw)63 self._last_request = time.time()64 resp.raise_for_status()65 return resp6667 def post(self, url: str, **kw) -> requests.Response:68 """POST direct avec throttling poli (APIs internes des plateformes)."""69 self._throttle()70 resp = self.session.post(url, timeout=self.timeout, **kw)71 self._last_request = time.time()72 resp.raise_for_status()73 return resp7475 def get_rendered(self, url: str) -> str:76 """HTML rendu (JavaScript exécuté) via Firecrawl.7778 Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). À utiliser79 pour les sites de restos SPA relativement ouverts.80 """81 key = os.environ.get("FIRECRAWL_API_KEY")82 if not key:83 raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")84 resp = requests.post(85 FIRECRAWL_API,86 json={"url": url, "formats": ["html"]},87 headers={"Authorization": f"Bearer {key}"},88 timeout=90,89 )90 resp.raise_for_status()91 data = resp.json()92 return (data.get("data") or {}).get("html", "")9394 def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,95 rendering_wait: int = 0, country: str = "ca",96 wait_for_selector: str | None = None,97 js_scenario: list | str | None = None,98 proxy_pool: str | None = None, headers: dict | None = None,99 method: str = "GET", body: str | None = None) -> dict:100 """Appel Scrapfly complet — retourne le dict `result` (content, status…).101102 Requis pour les plateformes de livraison (anti-bot agressif) ; voir103 CLAUDE.md §10 et §15 avant d'industrialiser sur ces sources.104 """105 import base64106 key = os.environ.get("SCRAPFLY_KEY")107 if not key:108 raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")109 params: dict = {"key": key, "url": url, "country": country}110 if asp:111 params["asp"] = "true"112 if render_js:113 params["render_js"] = "true"114 if rendering_wait:115 params["rendering_wait"] = rendering_wait116 if wait_for_selector:117 params["wait_for_selector"] = wait_for_selector118 if proxy_pool:119 params["proxy_pool"] = proxy_pool120 if js_scenario is not None:121 js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)122 params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()123 if headers:124 for k, v in headers.items():125 params[f"headers[{k}]"] = v126 self._throttle()127 if method.upper() == "POST":128 resp = requests.post(SCRAPFLY_API, params=params,129 data=(body or ""), timeout=180)130 else:131 resp = requests.get(SCRAPFLY_API, params=params, timeout=180)132 self._last_request = time.time()133 try:134 return resp.json().get("result") or {}135 except ValueError:136 return {}137138 def get_scrapfly(self, url: str, **kw) -> str:139 """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS)."""140 return self.scrapfly(url, **kw).get("content") or ""141142 def detail(self, external_id: str, key: str, fetch_fn) -> dict:143 """Payload « détail » avec cache : `fetch_fn` n'est appelé que si le144 resto est nouveau ou si sa clé (hash du contenu liste) a changé.145146 Évite de recapturer un menu complet (dizaines de requêtes) quand rien147 n'a bougé. `fetch_fn` doit retourner un dict JSON-sérialisable.148 """149 if not self.use_detail_cache:150 return fetch_fn() or {}151 from .. import db152 if self._detail_con is None:153 self._detail_con = db.connect()154 cached = db.get_cached_detail(self._detail_con, self.source_id,155 str(external_id), key)156 if cached is not None:157 return cached158 payload = fetch_fn() or {}159 db.put_cached_detail(self._detail_con, self.source_id,160 str(external_id), key, payload)161 return payload162163 # -- contrat --------------------------------------------------------------164 def fetch(self) -> list[Restaurant]:165 raise NotImplementedError166167168# =============================================================================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 blocage171# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne172# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir173# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).174# =============================================================================175if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):176 import requests as _ka_requests # noqa: E402177 from . import _resilient as _kar # noqa: E402178179 _ka_orig_get = BaseConnector.get180181 def _ka_full_url(url, kw):182 try:183 return _ka_requests.Request("GET", url,184 params=kw.get("params")).prepare().url185 except Exception: # noqa: BLE001186 return url187188 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 better201 raise202 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 better207 raise208209 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.211212 Comme get() mais tente d'abord le direct puis escalade même sur 200-213 challenge, avec rendu JS optionnel. Renvoie une réponse compatible214 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 = None224 target = _ka_full_url(url, kw)225 if resp is not None and getattr(resp, "url", None):226 target = resp.url227 return _kar.escalate_if_blocked(resp, target, timeout=timeout,228 country=country, render_js=render_js,229 headers=headers)230231 BaseConnector.get = _ka_resilient_get232 BaseConnector.get_resilient = _ka_get_resilient233 BaseConnector._KA_RESILIENT_WRAPPED = True234