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/autotrader.py : PORTAIL — AutoTrader.ca / AutoHebdo (Québec)5#6# AutoTrader.ca tourne depuis 2025-2026 sur la plateforme AutoScout247# (Next.js) : chaque page de résultats (SRP) embarque les 20 annonces8# complètes en JSON dans <script id="__NEXT_DATA__"> — marque/modèle/9# version, année, prix (brut + évaluation de prix), kilométrage, boîte,10# carburant, description, ville/code postal du vendeur, concessionnaire11# vs particulier, téléphone et photos CDN. Pas de VIN dans la liste ni12# sur la page détail (vérifié 2026-08-18) — la dédup VIN ne s'applique13# donc pas à cette source.14#15# URL : /cars/reg_qc?offer=U&sort=age&desc=1&page=N16# - reg_qc : province de Québec seulement17# - offer=U : occasion seulement (34 870 annonces au 2026-08-18)18# - sort=age&desc=1 : plus récentes d'abord — la fenêtre plafonnée19# couvre ainsi les nouveautés à chaque cycle20# - profondeur max : 200 pages × 20 = 4 000 annonces par requête21#22# Plafond : AUTOKA_AUTOTRADER_MAX (défaut 2 000) — fenêtre glissante des23# annonces les plus récentes ; les annonces sorties de la fenêtre passent24# inactives après le délai de grâce standard (MISS_GRACE), la source garde25# donc un volume ≈ plafond en base.26#27# Anti-bot : Akamai présent mais le HTML SSR se sert en direct (vérifié28# pages 1-50) ; repli automatique Scrapfly ASP SANS render_js sur 403/42929# (~1 crédit/page en cas de blocage seulement — budget nul en régime normal).30# -----------------------------------------------------------------------------31from __future__ import annotations3233import json34import os35import re3637import requests3839from ..schema import Vehicle40from .base import BaseConnector4142BASE = "https://www.autotrader.ca"43SRP = BASE + "/cars/reg_qc?offer=U&sort=age&desc=1&page={page}"4445PAGE_SIZE = 2046MAX_PAGES_SITE = 200 # profondeur maximale exposée par la plateforme4748_NEXT_DATA_RE = re.compile(49 r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)5051# vignettes liste 250x188 -> photos 640x480 (servies par le CDN AutoScout24)52_IMG_SIZE_RE = re.compile(r"/\d+x\d+\.webp$")5354# libellés d'évaluation de prix AutoScout24 (tracking.priceLabel)55_PRICE_LABELS = {"top-price", "good-price", "fair-price", "expensive",56 "too-expensive"}575859def _max_listings() -> int:60 try:61 return max(PAGE_SIZE, int(os.environ.get("AUTOKA_AUTOTRADER_MAX",62 "2000")))63 except ValueError:64 return 2000656667class AutoTraderQuebec(BaseConnector):68 """Portail AutoTrader.ca — annonces d'occasion du Québec, récentes d'abord."""6970 source_id = "autotrader"71 request_delay = 1.5 # politesse : gros portail national7273 def __init__(self) -> None:74 super().__init__()75 self.session.headers.update({76 "Accept": "text/html,application/xhtml+xml",77 "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.5",78 })7980 # -- extraction -----------------------------------------------------------8182 def _fetch_page(self, page: int) -> tuple[list[dict], int]:83 """Annonces embarquées + nombre total de pages d'une page SRP."""84 url = SRP.format(page=page)85 try:86 html = self.get(url).text87 except requests.HTTPError as exc: # Akamai : repli ASP économe88 status = exc.response.status_code if exc.response is not None else 089 if status not in (403, 429):90 raise91 html = self.get_scrapfly(url, render_js=False, asp=True)92 m = _NEXT_DATA_RE.search(html)93 if not m:94 raise RuntimeError(f"autotrader : __NEXT_DATA__ introuvable "95 f"(page {page})")96 pp = json.loads(m.group(1)).get("props", {}).get("pageProps", {})97 return (pp.get("listings") or []), int(pp.get("numberOfPages") or 0)9899 def _to_vehicle(self, l: dict) -> Vehicle | None:100 ext_id = str(l.get("id") or "")101 veh_d = l.get("vehicle") or {}102 if not ext_id or not l.get("url"):103 return None104 if (veh_d.get("articleType") or "Car") != "Car":105 return None # voitures seulement (atype=C)106107 price_d = l.get("price") or {}108 price = price_d.get("priceRaw")109 price = float(price) if price else None110111 loc = l.get("location") or {}112 if (loc.get("provinceCode") or "QC") != "QC":113 return None # garde-fou : Québec seulement114115 seller = l.get("seller") or {}116 is_private = (seller.get("type") or "").lower() == "private"117 dealer_name = (seller.get("companyName") or "").strip()118 if is_private or not dealer_name:119 dealer_name = "Particulier (AutoTrader)" if is_private else \120 "AutoTrader.ca"121122 details: dict = {"forsaleby": "particulier" if is_private123 else "concessionnaire"}124 label = ((l.get("tracking") or {}).get("priceLabel") or "")125 if label in _PRICE_LABELS:126 details["autotrader_price_label"] = label127 if price_d.get("priceEvaluation") is not None:128 details["autotrader_price_evaluation"] = price_d["priceEvaluation"]129 zip_code = (loc.get("zip") or "").strip()130 if zip_code:131 details["postal_code"] = zip_code132 phones = seller.get("phones") or []133 if phones and phones[0].get("formattedNumber"):134 details["dealer_phone"] = phones[0]["formattedNumber"]135136 images = [_IMG_SIZE_RE.sub("/640x480.webp", u)137 for u in (l.get("images") or []) if u]138139 title_bits = [veh_d.get("make") or "", veh_d.get("model") or "",140 str(veh_d.get("modelYear") or "")]141 return Vehicle(142 source=self.source_id,143 external_id=ext_id,144 url=l["url"],145 kind="auto",146 title=" ".join(b for b in title_bits if b),147 make=veh_d.get("make") or "",148 model=veh_d.get("model") or "",149 trim=(veh_d.get("modelVersionInput") or "").strip(),150 year=veh_d.get("modelYear"),151 price=price,152 price_label=(price_d.get("priceFormatted") or "").strip(),153 mileage_label=veh_d.get("mileageInKm") or "",154 transmission=veh_d.get("transmission") or "",155 fuel=veh_d.get("fuel") or "",156 engine=veh_d.get("motorTypeName") or "",157 dealer_name=dealer_name,158 city=(loc.get("city") or "").title(),159 description=l.get("description") or "",160 details=details,161 images=images,162 )163164 # -- contrat ---------------------------------------------------------------165166 def fetch(self) -> list[Vehicle]:167 cap = _max_listings()168 vehicles: dict[str, Vehicle] = {}169 listings, n_pages = self._fetch_page(1)170 n_pages = min(n_pages or MAX_PAGES_SITE, MAX_PAGES_SITE,171 -(-cap // PAGE_SIZE))172 for l in listings:173 v = self._to_vehicle(l)174 if v:175 vehicles[v.external_id] = v176 for page in range(2, n_pages + 1):177 if len(vehicles) >= cap:178 break179 try:180 listings, _ = self._fetch_page(page)181 except Exception:182 break # fin de pagination / page vide183 new = 0184 for l in listings:185 v = self._to_vehicle(l)186 if v and v.external_id not in vehicles:187 vehicles[v.external_id] = v188 new += 1189 if new == 0:190 break191 return list(vehicles.values())192