spb/auto-ka Public
Python 81.9%
TypeScript 12.4%
CSS 5.5%
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