# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/autotrader.py : PORTAIL — AutoTrader.ca / AutoHebdo (Québec) # # AutoTrader.ca tourne depuis 2025-2026 sur la plateforme AutoScout24 # (Next.js) : chaque page de résultats (SRP) embarque les 20 annonces # complètes en JSON dans ', re.S) # vignettes liste 250x188 -> photos 640x480 (servies par le CDN AutoScout24) _IMG_SIZE_RE = re.compile(r"/\d+x\d+\.webp$") # libellés d'évaluation de prix AutoScout24 (tracking.priceLabel) _PRICE_LABELS = {"top-price", "good-price", "fair-price", "expensive", "too-expensive"} def _max_listings() -> int: try: return max(PAGE_SIZE, int(os.environ.get("AUTOKA_AUTOTRADER_MAX", "2000"))) except ValueError: return 2000 class AutoTraderQuebec(BaseConnector): """Portail AutoTrader.ca — annonces d'occasion du Québec, récentes d'abord.""" source_id = "autotrader" request_delay = 1.5 # politesse : gros portail national def __init__(self) -> None: super().__init__() self.session.headers.update({ "Accept": "text/html,application/xhtml+xml", "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.5", }) # -- extraction ----------------------------------------------------------- def _fetch_page(self, page: int) -> tuple[list[dict], int]: """Annonces embarquées + nombre total de pages d'une page SRP.""" url = SRP.format(page=page) try: html = self.get(url).text except requests.HTTPError as exc: # Akamai : repli ASP économe status = exc.response.status_code if exc.response is not None else 0 if status not in (403, 429): raise html = self.get_scrapfly(url, render_js=False, asp=True) m = _NEXT_DATA_RE.search(html) if not m: raise RuntimeError(f"autotrader : __NEXT_DATA__ introuvable " f"(page {page})") pp = json.loads(m.group(1)).get("props", {}).get("pageProps", {}) return (pp.get("listings") or []), int(pp.get("numberOfPages") or 0) def _to_vehicle(self, l: dict) -> Vehicle | None: ext_id = str(l.get("id") or "") veh_d = l.get("vehicle") or {} if not ext_id or not l.get("url"): return None if (veh_d.get("articleType") or "Car") != "Car": return None # voitures seulement (atype=C) price_d = l.get("price") or {} price = price_d.get("priceRaw") price = float(price) if price else None loc = l.get("location") or {} if (loc.get("provinceCode") or "QC") != "QC": return None # garde-fou : Québec seulement seller = l.get("seller") or {} is_private = (seller.get("type") or "").lower() == "private" dealer_name = (seller.get("companyName") or "").strip() if is_private or not dealer_name: dealer_name = "Particulier (AutoTrader)" if is_private else \ "AutoTrader.ca" details: dict = {"forsaleby": "particulier" if is_private else "concessionnaire"} label = ((l.get("tracking") or {}).get("priceLabel") or "") if label in _PRICE_LABELS: details["autotrader_price_label"] = label if price_d.get("priceEvaluation") is not None: details["autotrader_price_evaluation"] = price_d["priceEvaluation"] zip_code = (loc.get("zip") or "").strip() if zip_code: details["postal_code"] = zip_code phones = seller.get("phones") or [] if phones and phones[0].get("formattedNumber"): details["dealer_phone"] = phones[0]["formattedNumber"] images = [_IMG_SIZE_RE.sub("/640x480.webp", u) for u in (l.get("images") or []) if u] title_bits = [veh_d.get("make") or "", veh_d.get("model") or "", str(veh_d.get("modelYear") or "")] return Vehicle( source=self.source_id, external_id=ext_id, url=l["url"], kind="auto", title=" ".join(b for b in title_bits if b), make=veh_d.get("make") or "", model=veh_d.get("model") or "", trim=(veh_d.get("modelVersionInput") or "").strip(), year=veh_d.get("modelYear"), price=price, price_label=(price_d.get("priceFormatted") or "").strip(), mileage_label=veh_d.get("mileageInKm") or "", transmission=veh_d.get("transmission") or "", fuel=veh_d.get("fuel") or "", engine=veh_d.get("motorTypeName") or "", dealer_name=dealer_name, city=(loc.get("city") or "").title(), description=l.get("description") or "", details=details, images=images, ) # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Vehicle]: cap = _max_listings() vehicles: dict[str, Vehicle] = {} listings, n_pages = self._fetch_page(1) n_pages = min(n_pages or MAX_PAGES_SITE, MAX_PAGES_SITE, -(-cap // PAGE_SIZE)) for l in listings: v = self._to_vehicle(l) if v: vehicles[v.external_id] = v for page in range(2, n_pages + 1): if len(vehicles) >= cap: break try: listings, _ = self._fetch_page(page) except Exception: break # fin de pagination / page vide new = 0 for l in listings: v = self._to_vehicle(l) if v and v.external_id not in vehicles: vehicles[v.external_id] = v new += 1 if new == 0: break return list(vehicles.values())