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/hgregoire.py : connecteur HGrégoire (hgregoire.com), la plus5# grosse chaîne de mégacentres d'occasion au Québec (St-Léonard, Laval,6# St-Eustache, Saguenay/Chicoutimi, Carbonear/Gaspésie, etc.).7#8# Stratégie : la page de résultats /auto-usage?page=N est rendue côté9# serveur (WordPress custom) et embarque, pour CHAQUE carte véhicule, un10# <script type="application/json" data-class="Vehicle"> complet :11# Id, année/marque/modèle/version, prix (CAD), odomètre, VIN, no de stock,12# transmission, carburant, traction, carrosserie, couleurs, moteur,13# consommation, photo principale, Permalink ET la succursale du véhicule14# (FullLocation.city + province). 24 véhicules par page, ~74 pages —15# AUCUNE page détail nécessaire : ~75 requêtes pour tout l'inventaire.16#17# Seules les succursales du QUÉBEC sont conservées (FullLocation.province18# == "QC") — le groupe opère aussi aux États-Unis (hgreg.com) et un19# véhicule hors-Québec serait exclu par ce garde-fou.20#21# robots.txt demande Crawl-delay: 10 → request_delay élevé (sync complet22# ≈ 12 min, acceptable pour une synchronisation quotidienne).23# AUTOKA_MAX_PAGES (env) limite le nombre de pages pour les tests.24#25# ENRICHISSEMENT DÉTAIL (galerie + équipements) : la carte liste n'a qu'UNE26# photo et quelques équipements ; la page détail expose la galerie complète27# (~25 photos) et l'onglet « Équipements » (~40 items). Avec Crawl-delay: 1028# un crawl direct des ~1 700 fiches prendrait ~4,7 h → les pages détail29# passent par SCRAPFLY (requêtes gérées par leur infra, non soumises au30# crawl-delay de notre IP). Cache BD à vie (clé stable « v1 » — la galerie31# et les équipements ne changent pas), plafond AUTOKA_MAX_DETAILS (défaut32# 300/sync) : l'inventaire complet s'enrichit en ~6 synchronisations.33# Le véhicule est toujours émis avec les données liste même sans détail.34# -----------------------------------------------------------------------------35from __future__ import annotations3637import json38import os39import re4041from ..normalize import MAKES42from ..schema import Vehicle, strip_accents43from .base import BaseConnector4445_MAKE_WORDS = {strip_accents(m).lower() for m in MAKES}4647_BLOB_RE = re.compile(48 r'<script type="application/json" data-class="Vehicle"[^>]*>'49 r'/\*<!\[CDATA\[\*/(.*?)/\*\]\]>\*/</script>', re.S)50_PAGE_RE = re.compile(r'data-page="(\d+)"')51_DOORS_RE = re.compile(r"(\d+)\s*portes?", re.I)52# photo de carte 448x252 -> version plus grande servie par le même CDN53_IMG_SIZE_RE = re.compile(r"/by-size/([^/]+)/\d+x\d+/")5455# page détail : galerie (chaque photo répétée en plusieurs tailles),56# onglet « Équipements » et lien Carfax éventuel57_GALLERY_RE = re.compile( # album parfois alphanumérique (ex. KA8107)58 r"https://[^\"'\s\\<>]*/photos/by-size/([\w-]+)/\d+x\d+/(\w+)\.(\w+)")59_EQUIP_TAB_RE = re.compile(60 r'id="vdptab-equipment"(.*?)(?:show-all-equipment|</section>)', re.S)61_EQUIP_ITEM_RE = re.compile(r"<span>\s*([^<]+?)\s*</span>")62_CARFAX_RE = re.compile(63 r'https?://[^"\'\s<>]*carfax[^"\'\s<>]*', re.I)6465# clé de cache détail stable (bump si le parsing change)66_DETAIL_KEY = "v1"6768MILE_KM = 1.609344697071# bannières du groupe qui ne sont pas des noms de ville72_BANNER_CITY = {73 "ineos grenadier": "Montréal", # INEOS Grenadier Montréal74 "le prix du gros": "Trois-Rivières", # Méga centre d'occasion Le Prix du Gros75}767778def _clean_city(raw: str) -> str:79 """Succursale -> ville : « Kia Vaudreuil » -> « Vaudreuil »,80 « Nissan St-Eustache » -> « St-Eustache » (certaines succursales du81 groupe sont des bannières de marque ; on retire le préfixe de marque)."""82 banner = _BANNER_CITY.get(strip_accents(raw or "").lower().strip())83 if banner:84 return banner85 words = (raw or "").split()86 while len(words) > 1 and strip_accents(words[0]).lower() in _MAKE_WORDS:87 words = words[1:]88 return " ".join(words)899091class HGregoire(BaseConnector):92 """HGrégoire — inventaire usagé complet via les pages liste (SRP)."""9394 source_id = "hgregoire"95 base_url = "https://www.hgregoire.com"96 dealer_name = "HGrégoire"97 request_delay = 10.0 # robots.txt : Crawl-delay: 1098 max_pages = 200 # garde-fou dur (~74 pages réelles)99 max_details = 300 # plafond de requêtes Scrapfly / sync100101 # -- pages liste ------------------------------------------------------102 def _list_html(self, page: int) -> str:103 url = f"{self.base_url}/auto-usage"104 if page > 1:105 url += f"?page={page}"106 return self.get(url).text107108 def _parse_blobs(self, html: str) -> list[dict]:109 out = []110 for raw in _BLOB_RE.findall(html):111 try:112 out.append(json.loads(raw))113 except ValueError:114 continue115 return out116117 # -- page détail (via Scrapfly) -> payload -----------------------------118 def _fetch_detail(self, url: str) -> dict:119 """Galerie complète + équipements + carfax d'une fiche véhicule.120121 Passe par Scrapfly (pas de crawl-delay pour notre IP). Lève une122 exception si la page ne ressemble pas à une fiche véhicule, pour ne123 jamais mettre en cache un payload vide issu d'une page d'erreur.124 """125 html = self.get_scrapfly(url)126 payload: dict = {"images": [], "features": []}127128 seen: set[str] = set()129 for album, pid, ext in _GALLERY_RE.findall(html):130 if pid not in seen: # chaque photo répétée en N tailles131 seen.add(pid)132 payload["images"].append(133 f"https://cfwww.hgregoire.com/photos/by-size/"134 f"{album}/1010x568/{pid}.{ext}")135136 m = _EQUIP_TAB_RE.search(html)137 if m:138 feats: list[str] = []139 for f in _EQUIP_ITEM_RE.findall(m.group(1)):140 f = " ".join(f.split())141 if f and f not in feats:142 feats.append(f)143 payload["features"] = feats144145 m = _CARFAX_RE.search(html)146 if m:147 payload["carfax_url"] = m.group(0)148149 if not payload["images"] and 'data-class="Vehicle"' not in html:150 raise RuntimeError(f"page détail HGrégoire illisible : {url}")151 return payload152153 # -- contrat ----------------------------------------------------------154 def fetch(self) -> list[Vehicle]:155 cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))156 detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))157 vehicles: list[Vehicle] = []158 seen: set[str] = set()159160 html = self._list_html(1)161 pages = [int(p) for p in _PAGE_RE.findall(html)]162 last_page = min(max(pages) if pages else 1, cap, self.max_pages)163164 page = 1165 while True:166 blobs = self._parse_blobs(html)167 if not blobs:168 break169 new = 0170 for d in blobs:171 veh = self._to_vehicle(d)172 if veh is not None and veh.external_id not in seen:173 seen.add(veh.external_id)174 vehicles.append(veh)175 new += 1176 page += 1177 if page > last_page or new == 0:178 break179 html = self._list_html(page)180181 # enrichissement détail (Scrapfly) — cache BD, budget par sync ;182 # le véhicule reste émis avec les données liste même sans détail183 real_fetches = 0184 for veh in vehicles:185 detail: dict = {}186 if real_fetches >= detail_cap:187 from .. import db188 if self._detail_con is None:189 self._detail_con = db.connect()190 detail = db.get_cached_detail(self._detail_con, self.source_id,191 veh.external_id,192 _DETAIL_KEY) or {}193 else:194 did_fetch = False195196 def _fetch(u=veh.url):197 nonlocal did_fetch198 did_fetch = True199 return self._fetch_detail(u)200201 try:202 detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch)203 except Exception:204 detail = {} # détail indisponible : liste suffisante205 if did_fetch:206 real_fetches += 1207 self._apply_detail(veh, detail or {})208 return vehicles209210 @staticmethod211 def _apply_detail(veh: Vehicle, detail: dict) -> None:212 images = [u for u in detail.get("images") or [] if isinstance(u, str)]213 if images:214 veh.images = images[:30]215 feats = [f for f in detail.get("features") or [] if isinstance(f, str)]216 if feats: # union liste + détail, ordre préservé217 merged = list(veh.features)218 merged += [f for f in feats if f not in merged]219 veh.features = merged[:80]220 if detail.get("carfax_url"):221 veh.carfax_url = str(detail["carfax_url"])222223 # -- carte JSON -> Vehicle ----------------------------------------------224 def _to_vehicle(self, d: dict) -> Vehicle | None:225 if d.get("Sold") or d.get("Deleted") or d.get("IsForSale") is False:226 return None227 if str(d.get("Type") or "Used") != "Used": # usagés seulement228 return None229230 loc = d.get("FullLocation") or {}231 province = (loc.get("province") or "").strip().upper()232 if province and province != "QC": # succursales US exclues233 return None234235 ext_id = str(d.get("Id") or "").strip()236 url = d.get("Permalink") or ""237 if not ext_id or not url:238 return None239240 year = d.get("Year")241 make = str(d.get("Make") or "").strip()242 model = str(d.get("Model") or "").strip()243 title = (d.get("ShortTitle")244 or " ".join(str(x) for x in (year, make, model) if x)).strip()245246 price = d.get("Price")247 try:248 price = float(price) if price else None249 except (TypeError, ValueError):250 price = None251252 km = d.get("Odometer")253 try:254 km = float(km) if km not in (None, "") else None255 except (TypeError, ValueError):256 km = None257 if km is not None and str(d.get("OdoUnitCode") or "").upper() == "SMI":258 km = round(km * MILE_KM) # milles -> km259260 # portes / passagers / équipements depuis la liste CarDetails261 doors = seats = None262 features: list[str] = []263 for item in d.get("CarDetails") or []:264 name = str(item.get("name") or "").strip()265 value = str(item.get("value") or "").strip()266 if not value: # équipement pur267 m = _DOORS_RE.search(name)268 if m:269 doors = int(m.group(1))270 elif name and "Stock" not in name:271 features.append(name.rstrip(" :"))272 elif "passagers" in name.lower():273 try:274 seats = int(value)275 except ValueError:276 pass277278 image = d.get("MainImage") or ""279 if image: # carte 448x252 -> grand280 image = _IMG_SIZE_RE.sub(r"/by-size/\1/1010x568/", image)281282 post = d.get("post") or {}283 description = str(post.get("post_content") or "")[:4000]284285 details: dict = {}286 if loc.get("city_slug"):287 details["branch_slug"] = loc["city_slug"]288 if d.get("Categories"):289 details["categories"] = d["Categories"]290 if d.get("HwyMPG"):291 details["consumption_hwy_l_100km"] = d["HwyMPG"]292 if d.get("OldPrice"):293 details["old_price"] = d["OldPrice"]294295 return Vehicle(296 source=self.source_id,297 external_id=ext_id,298 url=url,299 title=title,300 make=make,301 model=model,302 trim=str(d.get("Trim") or "").strip(),303 year=int(year) if year else None,304 price=price,305 price_label=str(d.get("FormattedPrice") or ""),306 mileage_km=km,307 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",308 transmission=str(d.get("Transmission") or ""),309 fuel=str(d.get("Fuel") or ""),310 drivetrain=str(d.get("DrivetrainType") or ""),311 body_type=str(d.get("BodyStyle") or ""),312 exterior_color=str(d.get("ExteriorColor") or ""),313 interior_color=str(d.get("InteriorColor") or ""),314 engine=str(d.get("Engine") or ""),315 doors=doors,316 seats=seats,317 vin=str(d.get("VIN") or ""),318 stock_number=str(d.get("Stock") or ""),319 dealer_name=self.dealer_name,320 city=_clean_city(str(loc.get("city") or "").strip()),321 description=description,322 features=features,323 details=details,324 images=[image] if image else [],325 )326