SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%
7.9 KB · 200 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/vvu_dealers.py : connecteurs des concessionnaires indépendants sur5#   la plateforme ADWS (adws.ca) — sites Next.js au titre commun « Vente de6#   Véhicules Usagés - Voitures d'Occasion de Qualité » (d'où « VVU »).7#8#   Stratégie : chaque site expose une API JSON interne `/api/inventory`9#   (paginée : ?page=N&limit=M) qui retourne TOUTES les données véhicule10#   (prix, km, VIN, moteur, couleurs, équipements FR, photos…). Une ou deux11#   requêtes suffisent par sync — aucune page détail à visiter.12#13#   L'URL de la page détail se reconstruit depuis le slug14#   `<make>-<model>-<year>-<_id>` ; le préfixe varie selon la version du15#   gabarit ADWS déployée (`/fr/vehicle/…` ou `/fr/occasion/…`).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from ..schema import Vehicle, strip_accents22from .base import BaseConnector2324# garde-fou : ces marchands ne vendent que des automobiles, mais on écarte25# tout intrus « sport motorisé » au cas où26_EXCLUDE_RE = re.compile(27    r"motoneige|vtt|bateau|ponton|motomarine|\bmoto\b|roulotte|remorque|"28    r"campeur|tracteur|souffleuse|scooter|spyder|side-by-side|atv|snowmobile",29    re.I)303132def _slugify(text: str) -> str:33    """« Mercedes-Benz C-Class » -> « mercedes-benz-c-class » (règle ADWS)."""34    out = strip_accents(str(text or "")).lower()35    out = re.sub(r"[^a-z0-9]+", "-", out)36    return out.strip("-")373839def _num(value) -> float | None:40    try:41        v = float(value)42        return v if v > 0 else None43    except (TypeError, ValueError):44        return None454647class ADWSConnector(BaseConnector):48    """Base commune aux sites ADWS. Sous-classes : base_url + chemin détail."""4950    base_url: str = ""51    dealer_name: str = ""52    city: str = ""53    region: str = ""                  # si la ville échappe à infer_region()54    detail_path: str = "vehicle"      # « vehicle » ou « vehicule » selon la55                                      # version du gabarit ADWS du site56    request_delay: float = 1.057    page_size: int = 1005859    def _get_json(self, url: str) -> dict:60        """GET avec retries : le backend Next.js/serverless d'ADWS renvoie61        parfois un 500 transitoire (démarrage à froid)."""62        import time63        import requests as _rq64        for attempt in range(3):65            try:66                return self.get(url).json()67            except (_rq.HTTPError, ValueError):68                if attempt == 2:69                    raise70                time.sleep(3 * (attempt + 1))7172    def _api_pages(self):73        page = 174        while True:75            data = self._get_json(f"{self.base_url}/api/inventory"76                                  f"?page={page}&limit={self.page_size}")77            items = data.get("data") or []78            yield from items79            total = data.get("totalFiltered") or data.get("total") or 080            if not items or page * self.page_size >= total:81                return82            page += 18384    def _detail_url(self, d: dict) -> str:85        slug = "-".join(filter(None, [86            _slugify(d.get("make")), _slugify(d.get("model")),87            str(d.get("year") or "")])) + f"-{d['_id']}"88        return f"{self.base_url}/fr/{self.detail_path}/{slug}"8990    def fetch(self) -> list[Vehicle]:91        vehicles: list[Vehicle] = []92        for d in self._api_pages():93            if not d.get("_id"):94                continue95            if (d.get("saleclass") or "used").lower() != "used":96                continue                          # neufs / démonstrateurs97            status = (d.get("status") or "").lower()98            if status and status not in ("available", "disponible"):99                continue                          # vendus / en préparation100            title = " ".join(str(d.get("heading") or "").split())101            if not title:102                title = " ".join(filter(None, [str(d.get("year") or ""),103                                               d.get("make"), d.get("model")]))104            if _EXCLUDE_RE.search(title):105                continue106107            price = _num(d.get("price"))108            special = _num(d.get("special_price"))109            details: dict = {}110            if special and price and special < price:111                details["regular_price"] = price112                price = special                   # le prix affiché = le spécial113114            km = _num(d.get("km"))115            photos = [u for u in (d.get("photo") or []) if isinstance(u, str)]116            features = [f for f in (d.get("vehicle_options_fr")117                                    or d.get("vehicle_options") or [])118                        if isinstance(f, str)]119120            for key in ("version", "engine_size", "cylinders", "entrydate",121                        "tyres_size", "tags"):122                if d.get(key):123                    details[key] = d[key]124125            vin = "" if d.get("hide_vin") else str(d.get("serialnumber") or "")126127            vehicles.append(Vehicle(128                source=self.source_id,129                external_id=str(d["_id"]),130                url=self._detail_url(d),131                title=title,132                make=str(d.get("make") or ""),133                model=str(d.get("model") or ""),134                trim=str(d.get("trim") or ""),135                year=d.get("year") if isinstance(d.get("year"), int) else None,136                price=price,137                price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),138                mileage_km=km,139                mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),140                transmission=str(d.get("transtype") or ""),141                fuel=str(d.get("fuel") or ""),142                drivetrain=str(d.get("drivetrain") or ""),143                body_type=str(d.get("body_type") or ""),144                exterior_color=str(d.get("base_ext_color") or ""),145                interior_color=str(d.get("base_int_color") or ""),146                engine=str(d.get("engine") or ""),147                doors=d.get("doors") if isinstance(d.get("doors"), int) else None,148                seats=(d.get("passengers")149                       if isinstance(d.get("passengers"), int) else None),150                vin=vin,151                stock_number=str(d.get("stocknumber") or ""),152                dealer_name=self.dealer_name,153                city=self.city,154                region=self.region,155                features=features[:80],156                details=details,157                images=photos[:20],158                carfax_url=str(d.get("carfax_url_fr")159                               or d.get("carfax_url_en") or ""),160            ))161        return vehicles162163164# ---------------------------------------------------------------------------165# Concessionnaires (1 sous-classe = 1 source)166# ---------------------------------------------------------------------------167168class AutoGlobalMTL(ADWSConnector):169    source_id = "autoglobalmtl"170    base_url = "https://www.autoglobalmtl.com"171    dealer_name = "Auto Global MTL"172    city = "Montréal"173    detail_path = "vehicle"174175176class GroupeAutomobile(ADWSConnector):177    source_id = "groupeautomobile"178    base_url = "https://www.groupeautomobile.com"179    dealer_name = "Le Groupe Automobile Laval"180    city = "Laval"181    detail_path = "vehicule"182183184class SummumAuto(ADWSConnector):185    source_id = "summumauto"186    base_url = "https://www.summumauto.com"187    dealer_name = "Summum Auto"188    city = "Laval"189    detail_path = "vehicle"190191192class Comme1Neuf(ADWSConnector):193    # occasionvilledequebec.com redirige (301) vers comme1neuf.com — même commerce194    source_id = "occasionvilledequebec"195    base_url = "https://www.comme1neuf.com"196    dealer_name = "Comme1Neuf"197    city = "Château-Richer"198    region = "Capitale-Nationale"199    detail_path = "vehicule"200