SPB Git

spb/food-ka Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

Python 57.7% TypeScript 24.9% CSS 16.7% HTML 0.6%
6.7 KB · 160 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/base.py : classe de base des connecteurs + backends de fetch5#   1. requests direct (sites/API sans protection)6#   2. Scrapfly (anti-bot ASP + rendu JavaScript — Metro, Walmart…) [préféré]7#   3. Firecrawl (rendu JavaScript, solution de repli)8# -----------------------------------------------------------------------------9from __future__ import annotations1011import json12import os13import time1415import requests1617from ..schema import Product1819USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "20              "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "21              "FoodKaBot/1.0 (+https://www.food-ka.com/bot; contact@spboucher.ai)")2223FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"24SCRAPFLY_API = "https://api.scrapfly.io/scrape"252627class BaseConnector:28    """Un connecteur = un adaptateur propre à une bannière d'épicerie.2930    Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne31    la liste complète des produits actuellement affichés sur le site.32    Le pipeline (ingest.py) s'occupe du diff avec la base de données.33    """3435    source_id: str = ""36    request_delay: float = 0.6           # politesse entre requêtes37    timeout: int = 3038    use_detail_cache: bool = True        # cache BD des pages détail3940    def __init__(self) -> None:41        self.session = requests.Session()42        self.session.headers["User-Agent"] = USER_AGENT43        self._last_request = 0.044        self._detail_con = None4546    # -- backend 1 : requests direct ------------------------------------------47    def get(self, url: str, **kw) -> requests.Response:48        """GET direct avec throttling poli."""49        wait = self.request_delay - (time.time() - self._last_request)50        if wait > 0:51            time.sleep(wait)52        resp = self.session.get(url, timeout=self.timeout, **kw)53        self._last_request = time.time()54        resp.raise_for_status()55        return resp5657    def post(self, url: str, **kw) -> requests.Response:58        """POST direct avec throttling poli (API JSON internes)."""59        wait = self.request_delay - (time.time() - self._last_request)60        if wait > 0:61            time.sleep(wait)62        resp = self.session.post(url, timeout=self.timeout, **kw)63        self._last_request = time.time()64        resp.raise_for_status()65        return resp6667    # -- backend 2 : Scrapfly (préféré pour les sites protégés) ---------------68    def get_scrapfly(self, url: str, *, render_js: bool = False,69                     asp: bool = True, country: str = "ca",70                     headers: dict | None = None,71                     wait_for_selector: str | None = None,72                     retries: int = 2) -> str:73        """Récupère une page via Scrapfly — contourne Cloudflare/Akamai (ASP)74        et peut rendre le JavaScript. Le backend le plus robuste.7576        Nécessite SCRAPFLY_API_KEY dans l'environnement (.env).77        Retourne le corps de la réponse (HTML ou JSON brut).78        """79        key = os.environ.get("SCRAPFLY_API_KEY")80        if not key:81            raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")82        params: dict = {83            "key": key, "url": url, "country": country,84            "asp": str(asp).lower(), "render_js": str(render_js).lower(),85        }86        if wait_for_selector:87            params["wait_for_selector"] = wait_for_selector88        if headers:89            for i, (hk, hv) in enumerate(headers.items()):90                params[f"headers[{hk}]"] = hv91        last_exc: Exception | None = None92        for attempt in range(retries + 1):93            try:94                resp = requests.get(SCRAPFLY_API, params=params, timeout=160)95                resp.raise_for_status()96                data = resp.json()97                result = data.get("result") or {}98                status = result.get("status_code")99                if status and int(status) >= 400:100                    raise RuntimeError(f"scrapfly: statut amont {status} pour {url}")101                return result.get("content") or ""102            except Exception as exc:   # réessai : proxys rotatifs = transitoire103                last_exc = exc104                time.sleep(2 * (attempt + 1))105        raise RuntimeError(f"scrapfly: échec après {retries + 1} tentatives: {last_exc}")106107    def get_scrapfly_json(self, url: str, **kw) -> dict | list:108        """Variante JSON de get_scrapfly (API internes derrière anti-bot)."""109        body = self.get_scrapfly(url, **kw)110        return json.loads(body)111112    # -- backend 3 : Firecrawl (repli rendu JavaScript) ------------------------113    def get_rendered(self, url: str, *, wait_ms: int = 0) -> str:114        """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.115116        Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).117        """118        key = os.environ.get("FIRECRAWL_API_KEY")119        if not key:120            raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")121        payload: dict = {"url": url, "formats": ["html"]}122        if wait_ms:123            payload["waitFor"] = wait_ms124        resp = requests.post(125            FIRECRAWL_API,126            json=payload,127            headers={"Authorization": f"Bearer {key}"},128            timeout=120,129        )130        resp.raise_for_status()131        data = resp.json()132        return (data.get("data") or {}).get("html", "")133134    # -- cache des pages détail -------------------------------------------------135    def detail(self, external_id: str, key: str, fetch_fn) -> dict:136        """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si137        le produit est nouveau ou si sa clé (hash du contenu liste) a changé.138139        Permet d'extraire les champs riches (description, valeurs nutritives…)140        sans revisiter chaque fiche produit à chaque synchronisation.141        `fetch_fn` doit retourner un dict JSON-sérialisable.142        """143        if not self.use_detail_cache:144            return fetch_fn() or {}145        from .. import db146        if self._detail_con is None:147            self._detail_con = db.connect()148        cached = db.get_cached_detail(self._detail_con, self.source_id,149                                      str(external_id), key)150        if cached is not None:151            return cached152        payload = fetch_fn() or {}153        db.put_cached_detail(self._detail_con, self.source_id,154                             str(external_id), key, payload)155        return payload156157    # -- contrat --------------------------------------------------------------158    def fetch(self) -> list[Product]:159        raise NotImplementedError160