SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%
5.6 KB · 142 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/base.py : classe de base des connecteurs + backends de fetch5#                      (requests direct, ou Firecrawl pour JS/Cloudflare)6# -----------------------------------------------------------------------------7from __future__ import annotations89import os10import time1112import requests1314from ..schema import Vehicle1516USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "17              "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "18              "AutoKaBot/1.0 (+contact@spboucher.ai)")1920FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"21SCRAPFLY_API = "https://api.scrapfly.io/scrape"222324class BaseConnector:25    """Un connecteur = un adaptateur propre à un site de concessionnaire.2627    Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne28    la liste complète des véhicules actuellement affichés sur le site.29    Le pipeline (ingest.py) s'occupe du diff avec la base de données.30    """3132    source_id: str = ""33    request_delay: float = 0.6           # politesse entre requêtes34    timeout: int = 3035    use_detail_cache: bool = True        # cache BD des pages détail3637    def __init__(self) -> None:38        self.session = requests.Session()39        self.session.headers["User-Agent"] = USER_AGENT40        self._last_request = 0.041        self._detail_con = None4243    # -- backends -------------------------------------------------------------44    def get(self, url: str, **kw) -> requests.Response:45        """GET direct avec throttling poli.4647        Retry (2×) sur les connexions keep-alive fermées par le serveur —48        fréquent chez D2C Media / SM360 après une période d'inactivité.49        """50        wait = self.request_delay - (time.time() - self._last_request)51        if wait > 0:52            time.sleep(wait)53        last_exc: Exception | None = None54        for attempt in range(3):55            try:56                resp = self.session.get(url, timeout=self.timeout, **kw)57                self._last_request = time.time()58                resp.raise_for_status()59                return resp60            except (requests.exceptions.ConnectionError,61                    requests.exceptions.ChunkedEncodingError) as exc:62                last_exc = exc63                self.session.close()      # repartir sur une connexion neuve64                time.sleep(1.5 * (attempt + 1))65        self._last_request = time.time()66        raise last_exc  # type: ignore[misc]6768    def get_rendered(self, url: str, wait_ms: int = 0) -> str:69        """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.7071        Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).72        À utiliser pour les sites SPA ou derrière Cloudflare.73        """74        key = os.environ.get("FIRECRAWL_API_KEY")75        if not key:76            raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")77        payload: dict = {"url": url, "formats": ["html"]}78        if wait_ms:79            payload["waitFor"] = wait_ms80        resp = requests.post(81            FIRECRAWL_API,82            json=payload,83            headers={"Authorization": f"Bearer {key}"},84            timeout=120,85        )86        resp.raise_for_status()87        data = resp.json()88        return (data.get("data") or {}).get("html", "")8990    def get_scrapfly(self, url: str, render_js: bool = False,91                     asp: bool = True) -> str:92        """Récupère une page via Scrapfly (anti-bot robuste, rendu JS optionnel).9394        À privilégier sur Firecrawl quand le site bloque, est lent, ou que le95        rendu Firecrawl perd de l'information. Nécessite SCRAPFLY_API_KEY.96        - asp=True : contournement anti-bot (Cloudflare, etc.)97        - render_js=True : exécute le JavaScript (SPA)98        """99        key = os.environ.get("SCRAPFLY_API_KEY")100        if not key:101            raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")102        params = {103            "key": key,104            "url": url,105            "country": "ca",106        }107        if asp:108            params["asp"] = "true"109        if render_js:110            params["render_js"] = "true"111        resp = requests.get(SCRAPFLY_API, params=params, timeout=120)112        resp.raise_for_status()113        data = resp.json()114        result = data.get("result") or {}115        return result.get("content", "")116117    def detail(self, external_id: str, key: str, fetch_fn) -> dict:118        """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si119        l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé.120121        Permet d'extraire les champs riches (description, équipements, VIN…)122        sans revisiter chaque page détail à chaque synchronisation.123        `fetch_fn` doit retourner un dict JSON-sérialisable.124        """125        if not self.use_detail_cache:126            return fetch_fn() or {}127        from .. import db128        if self._detail_con is None:129            self._detail_con = db.connect()130        cached = db.get_cached_detail(self._detail_con, self.source_id,131                                      str(external_id), key)132        if cached is not None:133            return cached134        payload = fetch_fn() or {}135        db.put_cached_detail(self._detail_con, self.source_id,136                             str(external_id), key, payload)137        return payload138139    # -- contrat --------------------------------------------------------------140    def fetch(self) -> list[Vehicle]:141        raise NotImplementedError142