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