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/automobileendirect.py : connecteur Automobile En Direct5# (automobileendirect.com), grande chaîne d'occasion — Saint-Constant,6# Laval, Saint-Hubert, Île-Perrot, Québec, Gatineau.7#8# Stratégie : le site est une SPA React alimentée par une API REST9# publique non authentifiée : https://api.automobileendirect.com10# GET /cars?page=N (en-tête accept-language: fr)11# -> {"totalResults": ~1725, "results": [22 véhicules]}12# chaque résultat : vin, slug, année/marque/modèle/version, prix13# courant + régulier (CAD), km, transmission, carburant, carrosserie,14# couleur, no de stock, photos, succursale (store.title.fr = ville).15# GET /cars/detailed/<vin> (optionnel, mise en cache BD, clé statique :16# contenu quasi immuable, le prix/km frais viennent de la liste)17# -> traction, portes, passagers, moteur, couleurs, description,18# options, anciens propriétaires ET la galerie complète19# (photos.all ≈ 25 images vs 4 aperçus dans la liste).20# ~80 requêtes de liste pour tout l'inventaire ; les pages détail sont21# plafonnées par AUTOKA_MAX_DETAILS (défaut 1200 — API JSON rapide,22# ~1 s/requête) et complétées aux syncs suivants via le cache BD.23#24# URL publique d'une fiche : /auto-usage/<slug>/<vin> (route SPA fr).25# AUTOKA_MAX_PAGES (env) limite le nombre de pages de liste (tests).26# -----------------------------------------------------------------------------27from __future__ import annotations2829import os30import re3132from ..schema import Vehicle33from .base import BaseConnector3435API = "https://api.automobileendirect.com"36SITE = "https://www.automobileendirect.com"3738_PER_PAGE = 22 # taille fixe côté serveur39_AWD_RE = re.compile(r"\b(awd|4wd|4x4|quattro|xdrive|4matic|4motion)\b", re.I)4041# clé de cache détail (v2 : ajout de la galerie photos.all au payload)42_DETAIL_KEY = "v2"434445def _fr(value) -> str:46 """Champ bilingue {'fr':…,'en':…} ou {'id':…,'title':{…}} -> texte fr."""47 if isinstance(value, dict):48 if "title" in value:49 return _fr(value["title"])50 return str(value.get("fr") or value.get("en") or "").strip()51 return str(value or "").strip()525354class AutomobileEnDirect(BaseConnector):55 """Automobile En Direct — inventaire complet via l'API REST de la SPA."""5657 source_id = "automobileendirect"58 dealer_name = "Automobile En Direct"59 request_delay = 1.060 max_pages = 200 # garde-fou dur (~80 pages réelles)61 max_details = 1200 # plafond de vraies requêtes détail / sync62 # (API JSON rapide ; inventaire complet63 # enrichi en ≤ 2 syncs, puis cache BD)6465 def __init__(self) -> None:66 super().__init__()67 self.session.headers["accept-language"] = "fr"6869 # -- API ----------------------------------------------------------------70 def _list_page(self, page: int) -> dict:71 return self.get(f"{API}/cars", params={"page": page}).json()7273 def _fetch_detail(self, vin: str) -> dict:74 data = self.get(f"{API}/cars/detailed/{vin}").json()75 # ne garder que les champs utiles (cache BD compact)76 keep = ("traction", "doors", "passengers", "engineDescription",77 "engineSize", "cylinders", "colors", "description",78 "options", "previousOwners", "fuelConsumption")79 out = {k: data[k] for k in keep if data.get(k) is not None}80 # galerie complète (photos.all) — la liste n'a que 4 aperçus81 photos = data.get("photos") or {}82 gallery = [u for u in (photos.get("all") or []) if isinstance(u, str)]83 if gallery:84 out["photos"] = gallery[:30]85 return out8687 # -- contrat --------------------------------------------------------------88 def fetch(self) -> list[Vehicle]:89 page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))90 detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))9192 rows: list[dict] = []93 seen: set[str] = set()94 page, total = 1, None95 while page <= min(page_cap, self.max_pages):96 data = self._list_page(page)97 results = data.get("results") or []98 if not results:99 break100 total = data.get("totalResults") or total101 for r in results:102 vin = str(r.get("vin") or "").strip()103 if vin and vin not in seen:104 seen.add(vin)105 rows.append(r)106 if total is not None and page * _PER_PAGE >= total:107 break108 page += 1109110 vehicles: list[Vehicle] = []111 real_fetches = 0112 for r in rows:113 extra: dict = {}114 vin = str(r["vin"])115 if real_fetches >= detail_cap:116 # budget épuisé : on sert quand même le cache BD existant117 from .. import db118 if self._detail_con is None:119 self._detail_con = db.connect()120 extra = db.get_cached_detail(self._detail_con, self.source_id,121 vin, _DETAIL_KEY) or {}122 else:123 before = self._last_request124 # clé statique : le détail (traction, moteur, options…) ne125 # change pas ; prix/km frais viennent toujours de la liste126 try:127 extra = self.detail(vin, _DETAIL_KEY,128 lambda v=vin: self._fetch_detail(v))129 except Exception:130 extra = {} # détail indisponible : données liste suffisent131 if self._last_request != before:132 real_fetches += 1133 veh = self._to_vehicle(r, extra)134 if veh is not None:135 vehicles.append(veh)136 return vehicles137138 # -- payload API -> Vehicle -------------------------------------------------139 def _to_vehicle(self, r: dict, extra: dict) -> Vehicle | None:140 if r.get("sold") or r.get("hidden") or r.get("removedFromInventory"):141 return None142 vin = str(r.get("vin") or "").strip()143 slug = str(r.get("slug") or "").strip()144 if not vin or not slug:145 return None146147 year = r.get("year")148 make = str(r.get("make") or "").strip()149 model = str(r.get("model") or "").strip()150 trim = str(r.get("trim") or "").strip()151152 prices = r.get("prices") or {}153 price = prices.get("current") or prices.get("regular")154 try:155 price = float(price) if price else None156 except (TypeError, ValueError):157 price = None158159 km = r.get("kilometers")160 try:161 km = float(km) if km not in (None, "") else None162 except (TypeError, ValueError):163 km = None164165 # galerie complète depuis le détail (≈ 25 photos), sinon aperçus liste166 images = [u for u in (extra.get("photos") or []) if isinstance(u, str)]167 if not images:168 photos = r.get("photos") or {}169 images = [u for u in (photos.get("preview") or [])170 if isinstance(u, str)]171 if not images and photos.get("main"):172 images = [photos["main"]]173174 # traction : page détail si dispo, sinon indice AWD/4x4 dans la version175 drivetrain = _fr(extra.get("traction"))176 if not drivetrain and _AWD_RE.search(trim):177 drivetrain = "Intégrale / 4x4"178179 colors = extra.get("colors") or {}180 interior = _fr(colors.get("interior")) if isinstance(colors, dict) else ""181182 engine = str(extra.get("engineDescription") or "").strip()183 if not engine and extra.get("engineSize"):184 cyl = extra.get("cylinders")185 engine = f"{extra['engineSize']}L" + (f" {cyl} cyl." if cyl else "")186187 description = _fr(extra.get("description")) or _fr(r.get("punchline"))188189 features: list[str] = []190 for opt in extra.get("options") or []:191 label = _fr(opt.get("title") if isinstance(opt, dict) else opt) \192 if isinstance(opt, (dict, str)) else ""193 if label:194 features.append(label)195196 details: dict = {}197 regular = prices.get("regular")198 if regular and price and regular != price:199 details["regular_price"] = regular200 if r.get("electricRange"):201 details["electric_range_km"] = r["electricRange"]202 if extra.get("previousOwners") is not None:203 details["previous_owners"] = extra["previousOwners"]204 if r.get("carfaxAmount") is not None:205 details["carfax_reports"] = r["carfaxAmount"]206207 try:208 doors = int(extra["doors"]) if extra.get("doors") else None209 except (TypeError, ValueError):210 doors = None211 try:212 seats = int(extra["passengers"]) if extra.get("passengers") else None213 except (TypeError, ValueError):214 seats = None215216 return Vehicle(217 source=self.source_id,218 external_id=vin,219 url=f"{SITE}/auto-usage/{slug}/{vin}",220 title=" ".join(str(x) for x in (year, make, model) if x),221 make=make,222 model=model,223 trim=trim,224 year=int(year) if year else None,225 price=price,226 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",227 mileage_km=km,228 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",229 transmission=_fr(r.get("transmission")),230 fuel=_fr(r.get("fuelType")),231 drivetrain=drivetrain,232 body_type=_fr(r.get("body")),233 exterior_color=_fr(r.get("exteriorColor")),234 interior_color=interior,235 engine=engine,236 doors=doors,237 seats=seats,238 vin=vin,239 stock_number=str(r.get("stockNumber") or ""),240 dealer_name=self.dealer_name,241 city=_fr(r.get("store")),242 description=description[:4000],243 features=features[:80],244 details=details,245 images=images[:30],246 )247