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/leprixdugros.py : connecteur Le Prix du Gros (leprixdugros.com),5# groupe Mauricie / Centre-du-Québec (Kia, Nissan, Hyundai, Mazda + méga6# centre d'occasion) — Trois-Rivières, Shawinigan, Donnacona, Lévis,7# Québec, Laval, Sherbrooke, Joliette. INVENTAIRE OCCASION UNIQUEMENT.8#9# Plateforme : WordPress custom (thème Stereodev « lpdg-2022 »). La page10# liste expose un endpoint JSON paginé non documenté (celui du filtre Vue) :11# /inventaire/occasion/?output=json&pg=N -> {items(36/page), total, page}.12# Chaque item : année/marque/modèle/version, km, VIN, prix, rabais, stock,13# photo, permalien. ~37 pages pour ~1 300 véhicules d'occasion.14#15# La page détail (permalien) complète : moteur, rouage, transmission,16# passagers, couleurs, photos ET la concession où se trouve le véhicule17# (bloc « c-single-car-map-location » : nom + ville). Ces pages sont mises18# en cache BD à vie (clé stable — les specs ne changent pas ; le prix et le19# km frais viennent de la liste). AUTOKA_MAX_DETAILS (env) plafonne les20# vraies requêtes détail par sync ; AUTOKA_MAX_PAGES limite la pagination.21#22# NOTE : les pages détail n'ont NI description NI liste d'équipements par23# véhicule (les blocs « cards » sont du boilerplate : inspection 150 points,24# reconditionnement) — rien à extraire de plus que les specs/ville/photos.25# Le site est parfois lent (timeouts ponctuels) : chaque page détail tente26# d'abord un GET direct, puis bascule sur Scrapfly en cas d'échec ; le27# try/except par annonce garantit l'émission avec les données liste.28# Carburant : la source ne l'affiche jamais ; en plus des jetons explicites29# (hybride/EV/diesel), un moteur « N cylindres X.XL » sans indice électrifié30# => Essence (sauf modèles hybrides « sans badge » connus, ex. Prius).31# -----------------------------------------------------------------------------32from __future__ import annotations3334import html as htmllib35import os36import re3738from ..schema import Vehicle39from .base import BaseConnector4041_SPEC_RE = re.compile(42 r'c-single-car-features__list__text">\s*<strong>([^<]+)</strong><br>\s*'43 r"<span>([^<]*)</span>", re.S)44_LOC_NAME_RE = re.compile(45 r'c-single-car-map-location__name">\s*([^<]+?)\s*</span>', re.S)46_LOC_ADDR_RE = re.compile(47 r'c-single-car-map-location__adresse">(.*?)</span>', re.S)48_CITY_RE = re.compile(r"([A-Za-zÀ-ÿ'’ .-]+?)\s*,\s*(?:QC|Québec)\b")49_IMG_RE = re.compile(r'https://images\.leprixdugros\.com/[^"\s\\<>]+')5051_FUEL_HINT_RE = re.compile(52 r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|electrique|"53 r"électrique|electric|\bev\b|diesel|tdi)", re.I)5455# moteur thermique explicite (« 4 cylindres 2.00L ») -> Essence par défaut56_CYL_ENGINE_RE = re.compile(r"\d+\s*cylindres?", re.I)57# modèles électrifiés « sans badge » : ne jamais leur inférer Essence58_ELECTRIFIED_MODELS = {59 "prius", "prius prime", "prius c", "prius v", "ioniq", "niro",60 "insight", "clarity", "volt", "bolt", "bolt euv", "leaf", "c-max",61 "cr-z", "ct 200h", "sienna", "venza", "crown", "sonata hybrid",62}63# camions HD & fourgons souvent diesel SANS mention explicite (ex. Ram 350064# « 6 cylindres 6.70L » = Cummins diesel) : ne pas inférer Essence65_DIESEL_PRONE_RE = re.compile(66 r"\b(2500|3500|4500|5500|f-?[2-5]50|sprinter|promaster)\b", re.I)676869def _clean(text: str | None) -> str:70 if not text:71 return ""72 out = htmllib.unescape(str(text))73 out = re.sub(r"<[^>]+>", " ", out)74 return re.sub(r"\s{2,}", " ", out).strip()757677# clé de cache détail stable (bump si le parsing change)78_DETAIL_KEY = "v2"798081class LePrixDuGros(BaseConnector):82 """Le Prix du Gros — JSON liste paginé + pages détail (specs, ville)."""8384 source_id = "leprixdugros"85 base_url = "https://www.leprixdugros.com"86 dealer_name = "Le Prix du Gros"87 request_delay = 1.0 # >= 1 s : le site tolère mal davantage88 max_pages = 80 # garde-fou dur (~37 pages réelles)89 max_details = 800 # plafond de vraies requêtes détail / sync90 # (couvre les ~690 véhicules en 1 sync ;91 # cache BD ensuite)9293 # -- liste JSON ----------------------------------------------------------94 def _list_page(self, page: int) -> dict:95 url = (f"{self.base_url}/inventaire/occasion/"96 f"?output=json&pg={page}")97 try:98 return self.get(url).json()99 except ValueError:100 return {}101102 # -- page détail -> payload (specs, concession, photos) -------------------103 def _fetch_detail(self, url: str) -> dict:104 try:105 html = self.get(url).text106 except Exception:107 # site lent / timeout ponctuel : repli Scrapfly (infra distincte)108 html = self.get_scrapfly(url)109 if not html:110 raise111 payload: dict = {"specs": {}, "images": []}112 for name, value in _SPEC_RE.findall(html):113 payload["specs"][_clean(name)] = _clean(value)114 m = _LOC_NAME_RE.search(html)115 if m:116 payload["dealer"] = _clean(m.group(1))117 m = _LOC_ADDR_RE.search(html)118 if m:119 # l'adresse est multi-lignes (<br>) : « 141 Rue Commerciale »,120 # « Donnacona, QC, G3M 1W2 », téléphone — la ville est le début121 # de LA ligne contenant « , QC »122 lines = [_clean(l) for l in re.split(r"<br\s*/?>", m.group(1))]123 lines = [l for l in lines if l]124 payload["address"] = ", ".join(lines[:2])125 for line in lines:126 c = _CITY_RE.match(line)127 if c:128 payload["city"] = c.group(1).strip()129 break130 seen: set[str] = set()131 for u in _IMG_RE.findall(html):132 if "_1000x750" in u and u not in seen:133 seen.add(u)134 payload["images"].append(u)135 if not payload["specs"] and not payload["images"]:136 # page d'erreur / interstitiel : ne pas mettre en cache du vide137 raise RuntimeError(f"page détail LPDG illisible : {url}")138 return payload139140 # -- contrat ---------------------------------------------------------------141 def fetch(self) -> list[Vehicle]:142 page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))143 detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))144145 items: list[dict] = []146 seen_ids: set[str] = set()147 page, last_page = 1, 1148 while page <= min(last_page, page_cap, self.max_pages):149 data = self._list_page(page)150 batch = data.get("items") or []151 if not batch:152 break153 per_page = int(data.get("perPage") or len(batch) or 36)154 total = int(data.get("total") or 0)155 last_page = max(1, -(-total // max(per_page, 1))) # ceil156 new = 0157 for item in batch:158 ext_id = str(item.get("id") or item.get("stocknumber") or "")159 if ext_id and ext_id not in seen_ids:160 seen_ids.add(ext_id)161 items.append(item)162 new += 1163 if new == 0:164 break165 page += 1166167 vehicles: list[Vehicle] = []168 real_fetches = 0169 for item in items:170 detail: dict = {}171 url = str(item.get("permalink") or "")172 ext_id = str(item.get("id") or item.get("stocknumber") or "")173 if url:174 def _fetch(u=url):175 return self._fetch_detail(u)176177 # clé stable : specs figées ; prix/km frais via la liste178 if real_fetches >= detail_cap:179 from .. import db180 if self._detail_con is None:181 self._detail_con = db.connect()182 detail = db.get_cached_detail(self._detail_con,183 self.source_id,184 ext_id, _DETAIL_KEY) or {}185 else:186 before = self._last_request187 try:188 detail = self.detail(ext_id, _DETAIL_KEY, _fetch)189 except Exception:190 # page détail disparue ou timeout ponctuel : le191 # véhicule est quand même émis avec les données liste192 detail = {}193 if self._last_request != before:194 real_fetches += 1195 veh = self._to_vehicle(item, detail or {})196 if veh is not None:197 vehicles.append(veh)198 return vehicles199200 # -- item liste + détail -> Vehicle ------------------------------------------201 def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None:202 inventory = str(item.get("inventory") or "used").lower()203 if inventory == "new": # occasion seulement204 return None205206 ext_id = str(item.get("id") or item.get("stocknumber") or "")207 url = str(item.get("permalink") or "")208 if not ext_id or not url:209 return None210211 make = str(item.get("make") or "").strip()212 model = str(item.get("model") or "").strip()213 trim = _clean(item.get("trim"))214 year = item.get("year")215 title = _clean(item.get("title"))216 title = re.sub(r"\s+d[’']occasion à vendre\s*$", "", title, flags=re.I) \217 or " ".join(str(x) for x in (make, model, trim, year or "") if x)218219 price = item.get("price")220 try:221 price = float(price) if price else None222 except (TypeError, ValueError):223 price = None224225 km = item.get("kms")226 try:227 km = float(km) if km not in (None, "") else None228 except (TypeError, ValueError):229 km = None230231 specs = detail.get("specs") or {}232 engine = specs.get("Moteur", "")233 seats = None234 m = re.search(r"\d+", specs.get("Passagers", ""))235 if m:236 seats = int(m.group(0))237238 # carburant : déduit du titre/version/moteur (non affiché ailleurs)239 fuel = ""240 m = _FUEL_HINT_RE.search(f"{title} {trim} {engine}")241 if m:242 fuel = m.group(1)243 elif (_CYL_ENGINE_RE.search(engine)244 and model.lower() not in _ELECTRIFIED_MODELS245 and not _DIESEL_PRONE_RE.search(f"{title} {model} {trim}")):246 # moteur « N cylindres X.XL » sans aucun indice électrifié/diesel247 fuel = "Essence"248249 images = list(detail.get("images") or [])250 if not images and item.get("photo"):251 images = [str(item["photo"])]252253 details: dict = {"inventory_type": inventory}254 if item.get("rebate"):255 details["rebate"] = item["rebate"]256 if str(item.get("is_certified") or "0") not in ("0", "", "None"):257 details["certified"] = True258 if specs.get("Cabine"):259 details["cab"] = specs["Cabine"]260 if detail.get("address"):261 details["branch_address"] = detail["address"]262 if detail.get("dealer"):263 details["branch"] = detail["dealer"]264265 return Vehicle(266 source=self.source_id,267 external_id=ext_id,268 url=url,269 title=title,270 make=make,271 model=model,272 trim=trim,273 year=int(year) if year else None,274 price=price,275 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",276 mileage_km=km,277 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",278 transmission=specs.get("Transmission", ""),279 fuel=fuel,280 drivetrain=specs.get("Rouage", ""),281 body_type="", # non exposé par la source282 exterior_color=specs.get("Couleur extérieure", ""),283 interior_color=specs.get("Couleur intérieure", ""),284 engine=engine,285 seats=seats,286 vin=str(item.get("vin") or ""),287 stock_number=str(item.get("stocknumber") or ""),288 dealer_name=detail.get("dealer") or self.dealer_name,289 city=detail.get("city", ""),290 details=details,291 images=images[:20],292 )293