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.4 KB · 179 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/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