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%
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