# -----------------------------------------------------------------------------
# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/leprixdugros.py : connecteur Le Prix du Gros (leprixdugros.com),
# groupe Mauricie / Centre-du-Québec (Kia, Nissan, Hyundai, Mazda + méga
# centre d'occasion) — Trois-Rivières, Shawinigan, Donnacona, Lévis,
# Québec, Laval, Sherbrooke, Joliette. INVENTAIRE OCCASION UNIQUEMENT.
#
# Plateforme : WordPress custom (thème Stereodev « lpdg-2022 »). La page
# liste expose un endpoint JSON paginé non documenté (celui du filtre Vue) :
# /inventaire/occasion/?output=json&pg=N -> {items(36/page), total, page}.
# Chaque item : année/marque/modèle/version, km, VIN, prix, rabais, stock,
# photo, permalien. ~37 pages pour ~1 300 véhicules d'occasion.
#
# La page détail (permalien) complète : moteur, rouage, transmission,
# passagers, couleurs, photos ET la concession où se trouve le véhicule
# (bloc « c-single-car-map-location » : nom + ville). Ces pages sont mises
# en cache BD à vie (clé stable — les specs ne changent pas ; le prix et le
# km frais viennent de la liste). AUTOKA_MAX_DETAILS (env) plafonne les
# vraies requêtes détail par sync ; AUTOKA_MAX_PAGES limite la pagination.
#
# NOTE : les pages détail n'ont NI description NI liste d'équipements par
# véhicule (les blocs « cards » sont du boilerplate : inspection 150 points,
# reconditionnement) — rien à extraire de plus que les specs/ville/photos.
# Le site est parfois lent (timeouts ponctuels) : chaque page détail tente
# d'abord un GET direct, puis bascule sur Scrapfly en cas d'échec ; le
# try/except par annonce garantit l'émission avec les données liste.
# Carburant : la source ne l'affiche jamais ; en plus des jetons explicites
# (hybride/EV/diesel), un moteur « N cylindres X.XL » sans indice électrifié
# => Essence (sauf modèles hybrides « sans badge » connus, ex. Prius).
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as htmllib
import os
import re
from ..schema import Vehicle
from .base import BaseConnector
_SPEC_RE = re.compile(
r'c-single-car-features__list__text">\s*([^<]+)
\s*'
r"([^<]*)", re.S)
_LOC_NAME_RE = re.compile(
r'c-single-car-map-location__name">\s*([^<]+?)\s*', re.S)
_LOC_ADDR_RE = re.compile(
r'c-single-car-map-location__adresse">(.*?)', re.S)
_CITY_RE = re.compile(r"([A-Za-zÀ-ÿ'’ .-]+?)\s*,\s*(?:QC|Québec)\b")
_IMG_RE = re.compile(r'https://images\.leprixdugros\.com/[^"\s\\<>]+')
_FUEL_HINT_RE = re.compile(
r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|electrique|"
r"électrique|electric|\bev\b|diesel|tdi)", re.I)
# moteur thermique explicite (« 4 cylindres 2.00L ») -> Essence par défaut
_CYL_ENGINE_RE = re.compile(r"\d+\s*cylindres?", re.I)
# modèles électrifiés « sans badge » : ne jamais leur inférer Essence
_ELECTRIFIED_MODELS = {
"prius", "prius prime", "prius c", "prius v", "ioniq", "niro",
"insight", "clarity", "volt", "bolt", "bolt euv", "leaf", "c-max",
"cr-z", "ct 200h", "sienna", "venza", "crown", "sonata hybrid",
}
# camions HD & fourgons souvent diesel SANS mention explicite (ex. Ram 3500
# « 6 cylindres 6.70L » = Cummins diesel) : ne pas inférer Essence
_DIESEL_PRONE_RE = re.compile(
r"\b(2500|3500|4500|5500|f-?[2-5]50|sprinter|promaster)\b", re.I)
def _clean(text: str | None) -> str:
if not text:
return ""
out = htmllib.unescape(str(text))
out = re.sub(r"<[^>]+>", " ", out)
return re.sub(r"\s{2,}", " ", out).strip()
# clé de cache détail stable (bump si le parsing change)
_DETAIL_KEY = "v2"
class LePrixDuGros(BaseConnector):
"""Le Prix du Gros — JSON liste paginé + pages détail (specs, ville)."""
source_id = "leprixdugros"
base_url = "https://www.leprixdugros.com"
dealer_name = "Le Prix du Gros"
request_delay = 1.0 # >= 1 s : le site tolère mal davantage
max_pages = 80 # garde-fou dur (~37 pages réelles)
max_details = 800 # plafond de vraies requêtes détail / sync
# (couvre les ~690 véhicules en 1 sync ;
# cache BD ensuite)
# -- liste JSON ----------------------------------------------------------
def _list_page(self, page: int) -> dict:
url = (f"{self.base_url}/inventaire/occasion/"
f"?output=json&pg={page}")
try:
return self.get(url).json()
except ValueError:
return {}
# -- page détail -> payload (specs, concession, photos) -------------------
def _fetch_detail(self, url: str) -> dict:
try:
html = self.get(url).text
except Exception:
# site lent / timeout ponctuel : repli Scrapfly (infra distincte)
html = self.get_scrapfly(url)
if not html:
raise
payload: dict = {"specs": {}, "images": []}
for name, value in _SPEC_RE.findall(html):
payload["specs"][_clean(name)] = _clean(value)
m = _LOC_NAME_RE.search(html)
if m:
payload["dealer"] = _clean(m.group(1))
m = _LOC_ADDR_RE.search(html)
if m:
# l'adresse est multi-lignes (
) : « 141 Rue Commerciale »,
# « Donnacona, QC, G3M 1W2 », téléphone — la ville est le début
# de LA ligne contenant « , QC »
lines = [_clean(l) for l in re.split(r"
", m.group(1))]
lines = [l for l in lines if l]
payload["address"] = ", ".join(lines[:2])
for line in lines:
c = _CITY_RE.match(line)
if c:
payload["city"] = c.group(1).strip()
break
seen: set[str] = set()
for u in _IMG_RE.findall(html):
if "_1000x750" in u and u not in seen:
seen.add(u)
payload["images"].append(u)
if not payload["specs"] and not payload["images"]:
# page d'erreur / interstitiel : ne pas mettre en cache du vide
raise RuntimeError(f"page détail LPDG illisible : {url}")
return payload
# -- contrat ---------------------------------------------------------------
def fetch(self) -> list[Vehicle]:
page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
items: list[dict] = []
seen_ids: set[str] = set()
page, last_page = 1, 1
while page <= min(last_page, page_cap, self.max_pages):
data = self._list_page(page)
batch = data.get("items") or []
if not batch:
break
per_page = int(data.get("perPage") or len(batch) or 36)
total = int(data.get("total") or 0)
last_page = max(1, -(-total // max(per_page, 1))) # ceil
new = 0
for item in batch:
ext_id = str(item.get("id") or item.get("stocknumber") or "")
if ext_id and ext_id not in seen_ids:
seen_ids.add(ext_id)
items.append(item)
new += 1
if new == 0:
break
page += 1
vehicles: list[Vehicle] = []
real_fetches = 0
for item in items:
detail: dict = {}
url = str(item.get("permalink") or "")
ext_id = str(item.get("id") or item.get("stocknumber") or "")
if url:
def _fetch(u=url):
return self._fetch_detail(u)
# clé stable : specs figées ; prix/km frais via la liste
if real_fetches >= detail_cap:
from .. import db
if self._detail_con is None:
self._detail_con = db.connect()
detail = db.get_cached_detail(self._detail_con,
self.source_id,
ext_id, _DETAIL_KEY) or {}
else:
before = self._last_request
try:
detail = self.detail(ext_id, _DETAIL_KEY, _fetch)
except Exception:
# page détail disparue ou timeout ponctuel : le
# véhicule est quand même émis avec les données liste
detail = {}
if self._last_request != before:
real_fetches += 1
veh = self._to_vehicle(item, detail or {})
if veh is not None:
vehicles.append(veh)
return vehicles
# -- item liste + détail -> Vehicle ------------------------------------------
def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None:
inventory = str(item.get("inventory") or "used").lower()
if inventory == "new": # occasion seulement
return None
ext_id = str(item.get("id") or item.get("stocknumber") or "")
url = str(item.get("permalink") or "")
if not ext_id or not url:
return None
make = str(item.get("make") or "").strip()
model = str(item.get("model") or "").strip()
trim = _clean(item.get("trim"))
year = item.get("year")
title = _clean(item.get("title"))
title = re.sub(r"\s+d[’']occasion à vendre\s*$", "", title, flags=re.I) \
or " ".join(str(x) for x in (make, model, trim, year or "") if x)
price = item.get("price")
try:
price = float(price) if price else None
except (TypeError, ValueError):
price = None
km = item.get("kms")
try:
km = float(km) if km not in (None, "") else None
except (TypeError, ValueError):
km = None
specs = detail.get("specs") or {}
engine = specs.get("Moteur", "")
seats = None
m = re.search(r"\d+", specs.get("Passagers", ""))
if m:
seats = int(m.group(0))
# carburant : déduit du titre/version/moteur (non affiché ailleurs)
fuel = ""
m = _FUEL_HINT_RE.search(f"{title} {trim} {engine}")
if m:
fuel = m.group(1)
elif (_CYL_ENGINE_RE.search(engine)
and model.lower() not in _ELECTRIFIED_MODELS
and not _DIESEL_PRONE_RE.search(f"{title} {model} {trim}")):
# moteur « N cylindres X.XL » sans aucun indice électrifié/diesel
fuel = "Essence"
images = list(detail.get("images") or [])
if not images and item.get("photo"):
images = [str(item["photo"])]
details: dict = {"inventory_type": inventory}
if item.get("rebate"):
details["rebate"] = item["rebate"]
if str(item.get("is_certified") or "0") not in ("0", "", "None"):
details["certified"] = True
if specs.get("Cabine"):
details["cab"] = specs["Cabine"]
if detail.get("address"):
details["branch_address"] = detail["address"]
if detail.get("dealer"):
details["branch"] = detail["dealer"]
return Vehicle(
source=self.source_id,
external_id=ext_id,
url=url,
title=title,
make=make,
model=model,
trim=trim,
year=int(year) if year 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=specs.get("Transmission", ""),
fuel=fuel,
drivetrain=specs.get("Rouage", ""),
body_type="", # non exposé par la source
exterior_color=specs.get("Couleur extérieure", ""),
interior_color=specs.get("Couleur intérieure", ""),
engine=engine,
seats=seats,
vin=str(item.get("vin") or ""),
stock_number=str(item.get("stocknumber") or ""),
dealer_name=detail.get("dealer") or self.dealer_name,
city=detail.get("city", ""),
details=details,
images=images[:20],
)