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/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