# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/central_dealers.py : connecteurs des concessionnaires sur la # plateforme AMVOQ « Focus 360 / Instavid360 » (thème WordPress # `theme_central`, inventaire Vue.js alimenté par un index Solr). # # Stratégie : le thème expose un endpoint JSON non authentifié # /wp-content/themes/theme_central/include/functions/RechercheVoitures.php # (exige seulement un en-tête Referer du même site) qui renvoie les # documents Solr complets de l'inventaire : gvo_id, année, marque, modèle, # sous-modèle, prix, km, transmission, carburant, traction, carrosserie, # couleurs, portes, VIN, no d'unité, municipalité/région et 1re photo. # # La page détail (/vehicule-usage///, rendue côté serveur, # le slug est ignoré par le serveur) complète : galerie photos complète, # description, options/équipements, nb de passagers, cylindrée. Ces pages # sont mises en cache BD (clé statique v1 : le prix/km frais viennent # toujours de l'API liste, le contenu détail est quasi immuable). # # vehicule_type_i discrimine le type d'inventaire (1 = automobile, # 2 = moto, 3 = VR) ; on garde le type 1 + un garde-fou regex sur la # catégorie/carrosserie (motoneige, VTT, Classe A/B/C, récréatif…). # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import os import re from ..schema import Vehicle, strip_accents from .base import BaseConnector IMG_HOST = "https://image.autousagee.ca" _SEARCH_PATH = ("/wp-content/themes/theme_central/include/functions/" "RechercheVoitures.php") # catégories / carrosseries qui ne sont PAS des automobiles usagées _EXCLUDE_RE = re.compile( r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|\batv\b|scooter|spyder|" r"classe\s*[abc]\b|recreatif|roulotte|remorque|bateau|ponton|" r"tracteur|souffleuse|habitation|\bvr\b|campeur", re.I) def _slugify(text: str) -> str: """Slug d'URL à la façon du thème (le serveur ne le valide pas).""" t = strip_accents(text or "").lower() t = re.sub(r"[^a-z0-9]+", "-", t) return t.strip("-") or "vehicule" def _clean(text: str | None) -> str: if not text: return "" out = htmllib.unescape(str(text)) return re.sub(r"\s+", " ", out).strip() def _num(value, cast=float): try: v = cast(value) except (TypeError, ValueError): return None return v if v else None class CentralConnector(BaseConnector): """Base commune aux sites `theme_central` (AMVOQ / Instavid360).""" base_url: str = "" dealer_name: str = "" city: str = "" request_delay: float = 1.0 page_size: int = 200 # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit # pour les tests) — au-delà, le véhicule est émis avec les données liste # seulement et sera enrichi aux syncs suivants. max_details = 400 # -- API liste (Solr) ------------------------------------------------------- def _api_get(self, params: dict) -> dict: url = self.base_url + _SEARCH_PATH headers = { "Referer": f"{self.base_url}/inventaire/", "Accept": "application/json, text/plain, */*", } return self.get(url, params=params, headers=headers).json() def _api_docs(self) -> list[dict]: docs: list[dict] = [] page = 1 while page <= 50: data = self._api_get({ "order": "alphaASC", "type": 0, "nbCarsPerPage": self.page_size, "currentPage": page, }) batch = data.get("data") or [] docs.extend(batch) total = int((data.get("meta") or {}).get("numFound") or 0) if not batch or len(docs) >= total: break page += 1 return docs # -- page détail ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: from bs4 import BeautifulSoup soup = BeautifulSoup(self.get(url).text, "html.parser") payload: dict = {} # galerie photos complète images = [] for img in soup.select(".vdp__slider-image img[src]"): src = img["src"] if "autousagee.ca" in src and src not in images: images.append(src) payload["images"] = images[:30] # description libre desc = soup.select_one('div[name="description-content"]') if desc: text = _clean(desc.get_text(" ")) text = re.sub(r"^DESCRIPTION DU V[ÉE]HICULE\s*", "", text, flags=re.I) payload["description"] = text[:4000] # spécifications (label -> valeur) specs: dict[str, str] = {} info = soup.select_one('div[name="informations-content"]') if info: for li in info.select("li"): spans = [_clean(s.get_text()) for s in li.find_all("span")] if len(spans) >= 2 and spans[0] and "--" not in spans[1]: specs[spans[0]] = spans[1] payload["specs"] = specs # options / équipements feats = [] opts = soup.select_one('div[name="options-content"]') if opts: for li in opts.select("li"): t = _clean(li.get_text(" ")) if t and t not in feats: feats.append(t) payload["features"] = feats[:80] return payload # -- doc Solr -> Vehicle ------------------------------------------------------ def _to_vehicle(self, doc: dict) -> Vehicle | None: gvo_id = doc.get("vehicule_gvo_id_i") make = _clean(doc.get("vehicule_marque_nom_s")) model = _clean(doc.get("vehicule_modele_s")) if not gvo_id or not (make or model): return None year = _num(doc.get("vehicule_annee_i"), int) trim = _clean(doc.get("vehicule_sous_modele_valide_s")) slug = _slugify(f"{make} {model} {year or ''}") url = f"{self.base_url}/vehicule-usage/{slug}/{gvo_id}/" price = _num(doc.get("vehicule_prix_d")) km = _num(doc.get("vehicule_km_i")) city = _clean(doc.get("vehicule_municipalite_s")) or self.city body = _clean(doc.get("vehicule_carrosserie_type_s")).split(",")[0] photos = doc.get("vehicule_photo_t") or [] if isinstance(photos, str): photos = [photos] images = [f"{IMG_HOST}/1024/{p}" for p in photos if isinstance(p, str)] details: dict = {} for src_key, out_key in (("vehicule_rabais_d", "rabais"), ("vehicule_categorie_s", "categorie"), ("vehicule_garantie_legal_texte_s", "garantie")): if doc.get(src_key): details[out_key] = doc[src_key] title = " ".join(x for x in (make, model, trim, str(year or "")) if x) return Vehicle( source=self.source_id, external_id=str(gvo_id), url=url, title=title, make=make, model=model, trim=trim, year=year, 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=_clean(doc.get("vehicule_transmission_s")), fuel=_clean(doc.get("vehicule_carburant_nom_s")), drivetrain=_clean(doc.get("vehicule_traction_s")), body_type=body, exterior_color=_clean(doc.get("vehicule_couleur_exterieur_s")), interior_color=_clean(doc.get("vehicule_couleur_interieur_s")), engine=_clean(doc.get("vehicule_moteur_s")), doors=_num(doc.get("vehicule_porte_nom_s"), int), vin=_clean(doc.get("vehicule_numero_serie_s")), stock_number=_clean(doc.get("vehicule_numero_unite_s")), dealer_name=self.dealer_name, city=city.title() if city.isupper() else city, region=_clean(doc.get("vehicule_region_s")), details=details, images=images, ) def _enrich(self, veh: Vehicle, payload: dict) -> None: if payload.get("images"): veh.images = payload["images"][:30] if payload.get("description"): veh.description = payload["description"] if payload.get("features"): veh.features = payload["features"] specs = payload.get("specs") or {} seats = specs.get("Nombre de passagers") if seats and veh.seats is None: veh.seats = _num(seats, int) litres = specs.get("Moteur") cyl = specs.get("Cylindre") if litres and re.fullmatch(r"\d+(\.\d+)?", litres): veh.engine = f"{litres}L" + (f" {cyl} cyl." if cyl else "") # -- contrat ------------------------------------------------------------------ def fetch(self) -> list[Vehicle]: cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) vehicles: list[Vehicle] = [] real_fetches = 0 for doc in self._api_docs(): type_i = doc.get("vehicule_type_i") if type_i not in (None, 1): # 2 = moto, 3 = VR continue cat = f"{doc.get('vehicule_categorie_s') or ''} " \ f"{doc.get('vehicule_carrosserie_type_s') or ''}" if _EXCLUDE_RE.search(strip_accents(cat)): continue veh = self._to_vehicle(doc) if veh is None: continue # enrichissement page détail (galerie, description, options) — # servi du cache BD sauf pour les nouveaux véhicules, dans la # limite du budget de vraies requêtes. if real_fetches < cap: def _fetch(u=veh.url): return self._fetch_detail(u) before = self._last_request try: payload = self.detail(veh.external_id, "v1", _fetch) except Exception: payload = {} if self._last_request != before: real_fetches += 1 self._enrich(veh, payload) vehicles.append(veh) return vehicles # --------------------------------------------------------------------------- # Concessionnaires (1 sous-classe = 1 source) # --------------------------------------------------------------------------- class DenisAuto(CentralConnector): source_id = "denisauto" base_url = "https://www.denisauto.ca" dealer_name = "Denis Auto" city = "Gatineau" class JRAuto(CentralConnector): source_id = "jrautotr" base_url = "https://www.jrautotr.com" dealer_name = "J.R. Auto" city = "Trois-Rivières" class Autosag(CentralConnector): source_id = "autosag" base_url = "https://www.autosag.com" dealer_name = "Autosag" city = "Chicoutimi" class LeCentreAutoQuebec(CentralConnector): source_id = "lecentreautoquebec" base_url = "https://www.lecentreautoquebec.com" dealer_name = "Le Centre Auto Québec Plus" city = "Québec" class AutoCreditPlus(CentralConnector): source_id = "autocreditplus" base_url = "https://www.autocreditplus.ca" dealer_name = "Auto Crédit Plus" city = "Trois-Rivières" class FleuryAutoGroupe(CentralConnector): source_id = "fleuryautogroupe" base_url = "https://www.fleuryautogroupe.com" dealer_name = "Fleury Auto Groupe" city = "Gatineau" # --------------------------------------------------------------------------- # Expansion 3 — Bas-Saint-Laurent et île de Montréal # --------------------------------------------------------------------------- class AutoBSL(CentralConnector): source_id = "autobsl" base_url = "https://www.autobsl.com" dealer_name = "Auto BSL" city = "Rimouski" class AutoXtreme(CentralConnector): source_id = "autoxtreme" base_url = "https://www.autoxtreme.ca" dealer_name = "Auto Xtrême" city = "Montréal" class DAstousAuto(CentralConnector): source_id = "dastousauto" base_url = "https://www.dastousauto.com" dealer_name = "D'Astous Auto" city = "Montréal"