# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cargurus.py : PORTAIL — CarGurus.ca (Québec, avec « deal rating ») # # CarGurus est protégé par DataDome (403 en direct, page « enable JS »). # MAIS son API interne de liste répond en JSON pur à travers Scrapfly ASP # SANS render_js — coût observé : 1 crédit/requête (proxy datacenter) : # GET /Cars/searchResults.action?zip=H2X1Y4&distance=500 # &entitySelectingHelper.selectedEntity=&offset=N&maxResults=50 # &sortDir=ASC&sortType=AGE_IN_DAYS&filtersModified=true # → tableau JSON de ~48 annonces ultra-riches : VIN (≈100 % — alimente la # dédup inter-sources), prix + PRIX ATTENDU + dealRating/dealScore # (l'évaluation « bonne/mauvaise affaire » maison de CarGurus, stockée # dans details), km, boîte, carburant, rouage, carrosserie, couleurs, # moteur, équipements, concessionnaire + ville + téléphone, photo. # # Rayon : zip Montréal (H2X 1Y4) + distance=500 km couvre tout le Québec ; # les débordements ON/NB sont filtrés par sellerRegion == "QC". # Tri : AGE_IN_DAYS ASC = plus récentes d'abord — fenêtre glissante. # # Budget SERRÉ (documenté) : plafond AUTOKA_CARGURUS_MAX (défaut 500) # → ~11 requêtes ≈ 11 crédits Scrapfly par sync, ~130/jour au rythme # de 12 syncs/jour. Les annonces sorties de la fenêtre passent inactives # après le délai de grâce standard (MISS_GRACE). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re from ..schema import Vehicle from .base import BaseConnector API = ("https://www.cargurus.ca/Cars/searchResults.action" "?zip=H2X1Y4&distance=500&entitySelectingHelper.selectedEntity=" "&offset={offset}&maxResults=50" "&sortDir=ASC&sortType=AGE_IN_DAYS&filtersModified=true") VDP = "https://www.cargurus.ca/Cars/inventorylisting/vdp.action?listingId={id}" MAX_OFFSET_SITE = 5000 # garde-fou de pagination _VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") _DOORS_RE = re.compile(r"(\d+)") def _max_listings() -> int: try: return max(48, int(os.environ.get("AUTOKA_CARGURUS_MAX", "500"))) except ValueError: return 500 class CarGurusQuebec(BaseConnector): """Portail CarGurus.ca — annonces QC récentes + deal rating (via Scrapfly).""" source_id = "cargurus" request_delay = 1.0 # -- API --------------------------------------------------------------------- def _fetch_batch(self, offset: int) -> list[dict]: """Un lot d'annonces JSON (via Scrapfly ASP, sans render_js). L'ASP rate parfois un coup (contenu vide/HTML DataDome) et l'API Scrapfly elle-même a des micro-pannes (ConnectTimeout observé 2026-09-13, deux syncs avortés en pleine pagination) : 2 nouvelles tentatives avant d'abandonner — coût borné à 3 crédits par lot (un échec de connexion ne consomme aucun crédit). """ import time import requests last_exc: Exception | None = None for attempt in range(3): try: body = self.get_scrapfly(API.format(offset=offset), render_js=False, asp=True) data = json.loads(body) return data if isinstance(data, list) else [] except (ValueError, requests.RequestException) as exc: last_exc = exc time.sleep(2.0 * (attempt + 1)) raise RuntimeError(f"cargurus : échec à offset={offset} " f"({str(last_exc)[:80]})") from last_exc def _to_vehicle(self, l: dict) -> Vehicle | None: ext_id = str(l.get("id") or "") if not ext_id: return None if (l.get("sellerRegion") or "") != "QC": return None # rayon 500 km : déborde sur ON/NB price = l.get("price") price = float(price) if price else None mileage = l.get("mileage") mileage = float(mileage) if mileage is not None else None vin = (l.get("vin") or "").strip().upper() if not _VIN_RE.fullmatch(vin): vin = "" is_private = (l.get("sellerType") or "").upper() == "PRIVATE" dealer_name = (l.get("serviceProviderName") or "").strip() if is_private or not dealer_name: dealer_name = "Particulier (CarGurus)" if is_private else \ "CarGurus.ca" details: dict = {"forsaleby": "particulier" if is_private else "concessionnaire"} # l'évaluation d'aubaine maison de CarGurus — la valeur ajoutée du portail for src_key, dst_key in (("dealRating", "cargurus_deal_rating"), ("dealScore", "cargurus_deal_score"), ("expectedPrice", "cargurus_expected_price"), ("priceDifferential", "cargurus_price_differential"), ("daysOnMarket", "days_on_market")): if l.get(src_key) is not None: details[dst_key] = l[src_key] if l.get("phoneNumberString"): details["dealer_phone"] = l["phoneNumberString"] doors = None m = _DOORS_RE.search(l.get("localizedDoors") or "") if m: doors = int(m.group(1)) city = (l.get("sellerCity") or "").split(",")[0].strip() img = (l.get("originalPictureData") or {}).get("url") or "" return Vehicle( source=self.source_id, external_id=ext_id, url=VDP.format(id=ext_id), kind="auto", title=l.get("listingTitle") or "", make=l.get("makeName") or "", model=l.get("modelName") or "", trim=(l.get("trimName") or "").strip(), year=l.get("carYear"), price=price, price_label=(l.get("priceString") or "").strip(), mileage_km=mileage, transmission=l.get("localizedTransmission") or "", fuel=l.get("localizedFuelType") or "", drivetrain=l.get("localizedDriveTrain") or "", body_type=l.get("bodyTypeName") or "", exterior_color=l.get("localizedExteriorColor") or "", interior_color=l.get("localizedInteriorColor") or "", engine=l.get("localizedEngineDisplayName") or "", doors=doors, vin=vin, stock_number=l.get("stockNumber") or "", dealer_name=dealer_name, city=city, features=[o for o in (l.get("options") or []) if o], details=details, images=[img] if img else [], ) # -- contrat ----------------------------------------------------------------- def fetch(self) -> list[Vehicle]: cap = _max_listings() vehicles: dict[str, Vehicle] = {} offset = 0 while len(vehicles) < cap and offset < MAX_OFFSET_SITE: batch = self._fetch_batch(offset) if not batch: break for l in batch: v = self._to_vehicle(l) if v and v.external_id not in vehicles: vehicles[v.external_id] = v offset += len(batch) return list(vehicles.values())