SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
7.9 KB · 192 lines python
Raw Blame History
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