SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%
10.3 KB · 247 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/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