# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/automobileendirect.py : connecteur Automobile En Direct # (automobileendirect.com), grande chaîne d'occasion — Saint-Constant, # Laval, Saint-Hubert, Île-Perrot, Québec, Gatineau. # # Stratégie : le site est une SPA React alimentée par une API REST # publique non authentifiée : https://api.automobileendirect.com # GET /cars?page=N (en-tête accept-language: fr) # -> {"totalResults": ~1725, "results": [22 véhicules]} # chaque résultat : vin, slug, année/marque/modèle/version, prix # courant + régulier (CAD), km, transmission, carburant, carrosserie, # couleur, no de stock, photos, succursale (store.title.fr = ville). # GET /cars/detailed/ (optionnel, mise en cache BD, clé statique : # contenu quasi immuable, le prix/km frais viennent de la liste) # -> traction, portes, passagers, moteur, couleurs, description, # options, anciens propriétaires ET la galerie complète # (photos.all ≈ 25 images vs 4 aperçus dans la liste). # ~80 requêtes de liste pour tout l'inventaire ; les pages détail sont # plafonnées par AUTOKA_MAX_DETAILS (défaut 1200 — API JSON rapide, # ~1 s/requête) et complétées aux syncs suivants via le cache BD. # # URL publique d'une fiche : /auto-usage// (route SPA fr). # AUTOKA_MAX_PAGES (env) limite le nombre de pages de liste (tests). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from ..schema import Vehicle from .base import BaseConnector API = "https://api.automobileendirect.com" SITE = "https://www.automobileendirect.com" _PER_PAGE = 22 # taille fixe côté serveur _AWD_RE = re.compile(r"\b(awd|4wd|4x4|quattro|xdrive|4matic|4motion)\b", re.I) # clé de cache détail (v2 : ajout de la galerie photos.all au payload) _DETAIL_KEY = "v2" def _fr(value) -> str: """Champ bilingue {'fr':…,'en':…} ou {'id':…,'title':{…}} -> texte fr.""" if isinstance(value, dict): if "title" in value: return _fr(value["title"]) return str(value.get("fr") or value.get("en") or "").strip() return str(value or "").strip() class AutomobileEnDirect(BaseConnector): """Automobile En Direct — inventaire complet via l'API REST de la SPA.""" source_id = "automobileendirect" dealer_name = "Automobile En Direct" request_delay = 1.0 max_pages = 200 # garde-fou dur (~80 pages réelles) max_details = 1200 # plafond de vraies requêtes détail / sync # (API JSON rapide ; inventaire complet # enrichi en ≤ 2 syncs, puis cache BD) def __init__(self) -> None: super().__init__() self.session.headers["accept-language"] = "fr" # -- API ---------------------------------------------------------------- def _list_page(self, page: int) -> dict: return self.get(f"{API}/cars", params={"page": page}).json() def _fetch_detail(self, vin: str) -> dict: data = self.get(f"{API}/cars/detailed/{vin}").json() # ne garder que les champs utiles (cache BD compact) keep = ("traction", "doors", "passengers", "engineDescription", "engineSize", "cylinders", "colors", "description", "options", "previousOwners", "fuelConsumption") out = {k: data[k] for k in keep if data.get(k) is not None} # galerie complète (photos.all) — la liste n'a que 4 aperçus photos = data.get("photos") or {} gallery = [u for u in (photos.get("all") or []) if isinstance(u, str)] if gallery: out["photos"] = gallery[:30] return out # -- contrat -------------------------------------------------------------- def fetch(self) -> list[Vehicle]: page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) rows: list[dict] = [] seen: set[str] = set() page, total = 1, None while page <= min(page_cap, self.max_pages): data = self._list_page(page) results = data.get("results") or [] if not results: break total = data.get("totalResults") or total for r in results: vin = str(r.get("vin") or "").strip() if vin and vin not in seen: seen.add(vin) rows.append(r) if total is not None and page * _PER_PAGE >= total: break page += 1 vehicles: list[Vehicle] = [] real_fetches = 0 for r in rows: extra: dict = {} vin = str(r["vin"]) if real_fetches >= detail_cap: # budget épuisé : on sert quand même le cache BD existant from .. import db if self._detail_con is None: self._detail_con = db.connect() extra = db.get_cached_detail(self._detail_con, self.source_id, vin, _DETAIL_KEY) or {} else: before = self._last_request # clé statique : le détail (traction, moteur, options…) ne # change pas ; prix/km frais viennent toujours de la liste try: extra = self.detail(vin, _DETAIL_KEY, lambda v=vin: self._fetch_detail(v)) except Exception: extra = {} # détail indisponible : données liste suffisent if self._last_request != before: real_fetches += 1 veh = self._to_vehicle(r, extra) if veh is not None: vehicles.append(veh) return vehicles # -- payload API -> Vehicle ------------------------------------------------- def _to_vehicle(self, r: dict, extra: dict) -> Vehicle | None: if r.get("sold") or r.get("hidden") or r.get("removedFromInventory"): return None vin = str(r.get("vin") or "").strip() slug = str(r.get("slug") or "").strip() if not vin or not slug: return None year = r.get("year") make = str(r.get("make") or "").strip() model = str(r.get("model") or "").strip() trim = str(r.get("trim") or "").strip() prices = r.get("prices") or {} price = prices.get("current") or prices.get("regular") try: price = float(price) if price else None except (TypeError, ValueError): price = None km = r.get("kilometers") try: km = float(km) if km not in (None, "") else None except (TypeError, ValueError): km = None # galerie complète depuis le détail (≈ 25 photos), sinon aperçus liste images = [u for u in (extra.get("photos") or []) if isinstance(u, str)] if not images: photos = r.get("photos") or {} images = [u for u in (photos.get("preview") or []) if isinstance(u, str)] if not images and photos.get("main"): images = [photos["main"]] # traction : page détail si dispo, sinon indice AWD/4x4 dans la version drivetrain = _fr(extra.get("traction")) if not drivetrain and _AWD_RE.search(trim): drivetrain = "Intégrale / 4x4" colors = extra.get("colors") or {} interior = _fr(colors.get("interior")) if isinstance(colors, dict) else "" engine = str(extra.get("engineDescription") or "").strip() if not engine and extra.get("engineSize"): cyl = extra.get("cylinders") engine = f"{extra['engineSize']}L" + (f" {cyl} cyl." if cyl else "") description = _fr(extra.get("description")) or _fr(r.get("punchline")) features: list[str] = [] for opt in extra.get("options") or []: label = _fr(opt.get("title") if isinstance(opt, dict) else opt) \ if isinstance(opt, (dict, str)) else "" if label: features.append(label) details: dict = {} regular = prices.get("regular") if regular and price and regular != price: details["regular_price"] = regular if r.get("electricRange"): details["electric_range_km"] = r["electricRange"] if extra.get("previousOwners") is not None: details["previous_owners"] = extra["previousOwners"] if r.get("carfaxAmount") is not None: details["carfax_reports"] = r["carfaxAmount"] try: doors = int(extra["doors"]) if extra.get("doors") else None except (TypeError, ValueError): doors = None try: seats = int(extra["passengers"]) if extra.get("passengers") else None except (TypeError, ValueError): seats = None return Vehicle( source=self.source_id, external_id=vin, url=f"{SITE}/auto-usage/{slug}/{vin}", title=" ".join(str(x) for x in (year, make, model) if x), make=make, model=model, trim=trim, year=int(year) if year else None, price=price, price_label=f"{price:,.0f} $".replace(",", " ") if price else "", mileage_km=km, mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", transmission=_fr(r.get("transmission")), fuel=_fr(r.get("fuelType")), drivetrain=drivetrain, body_type=_fr(r.get("body")), exterior_color=_fr(r.get("exteriorColor")), interior_color=interior, engine=engine, doors=doors, seats=seats, vin=vin, stock_number=str(r.get("stockNumber") or ""), dealer_name=self.dealer_name, city=_fr(r.get("store")), description=description[:4000], features=features[:80], details=details, images=images[:30], )