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.0 KB · 177 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/otogo.py : PORTAIL — Otogo.ca (LC Media, 100 % Québec)5#6#   Otogo expose une API JSON publique, sans clé ni rendu JS :7#       GET https://www.otogo.ca/api-search/?latlng=0,0&locale=fr&no_facets=8#           &rpp=100&page=N&status=Usagé,Certifié,Démo[&price=lo-hi]9#   Chaque annonce de la liste est déjà riche : titre/version FR, année,10#   prix, kilométrage, VIN (≈100 %), nom du concessionnaire et surtout le11#   GPS EXACT du vendeur (latitude/longitude) — parfait pour la recherche12#   par rayon et la dédup VIN avec les concessionnaires connectés.13#   (Paramètres découverts dans les bundles Nuxt : constantes c.b du module14#   5731047.js — latlng/locale/rpp/page/price/status/no_facets/no_results.)15#16#   Profondeur : l'API plafonne à ~1 000 résultats par requête (rpp=100 →17#   meta.pages ≤ 10) pour ~24 000 annonces d'occasion. On PARTITIONNE donc18#   par tranches de prix (bissection récursive + tranche ouverte « lo- ») :19#   chaque annonce n'est téléchargée qu'une fois, ~250 requêtes directes20#   par sync complet, zéro crédit d'API de scraping externe.21#22#   Ville : absente de la liste — déduite du GPS exact via nearest_city()23#   (data/villes_gps.json) pour l'affichage et la région administrative.24#   Carburant/boîte : absents de la liste ; repli commun finalize() (jetons25#   du titre/de la version). Page détail : /auto/{slug_fr} (non visitée —26#   économie ; le VIN et le GPS sont déjà dans la liste).27#28#   Plafond : AUTOKA_OTOGO_MAX (défaut 0 = inventaire complet).29# -----------------------------------------------------------------------------30from __future__ import annotations3132import os33import re3435from ..normalize import nearest_city36from ..schema import Vehicle37from .base import BaseConnector3839BASE = "https://www.otogo.ca"40API = BASE + "/api-search/"4142RPP = 100                     # résultats par page (max utile observé)43DEPTH = 1000                  # profondeur max de pagination par requête44STATUS = "Usagé,Certifié,Démo"   # occasion seulement (exclut Neuf)45PRICE_SPLIT_FLOOR = 250       # $ : largeur minimale d'une tranche de prix4647_VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")484950def _max_listings() -> int:51    try:52        return max(0, int(os.environ.get("AUTOKA_OTOGO_MAX", "0")))53    except ValueError:54        return 0555657class OtogoQuebec(BaseConnector):58    """Portail Otogo.ca — inventaire d'occasion des concessionnaires du Québec."""5960    source_id = "otogo"61    request_delay = 0.86263    def __init__(self) -> None:64        super().__init__()65        self.session.headers.update({66            "Accept": "application/json",67            "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.5",68        })6970    # -- API --------------------------------------------------------------------7172    def _query(self, page: int, lo: int, hi: int | None) -> tuple[dict, list[dict]]:73        """Une page de résultats pour la tranche de prix [lo, hi] (hi=None = ouvert)."""74        params = {75            "latlng": "0,0", "locale": "fr", "no_facets": "",76            "rpp": str(RPP), "status": STATUS,77            "price": f"{lo}-{'' if hi is None else hi}",78        }79        if page > 1:80            params["page"] = str(page)81        data = self.get(API, params=params).json()82        meta = data.get("meta") or {}83        results = ((data.get("data") or {}).get("results")) or []84        return meta, results8586    def _to_vehicle(self, r: dict) -> Vehicle | None:87        ext_id = str(r.get("id") or "")88        slug = r.get("slug_fr") or r.get("slug_en") or ""89        if not ext_id or not slug:90            return None9192        price = r.get("price")93        price = float(price) if price and price >= 500 else None94        mileage = r.get("mileage")95        mileage = float(mileage) if mileage is not None else None9697        try:98            lat = float(r.get("latitude"))99            lng = float(r.get("longitude"))100        except (TypeError, ValueError):101            lat = lng = None102103        vin = (r.get("vin") or "").strip().upper()104        if not _VIN_RE.fullmatch(vin):105            vin = ""106107        details: dict = {}108        if r.get("status_fr"):109            details["otogo_status"] = r["status_fr"]   # Usagé | Certifié | Démo110111        return Vehicle(112            source=self.source_id,113            external_id=ext_id,114            url=f"{BASE}/auto/{slug}",115            kind="auto",116            title=r.get("title_fr") or r.get("title_en") or "",117            trim=(r.get("trim_fr") or r.get("trim_en") or "").strip(" |"),118            year=r.get("year"),119            price=price,120            price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),121            mileage_km=mileage,122            vin=vin,123            dealer_name=(r.get("dealer_name") or "").strip(),124            city=nearest_city(lat, lng),125            lat=lat,126            lng=lng,127            details=details,128            images=[u for u in [r.get("thumbnail")] if u],129        )130131    # -- contrat -----------------------------------------------------------------132133    def fetch(self) -> list[Vehicle]:134        cap = _max_listings()135        vehicles: dict[str, Vehicle] = {}136        # partition par prix : bissection récursive tant qu'une tranche137        # dépasse la profondeur maximale de l'API (~1 000 annonces)138        stack: list[tuple[int, int | None]] = [(0, 100_000), (100_000, None)]139        while stack:140            if cap and len(vehicles) >= cap:141                break142            lo, hi = stack.pop()143            meta, results = self._query(1, lo, hi)144            total = int(meta.get("total") or 0)145            if total == 0:146                continue147            if total > DEPTH:148                if hi is None:                      # tranche ouverte : décaler149                    stack.append((lo + 100_000, None))150                    stack.append((lo, lo + 100_000))151                    continue152                if hi - lo > PRICE_SPLIT_FLOOR:     # bissection153                    mid = (lo + hi) // 2154                    stack.append((mid, hi))155                    stack.append((lo, mid))156                    continue157                # tranche déjà minimale : on prend ce que la profondeur permet158            for r in results:159                v = self._to_vehicle(r)160                if v:161                    vehicles[v.external_id] = v162            pages = int(meta.get("pages") or 1)163            for page in range(2, pages + 1):164                if cap and len(vehicles) >= cap:165                    break166                try:167                    _, results = self._query(page, lo, hi)168                except Exception:169                    break                            # fin de pagination170                if not results:171                    break172                for r in results:173                    v = self._to_vehicle(r)174                    if v:175                        vehicles[v.external_id] = v176        return list(vehicles.values())177