# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/hgregoire.py : connecteur HGrégoire (hgregoire.com), la plus # grosse chaîne de mégacentres d'occasion au Québec (St-Léonard, Laval, # St-Eustache, Saguenay/Chicoutimi, Carbonear/Gaspésie, etc.). # # Stratégie : la page de résultats /auto-usage?page=N est rendue côté # serveur (WordPress custom) et embarque, pour CHAQUE carte véhicule, un # ', re.S) _PAGE_RE = re.compile(r'data-page="(\d+)"') _DOORS_RE = re.compile(r"(\d+)\s*portes?", re.I) # photo de carte 448x252 -> version plus grande servie par le même CDN _IMG_SIZE_RE = re.compile(r"/by-size/([^/]+)/\d+x\d+/") # page détail : galerie (chaque photo répétée en plusieurs tailles), # onglet « Équipements » et lien Carfax éventuel _GALLERY_RE = re.compile( # album parfois alphanumérique (ex. KA8107) r"https://[^\"'\s\\<>]*/photos/by-size/([\w-]+)/\d+x\d+/(\w+)\.(\w+)") _EQUIP_TAB_RE = re.compile( r'id="vdptab-equipment"(.*?)(?:show-all-equipment|)', re.S) _EQUIP_ITEM_RE = re.compile(r"\s*([^<]+?)\s*") _CARFAX_RE = re.compile( r'https?://[^"\'\s<>]*carfax[^"\'\s<>]*', re.I) # clé de cache détail stable (bump si le parsing change) _DETAIL_KEY = "v1" MILE_KM = 1.609344 # bannières du groupe qui ne sont pas des noms de ville _BANNER_CITY = { "ineos grenadier": "Montréal", # INEOS Grenadier Montréal "le prix du gros": "Trois-Rivières", # Méga centre d'occasion Le Prix du Gros } def _clean_city(raw: str) -> str: """Succursale -> ville : « Kia Vaudreuil » -> « Vaudreuil », « Nissan St-Eustache » -> « St-Eustache » (certaines succursales du groupe sont des bannières de marque ; on retire le préfixe de marque).""" banner = _BANNER_CITY.get(strip_accents(raw or "").lower().strip()) if banner: return banner words = (raw or "").split() while len(words) > 1 and strip_accents(words[0]).lower() in _MAKE_WORDS: words = words[1:] return " ".join(words) class HGregoire(BaseConnector): """HGrégoire — inventaire usagé complet via les pages liste (SRP).""" source_id = "hgregoire" base_url = "https://www.hgregoire.com" dealer_name = "HGrégoire" request_delay = 10.0 # robots.txt : Crawl-delay: 10 max_pages = 200 # garde-fou dur (~74 pages réelles) max_details = 300 # plafond de requêtes Scrapfly / sync # -- pages liste ------------------------------------------------------ def _list_html(self, page: int) -> str: url = f"{self.base_url}/auto-usage" if page > 1: url += f"?page={page}" return self.get(url).text def _parse_blobs(self, html: str) -> list[dict]: out = [] for raw in _BLOB_RE.findall(html): try: out.append(json.loads(raw)) except ValueError: continue return out # -- page détail (via Scrapfly) -> payload ----------------------------- def _fetch_detail(self, url: str) -> dict: """Galerie complète + équipements + carfax d'une fiche véhicule. Passe par Scrapfly (pas de crawl-delay pour notre IP). Lève une exception si la page ne ressemble pas à une fiche véhicule, pour ne jamais mettre en cache un payload vide issu d'une page d'erreur. """ html = self.get_scrapfly(url) payload: dict = {"images": [], "features": []} seen: set[str] = set() for album, pid, ext in _GALLERY_RE.findall(html): if pid not in seen: # chaque photo répétée en N tailles seen.add(pid) payload["images"].append( f"https://cfwww.hgregoire.com/photos/by-size/" f"{album}/1010x568/{pid}.{ext}") m = _EQUIP_TAB_RE.search(html) if m: feats: list[str] = [] for f in _EQUIP_ITEM_RE.findall(m.group(1)): f = " ".join(f.split()) if f and f not in feats: feats.append(f) payload["features"] = feats m = _CARFAX_RE.search(html) if m: payload["carfax_url"] = m.group(0) if not payload["images"] and 'data-class="Vehicle"' not in html: raise RuntimeError(f"page détail HGrégoire illisible : {url}") return payload # -- contrat ---------------------------------------------------------- def fetch(self) -> list[Vehicle]: cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) vehicles: list[Vehicle] = [] seen: set[str] = set() html = self._list_html(1) pages = [int(p) for p in _PAGE_RE.findall(html)] last_page = min(max(pages) if pages else 1, cap, self.max_pages) page = 1 while True: blobs = self._parse_blobs(html) if not blobs: break new = 0 for d in blobs: veh = self._to_vehicle(d) if veh is not None and veh.external_id not in seen: seen.add(veh.external_id) vehicles.append(veh) new += 1 page += 1 if page > last_page or new == 0: break html = self._list_html(page) # enrichissement détail (Scrapfly) — cache BD, budget par sync ; # le véhicule reste émis avec les données liste même sans détail real_fetches = 0 for veh in vehicles: detail: dict = {} 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, veh.external_id, _DETAIL_KEY) or {} else: did_fetch = False def _fetch(u=veh.url): nonlocal did_fetch did_fetch = True return self._fetch_detail(u) try: detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch) except Exception: detail = {} # détail indisponible : liste suffisante if did_fetch: real_fetches += 1 self._apply_detail(veh, detail or {}) return vehicles @staticmethod def _apply_detail(veh: Vehicle, detail: dict) -> None: images = [u for u in detail.get("images") or [] if isinstance(u, str)] if images: veh.images = images[:30] feats = [f for f in detail.get("features") or [] if isinstance(f, str)] if feats: # union liste + détail, ordre préservé merged = list(veh.features) merged += [f for f in feats if f not in merged] veh.features = merged[:80] if detail.get("carfax_url"): veh.carfax_url = str(detail["carfax_url"]) # -- carte JSON -> Vehicle ---------------------------------------------- def _to_vehicle(self, d: dict) -> Vehicle | None: if d.get("Sold") or d.get("Deleted") or d.get("IsForSale") is False: return None if str(d.get("Type") or "Used") != "Used": # usagés seulement return None loc = d.get("FullLocation") or {} province = (loc.get("province") or "").strip().upper() if province and province != "QC": # succursales US exclues return None ext_id = str(d.get("Id") or "").strip() url = d.get("Permalink") or "" if not ext_id or not url: return None year = d.get("Year") make = str(d.get("Make") or "").strip() model = str(d.get("Model") or "").strip() title = (d.get("ShortTitle") or " ".join(str(x) for x in (year, make, model) if x)).strip() price = d.get("Price") try: price = float(price) if price else None except (TypeError, ValueError): price = None km = d.get("Odometer") try: km = float(km) if km not in (None, "") else None except (TypeError, ValueError): km = None if km is not None and str(d.get("OdoUnitCode") or "").upper() == "SMI": km = round(km * MILE_KM) # milles -> km # portes / passagers / équipements depuis la liste CarDetails doors = seats = None features: list[str] = [] for item in d.get("CarDetails") or []: name = str(item.get("name") or "").strip() value = str(item.get("value") or "").strip() if not value: # équipement pur m = _DOORS_RE.search(name) if m: doors = int(m.group(1)) elif name and "Stock" not in name: features.append(name.rstrip(" :")) elif "passagers" in name.lower(): try: seats = int(value) except ValueError: pass image = d.get("MainImage") or "" if image: # carte 448x252 -> grand image = _IMG_SIZE_RE.sub(r"/by-size/\1/1010x568/", image) post = d.get("post") or {} description = str(post.get("post_content") or "")[:4000] details: dict = {} if loc.get("city_slug"): details["branch_slug"] = loc["city_slug"] if d.get("Categories"): details["categories"] = d["Categories"] if d.get("HwyMPG"): details["consumption_hwy_l_100km"] = d["HwyMPG"] if d.get("OldPrice"): details["old_price"] = d["OldPrice"] return Vehicle( source=self.source_id, external_id=ext_id, url=url, title=title, make=make, model=model, trim=str(d.get("Trim") or "").strip(), year=int(year) if year else None, price=price, price_label=str(d.get("FormattedPrice") or ""), mileage_km=km, mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", transmission=str(d.get("Transmission") or ""), fuel=str(d.get("Fuel") or ""), drivetrain=str(d.get("DrivetrainType") or ""), body_type=str(d.get("BodyStyle") or ""), exterior_color=str(d.get("ExteriorColor") or ""), interior_color=str(d.get("InteriorColor") or ""), engine=str(d.get("Engine") or ""), doors=doors, seats=seats, vin=str(d.get("VIN") or ""), stock_number=str(d.get("Stock") or ""), dealer_name=self.dealer_name, city=_clean_city(str(loc.get("city") or "").strip()), description=description, features=features, details=details, images=[image] if image else [], )