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%
9.8 KB · 234 lines python
Raw Blame History
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