Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/cargurus.py : PORTAIL — CarGurus.ca (Québec, avec « deal rating »)5#6# CarGurus est protégé par DataDome (403 en direct, page « enable JS »).7# MAIS son API interne de liste répond en JSON pur à travers Scrapfly ASP8# SANS render_js — coût observé : 1 crédit/requête (proxy datacenter) :9# GET /Cars/searchResults.action?zip=H2X1Y4&distance=50010# &entitySelectingHelper.selectedEntity=&offset=N&maxResults=5011# &sortDir=ASC&sortType=AGE_IN_DAYS&filtersModified=true12# → tableau JSON de ~48 annonces ultra-riches : VIN (≈100 % — alimente la13# dédup inter-sources), prix + PRIX ATTENDU + dealRating/dealScore14# (l'évaluation « bonne/mauvaise affaire » maison de CarGurus, stockée15# dans details), km, boîte, carburant, rouage, carrosserie, couleurs,16# moteur, équipements, concessionnaire + ville + téléphone, photo.17#18# Rayon : zip Montréal (H2X 1Y4) + distance=500 km couvre tout le Québec ;19# les débordements ON/NB sont filtrés par sellerRegion == "QC".20# Tri : AGE_IN_DAYS ASC = plus récentes d'abord — fenêtre glissante.21#22# Budget SERRÉ (documenté) : plafond AUTOKA_CARGURUS_MAX (défaut 500)23# → ~11 requêtes ≈ 11 crédits Scrapfly par sync, ~130/jour au rythme24# de 12 syncs/jour. Les annonces sorties de la fenêtre passent inactives25# après le délai de grâce standard (MISS_GRACE).26# -----------------------------------------------------------------------------27from __future__ import annotations2829import json30import os31import re3233from ..schema import Vehicle34from .base import BaseConnector3536API = ("https://www.cargurus.ca/Cars/searchResults.action"37 "?zip=H2X1Y4&distance=500&entitySelectingHelper.selectedEntity="38 "&offset={offset}&maxResults=50"39 "&sortDir=ASC&sortType=AGE_IN_DAYS&filtersModified=true")4041VDP = "https://www.cargurus.ca/Cars/inventorylisting/vdp.action?listingId={id}"4243MAX_OFFSET_SITE = 5000 # garde-fou de pagination44_VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")45_DOORS_RE = re.compile(r"(\d+)")464748def _max_listings() -> int:49 try:50 return max(48, int(os.environ.get("AUTOKA_CARGURUS_MAX", "500")))51 except ValueError:52 return 500535455class CarGurusQuebec(BaseConnector):56 """Portail CarGurus.ca — annonces QC récentes + deal rating (via Scrapfly)."""5758 source_id = "cargurus"59 request_delay = 1.06061 # -- API ---------------------------------------------------------------------6263 def _fetch_batch(self, offset: int) -> list[dict]:64 """Un lot d'annonces JSON (via Scrapfly ASP, sans render_js).6566 L'ASP rate parfois un coup (contenu vide/HTML DataDome) et l'API67 Scrapfly elle-même a des micro-pannes (ConnectTimeout observé68 2026-09-13, deux syncs avortés en pleine pagination) : 2 nouvelles69 tentatives avant d'abandonner — coût borné à 3 crédits par lot70 (un échec de connexion ne consomme aucun crédit).71 """72 import time7374 import requests7576 last_exc: Exception | None = None77 for attempt in range(3):78 try:79 body = self.get_scrapfly(API.format(offset=offset),80 render_js=False, asp=True)81 data = json.loads(body)82 return data if isinstance(data, list) else []83 except (ValueError, requests.RequestException) as exc:84 last_exc = exc85 time.sleep(2.0 * (attempt + 1))86 raise RuntimeError(f"cargurus : échec à offset={offset} "87 f"({str(last_exc)[:80]})") from last_exc8889 def _to_vehicle(self, l: dict) -> Vehicle | None:90 ext_id = str(l.get("id") or "")91 if not ext_id:92 return None93 if (l.get("sellerRegion") or "") != "QC":94 return None # rayon 500 km : déborde sur ON/NB9596 price = l.get("price")97 price = float(price) if price else None98 mileage = l.get("mileage")99 mileage = float(mileage) if mileage is not None else None100101 vin = (l.get("vin") or "").strip().upper()102 if not _VIN_RE.fullmatch(vin):103 vin = ""104105 is_private = (l.get("sellerType") or "").upper() == "PRIVATE"106 dealer_name = (l.get("serviceProviderName") or "").strip()107 if is_private or not dealer_name:108 dealer_name = "Particulier (CarGurus)" if is_private else \109 "CarGurus.ca"110111 details: dict = {"forsaleby": "particulier" if is_private112 else "concessionnaire"}113 # l'évaluation d'aubaine maison de CarGurus — la valeur ajoutée du portail114 for src_key, dst_key in (("dealRating", "cargurus_deal_rating"),115 ("dealScore", "cargurus_deal_score"),116 ("expectedPrice", "cargurus_expected_price"),117 ("priceDifferential",118 "cargurus_price_differential"),119 ("daysOnMarket", "days_on_market")):120 if l.get(src_key) is not None:121 details[dst_key] = l[src_key]122 if l.get("phoneNumberString"):123 details["dealer_phone"] = l["phoneNumberString"]124125 doors = None126 m = _DOORS_RE.search(l.get("localizedDoors") or "")127 if m:128 doors = int(m.group(1))129130 city = (l.get("sellerCity") or "").split(",")[0].strip()131 img = (l.get("originalPictureData") or {}).get("url") or ""132133 return Vehicle(134 source=self.source_id,135 external_id=ext_id,136 url=VDP.format(id=ext_id),137 kind="auto",138 title=l.get("listingTitle") or "",139 make=l.get("makeName") or "",140 model=l.get("modelName") or "",141 trim=(l.get("trimName") or "").strip(),142 year=l.get("carYear"),143 price=price,144 price_label=(l.get("priceString") or "").strip(),145 mileage_km=mileage,146 transmission=l.get("localizedTransmission") or "",147 fuel=l.get("localizedFuelType") or "",148 drivetrain=l.get("localizedDriveTrain") or "",149 body_type=l.get("bodyTypeName") or "",150 exterior_color=l.get("localizedExteriorColor") or "",151 interior_color=l.get("localizedInteriorColor") or "",152 engine=l.get("localizedEngineDisplayName") or "",153 doors=doors,154 vin=vin,155 stock_number=l.get("stockNumber") or "",156 dealer_name=dealer_name,157 city=city,158 features=[o for o in (l.get("options") or []) if o],159 details=details,160 images=[img] if img else [],161 )162163 # -- contrat -----------------------------------------------------------------164165 def fetch(self) -> list[Vehicle]:166 cap = _max_listings()167 vehicles: dict[str, Vehicle] = {}168 offset = 0169 while len(vehicles) < cap and offset < MAX_OFFSET_SITE:170 batch = self._fetch_batch(offset)171 if not batch:172 break173 for l in batch:174 v = self._to_vehicle(l)175 if v and v.external_id not in vehicles:176 vehicles[v.external_id] = v177 offset += len(batch)178 return list(vehicles.values())179