# ----------------------------------------------------------------------------- # 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], )