# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/vvu_dealers.py : connecteurs des concessionnaires indépendants sur # la plateforme ADWS (adws.ca) — sites Next.js au titre commun « Vente de # Véhicules Usagés - Voitures d'Occasion de Qualité » (d'où « VVU »). # # Stratégie : chaque site expose une API JSON interne `/api/inventory` # (paginée : ?page=N&limit=M) qui retourne TOUTES les données véhicule # (prix, km, VIN, moteur, couleurs, équipements FR, photos…). Une ou deux # requêtes suffisent par sync — aucune page détail à visiter. # # L'URL de la page détail se reconstruit depuis le slug # `---<_id>` ; le préfixe varie selon la version du # gabarit ADWS déployée (`/fr/vehicle/…` ou `/fr/occasion/…`). # ----------------------------------------------------------------------------- from __future__ import annotations import re from ..schema import Vehicle, strip_accents from .base import BaseConnector # garde-fou : ces marchands ne vendent que des automobiles, mais on écarte # tout intrus « sport motorisé » au cas où _EXCLUDE_RE = re.compile( r"motoneige|vtt|bateau|ponton|motomarine|\bmoto\b|roulotte|remorque|" r"campeur|tracteur|souffleuse|scooter|spyder|side-by-side|atv|snowmobile", re.I) def _slugify(text: str) -> str: """« Mercedes-Benz C-Class » -> « mercedes-benz-c-class » (règle ADWS).""" out = strip_accents(str(text or "")).lower() out = re.sub(r"[^a-z0-9]+", "-", out) return out.strip("-") def _num(value) -> float | None: try: v = float(value) return v if v > 0 else None except (TypeError, ValueError): return None class ADWSConnector(BaseConnector): """Base commune aux sites ADWS. Sous-classes : base_url + chemin détail.""" base_url: str = "" dealer_name: str = "" city: str = "" region: str = "" # si la ville échappe à infer_region() detail_path: str = "vehicle" # « vehicle » ou « vehicule » selon la # version du gabarit ADWS du site request_delay: float = 1.0 page_size: int = 100 def _get_json(self, url: str) -> dict: """GET avec retries : le backend Next.js/serverless d'ADWS renvoie parfois un 500 transitoire (démarrage à froid).""" import time import requests as _rq for attempt in range(3): try: return self.get(url).json() except (_rq.HTTPError, ValueError): if attempt == 2: raise time.sleep(3 * (attempt + 1)) def _api_pages(self): page = 1 while True: data = self._get_json(f"{self.base_url}/api/inventory" f"?page={page}&limit={self.page_size}") items = data.get("data") or [] yield from items total = data.get("totalFiltered") or data.get("total") or 0 if not items or page * self.page_size >= total: return page += 1 def _detail_url(self, d: dict) -> str: slug = "-".join(filter(None, [ _slugify(d.get("make")), _slugify(d.get("model")), str(d.get("year") or "")])) + f"-{d['_id']}" return f"{self.base_url}/fr/{self.detail_path}/{slug}" def fetch(self) -> list[Vehicle]: vehicles: list[Vehicle] = [] for d in self._api_pages(): if not d.get("_id"): continue if (d.get("saleclass") or "used").lower() != "used": continue # neufs / démonstrateurs status = (d.get("status") or "").lower() if status and status not in ("available", "disponible"): continue # vendus / en préparation title = " ".join(str(d.get("heading") or "").split()) if not title: title = " ".join(filter(None, [str(d.get("year") or ""), d.get("make"), d.get("model")])) if _EXCLUDE_RE.search(title): continue price = _num(d.get("price")) special = _num(d.get("special_price")) details: dict = {} if special and price and special < price: details["regular_price"] = price price = special # le prix affiché = le spécial km = _num(d.get("km")) photos = [u for u in (d.get("photo") or []) if isinstance(u, str)] features = [f for f in (d.get("vehicle_options_fr") or d.get("vehicle_options") or []) if isinstance(f, str)] for key in ("version", "engine_size", "cylinders", "entrydate", "tyres_size", "tags"): if d.get(key): details[key] = d[key] vin = "" if d.get("hide_vin") else str(d.get("serialnumber") or "") vehicles.append(Vehicle( source=self.source_id, external_id=str(d["_id"]), url=self._detail_url(d), title=title, make=str(d.get("make") or ""), model=str(d.get("model") or ""), trim=str(d.get("trim") or ""), year=d.get("year") if isinstance(d.get("year"), int) 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=str(d.get("transtype") or ""), fuel=str(d.get("fuel") or ""), drivetrain=str(d.get("drivetrain") or ""), body_type=str(d.get("body_type") or ""), exterior_color=str(d.get("base_ext_color") or ""), interior_color=str(d.get("base_int_color") or ""), engine=str(d.get("engine") or ""), doors=d.get("doors") if isinstance(d.get("doors"), int) else None, seats=(d.get("passengers") if isinstance(d.get("passengers"), int) else None), vin=vin, stock_number=str(d.get("stocknumber") or ""), dealer_name=self.dealer_name, city=self.city, region=self.region, features=features[:80], details=details, images=photos[:20], carfax_url=str(d.get("carfax_url_fr") or d.get("carfax_url_en") or ""), )) return vehicles # --------------------------------------------------------------------------- # Concessionnaires (1 sous-classe = 1 source) # --------------------------------------------------------------------------- class AutoGlobalMTL(ADWSConnector): source_id = "autoglobalmtl" base_url = "https://www.autoglobalmtl.com" dealer_name = "Auto Global MTL" city = "Montréal" detail_path = "vehicle" class GroupeAutomobile(ADWSConnector): source_id = "groupeautomobile" base_url = "https://www.groupeautomobile.com" dealer_name = "Le Groupe Automobile Laval" city = "Laval" detail_path = "vehicule" class SummumAuto(ADWSConnector): source_id = "summumauto" base_url = "https://www.summumauto.com" dealer_name = "Summum Auto" city = "Laval" detail_path = "vehicle" class Comme1Neuf(ADWSConnector): # occasionvilledequebec.com redirige (301) vers comme1neuf.com — même commerce source_id = "occasionvilledequebec" base_url = "https://www.comme1neuf.com" dealer_name = "Comme1Neuf" city = "Château-Richer" region = "Capitale-Nationale" detail_path = "vehicule"