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/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