# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de véhicules usagés à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/moto_dealers.py : verticale MOTOS & SCOOTERS usagés — connecteurs # des concessionnaires de motocyclettes/scooters du Québec. # # Chaque véhicule émis porte kind="moto" ou kind="scooter" (jamais "auto"). # Les motoneiges, VTT, côte-à-côte, motomarines, bateaux, VR et vélos sont # systématiquement EXCLUS. Les marques moto (Harley-Davidson, Indian, KTM, # Vespa…) ne sont pas dans normalize.MAKES : make est TOUJOURS rempli # explicitement depuis la source (JSON inventaire ou brand JSON-LD), on ne # compte jamais sur split_title(). # # Plateformes rencontrées dans cette verticale : # # 1. PowerGo (powergo.ca) — de très loin la plus répandue chez les # concessionnaires de sports motorisés au Québec (l'équivalent moto de # D2C Media côté autos). Sites Next.js ; l'inventaire COMPLET (neuf + # usagé, tous types) est publié dans un JSON public : # /data/vehicles-inventory-fr-items.json # → type, category, conditionCode (new/used), make, model, year, prix, # usage (km), stock, URL de la page détail, 1re photo (gabarit CDN). # Chaque page détail expose en plus un JSON-LD schema.org @type Vehicle # (parfois dans un @graph) : galerie complète, description, couleur, # kilométrage… Pages détail en cache BD, clé = prix+km du listing (donc # revalidation automatique quand le prix ou le km change). # NB : les sites PowerGo « ancien gabarit » (premonthd.com, lavalmoto.com, # motojmf.com, motopro.ca…) n'exposent PAS ce JSON (listing 100 % JS). # # 2. Magento/Traction (Moto Ducharme) — listing HTML server-rendered # par catégorie (/fr/vehicules-d-occasion/motocyclettes), pages détail # avec JSON-LD @type Product + table de spécifications (Kilométrage, # Couleur…) + galerie JSON Magento. # # 2b. Next.js custom (Groupe Contant, refonte 2026 — ex-PowerGo) : usagés # server-rendered dans le payload RSC (self.__next_f.push) des pages # /fr/vehicules-usages/ ; voir la classe GroupeContant. # # 3. D2C Media — aucun des concessionnaires moto candidats sondés n'y est # (PowerGo domine cette verticale), mais la base D2CMotoConnector est # prête : mêmes mécanismes que D2CConnector (sitemap /fr/sitemap.xml + # JSON-LD des pages /occasion/…-idNNN.html), avec un filtre de segments # INVERSÉ (inclut motos/scooters, exclut le reste) et kind rempli. # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import os import re from ..schema import Vehicle from .base import BaseConnector from .d2c_dealers import D2CConnector, _LD_RE, _VEH_URL_RE, _clean # --- détection moto vs scooter ------------------------------------------------ # scooter si la catégorie/le segment d'URL/le titre évoque un scooter ou un # cyclomoteur (Vespa, Burgman, BWS, Kymco…) — sinon moto _SCOOTER_RE = re.compile( r"scooter|cyclomoteur|moped|vespa|piaggio\b|burgman|bws\b|kymco|" r"primavera|typhoon\b|ruckus|metropolitan\b|elite\s*125|majesty|" r"xmax|x-max|nmax|address\b|like\s*200|agility\b|super\s*8\b", re.I) def moto_kind(*texts: str) -> str: """kind Auto-Ka ("moto" | "scooter") d'après des indices textuels.""" blob = " ".join(t for t in texts if t) return "scooter" if _SCOOTER_RE.search(blob) else "moto" # ============================================================================== # 1. D2C Media — base moto/scooter (aucun site connecté pour l'instant ; voir # l'en-tête — prête pour le premier concessionnaire moto rencontré sur D2C) # ============================================================================== class D2CMotoConnector(D2CConnector): """Base D2C Media pour les concessionnaires de MOTOS/SCOOTERS. Même stratégie que D2CConnector (sitemap + JSON-LD des pages détail), mais le filtre de segments /occasion// est inversé : on ne garde QUE les catégories moto/scooter, et on exclut explicitement les motoneiges, VTT, côte-à-côte, motomarines, VR, etc. """ # catégories INCLUSES (\b après moto : « motoneige »/« motomarine » ne # matchent pas) _MOTO_SEGMENTS = re.compile( r"scooter|cyclomoteur|moto(?:s)?\b|motocyclette", re.I) # catégories toujours EXCLUES, même si un segment voisin est moto _NON_MOTO_SEGMENTS = re.compile( r"motoneige|vtt|c[oô]te[s]?-?[aà]-?c[oô]te|motomarine|spyder|ryker|" r"side|atv|snow|bateau|ponton|nautique|roulotte|remorque|\bvr\b|" r"campeur|tracteur|souffleuse|v[ée]lo|auto", re.I) def _vehicle_urls(self) -> list[str]: xml = self._get_text(f"{self.base_url}/fr/sitemap.xml") urls: list[str] = [] for loc in re.findall(r"([^<]+)", xml): loc = loc.strip() if "/occasion/" not in loc or not _VEH_URL_RE.search(loc): continue segments = loc.split("/occasion/", 1)[1].split("/")[:-1] if any(self._NON_MOTO_SEGMENTS.search(s) for s in segments): continue if not any(self._MOTO_SEGMENTS.search(s) for s in segments): continue # autos et catégories inconnues urls.append(loc) return urls def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: veh = super()._to_vehicle(ext_id, url, d) if veh is None: return None path = url.split("/occasion/", 1)[-1] veh.kind = moto_kind(path, str(d.get("bodyType") or ""), veh.title) return veh # ============================================================================== # 2. PowerGo — base commune (JSON inventaire + JSON-LD des pages détail) # ============================================================================== # gabarit CDN de la 1re photo du listing : {imgWidth} → largeur désirée _PG_IMG_WIDTH_RE = re.compile(r"\{imgWidth\}") # liste « Spécifications » de la page détail PowerGo : le JSON-LD n'expose # NI VIN, NI carburant, NI catégorie — mais le HTML server-rendered liste #
  • NIV: 538XXFZ…
  • , spec-color, # spec-category, spec-engine-size, spec-submodel, spec-weight… _PG_SPEC_RE = re.compile( r'
  • (.*?)
  • ', re.S) _PG_SPEC_VAL_RE = re.compile(r"]*>(.*?)", re.S) # moto électrique (make/modèle/titre) : ne jamais leur inférer Essence _PG_ELECTRIC_RE = re.compile( r"\b(électrique|electrique|electric|livewire|zero motorcycles|energica|" r"elettrica|e-?scooter|can-?am\s+(?:origin|pulse))\b", re.I) MAX_IMAGES = 25 def _pg_extract_specs(html: str) -> dict: """Spécifications de la page détail : {'vin': …, 'category': …, …}.""" specs: dict[str, str] = {} for name, body in _PG_SPEC_RE.findall(html): m = _PG_SPEC_VAL_RE.search(body) if not m: continue val = _clean(m.group(1)) if val and val.upper() not in ("-", "N/D", "N.D.", "N/A"): specs.setdefault(name.lower(), val) return specs def _pg_extract_ld_vehicle(html: str) -> dict: """JSON-LD @type Vehicle/Car/Motorcycle d'une page détail PowerGo. Selon le gabarit, le nœud est au premier niveau, dans une liste, ou dans un @graph (ex. sites Harley-Davidson Gabriel/St-Jérôme). """ for block in _LD_RE.findall(html): try: cand = json.loads(block.strip()) except ValueError: continue stack = cand if isinstance(cand, list) else [cand] for item in stack: if not isinstance(item, dict): continue nodes = item.get("@graph") if isinstance(item.get("@graph"), list) \ else [item] for node in nodes: if not isinstance(node, dict): continue types = node.get("@type") types = types if isinstance(types, list) else [types] if any(t in ("Vehicle", "Car", "Motorcycle") for t in types): return node return {} class PowerGoMotoConnector(BaseConnector): """Base commune aux sites PowerGo. Sous-classes : base_url + ville. fetch() : JSON inventaire complet → filtre usagé + motos/scooters → enrichissement JSON-LD par page détail (cache BD, clé prix+km : seuls les nouveaux véhicules et les changements de prix/km génèrent des requêtes). Au-delà du plafond de requêtes détail, le véhicule est quand même émis avec les champs (déjà riches) du listing JSON. """ base_url: str = "" dealer_name: str = "" city: str = "" request_delay: float = 1.0 max_details = 400 # unaccentedType inclus (préfixes, insensible au singulier/pluriel) : # motocyclette(s) et scooters ; « motos trois roues » (Spyder/Ryker, # immatriculées moto au Québec) incluses aussi. Tout le reste (motoneige, # vtt, côte-à-côte, motomarine, bateau, vélo, remorque…) est exclu. include_types = ("motocyclette", "motos trois roues", "scooter") def _kind(self, item: dict) -> str: return moto_kind(str(item.get("unaccentedCategory") or ""), str(item.get("unaccentedType") or ""), str(item.get("label") or "")) # -- page détail -> JSON-LD -------------------------------------------------- def _get_utf8(self, url: str) -> str: """GET dont le texte est décodé en UTF-8. Les serveurs PowerGo n'envoient pas de charset ; requests retombe alors sur ISO-8859-1 et les accents deviennent du mojibake (« Numéro » au lieu de « Numéro »). """ resp = self.get(url) if not resp.encoding or resp.encoding.lower() in ("iso-8859-1", "latin-1"): resp.encoding = "utf-8" return resp.text def _fetch_detail(self, url: str) -> dict: """Payload détail : JSON-LD + liste « Spécifications » du HTML. Le JSON-LD PowerGo est pauvre (ni VIN, ni carburant, ni catégorie) ; les spécifications server-rendered comblent VIN, couleur, catégorie, cylindrée, sous-modèle. """ html = self._get_utf8(url) return {"ld": _pg_extract_ld_vehicle(html), "specs": _pg_extract_specs(html)} # -- contrat ------------------------------------------------------------- def fetch(self) -> list[Vehicle]: items = self.get( f"{self.base_url}/data/vehicles-inventory-fr-items.json").json() if not isinstance(items, list): return [] cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) vehicles: list[Vehicle] = [] real_fetches = 0 for it in items: if not isinstance(it, dict): continue if it.get("conditionCode") != "used" or it.get("isSold"): continue utype = str(it.get("unaccentedType") or "").lower().strip() if not utype.startswith(self.include_types): continue page = (it.get("page") or {}).get("url") or "" if not page: continue url = self.base_url + page ext_id = str(it.get("vehicle_id") or it.get("stockNumber") or page) # clé de cache = prix + km : revalidation automatique au changement # (v2 : + specs HTML — VIN, catégorie, cylindrée, couleur) key = f"v2:{it.get('salePriceValue')}:{it.get('usageValue')}" det: dict = {} if real_fetches < cap: before = self._last_request try: det = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) except Exception: det = {} # page détail disparue : listing seul if self._last_request != before: real_fetches += 1 else: # plafond atteint : cache BD sinon listing seul from .. import db if self._detail_con is None: self._detail_con = db.connect() det = db.get_cached_detail(self._detail_con, self.source_id, ext_id, key) or {} try: veh = self._to_vehicle(ext_id, url, it, det or {}) except Exception: continue if veh is not None: vehicles.append(veh) return vehicles def _to_vehicle(self, ext_id: str, url: str, it: dict, det: dict) -> Vehicle | None: ld = det.get("ld") or {} specs = det.get("specs") or {} title = _clean(it.get("label")) or _clean(ld.get("name")) if not title: return None price = it.get("salePriceValue") or it.get("basePriceValue") if price in (None, "", 0, "0") and isinstance(ld.get("offers"), dict): price = ld["offers"].get("price") try: price = float(price) if price not in (None, "", 0, "0") else None except (TypeError, ValueError): price = None # usage : PowerGo mélange km et heures (motocross) — km seulement si # l'étiquette le confirme ; sinon repli JSON-LD (unitCode KMT) km = None usage_label = _clean(it.get("usageLabel")) if it.get("usageValue") and "km" in usage_label.lower(): try: km = float(it["usageValue"]) except (TypeError, ValueError): km = None if km is None: odo = ld.get("mileageFromOdometer") if isinstance(odo, dict) and odo.get("unitCode") in ("KMT", None): try: km = float(odo.get("value")) except (TypeError, ValueError): km = None year = it.get("year") if not year and ld.get("vehicleModelDate"): year = ld["vehicleModelDate"] try: year = int(str(year)[:4]) if year else None except ValueError: year = None make = _clean(it.get("make")) if not make: brand = ld.get("brand") or {} make = _clean(brand.get("name") if isinstance(brand, dict) else str(brand)) or _clean(ld.get("manufacturer")) images = ld.get("image") or [] if isinstance(images, str): images = [images] images = [u for u in images if isinstance(u, str) and u.startswith("http")] if not images: # repli : 1re photo du listing img = it.get("image") or {} src = str(img.get("source") or "") dims = [d for d in (img.get("dimensions") or []) if isinstance(d, int)] if src: images = [_PG_IMG_WIDTH_RE.sub(str(max(dims) if dims else 1000), src)] details = {k: v for k, v in (("type", _clean(it.get("type"))), ("category", _clean(it.get("category")))) if v} # prix courant vs prix régulier du listing -> rabais affiché base = it.get("basePriceValue") try: base = float(base) if base not in (None, "", 0, "0") else None except (TypeError, ValueError): base = None if base and price and base > price: details["regular_price"] = base details["rebate"] = round(base - price, 2) # vidéos du listing (visite 360 / YouTube) — URL quand la source la # publie, sinon simple drapeau booléen (cas motosillimitees) video = it.get("video360Url") or it.get("videoYoutubeUrl") if isinstance(video, str) and video.startswith("http"): details["video_url"] = video elif video: details["has_video"] = True if specs.get("weight"): details["weight"] = specs["weight"] kind = self._kind(it) # VIN / catégorie (carrosserie moto) / cylindrée / couleur : la liste # « Spécifications » de la page détail (le JSON-LD ne les a pas) vin = (specs.get("vin") or "").upper().replace(" ", "") if len(vin) < 11: # « - », tronqué, absent vin = "" body_type = specs.get("category") or _clean(it.get("category")) engine = specs.get("engine-size", "") # carburant : Électrique si la marque/le modèle l'indique, sinon # Essence (aucune moto/scooter thermique n'est diesel/hybride) blob = " ".join(filter(None, (make, title, _clean(it.get("model"))))) fuel = "Électrique" if _PG_ELECTRIC_RE.search(blob) else "Essence" # rouage : roue arrière motrice = Propulsion (universel en moto, # y compris les trois roues Spyder/Ryker) ; boîte : les scooters # sont tous à variateur (CVT) -> Automatique, motos trop variées transmission = "Automatique" if kind == "scooter" else "" return Vehicle( source=self.source_id, external_id=ext_id, url=url, kind=kind, title=title, make=make, # explicite : marques moto absentes model=_clean(it.get("model")) or _clean(ld.get("model")), trim=specs.get("submodel", ""), year=year, price=price, price_label=_clean(it.get("salePriceLabel") or it.get("basePriceLabel")), mileage_km=km, mileage_label=usage_label, transmission=transmission, fuel=fuel, drivetrain="Propulsion", body_type=body_type, exterior_color=_clean(ld.get("color")) or specs.get("color", ""), engine=engine, vin=vin, stock_number=_clean(it.get("stockNumber")) or _clean(ld.get("sku")), dealer_name=self.dealer_name, # groupes multi-succursales (Contant, Imperium…) : la succursale # du véhicule est dans le listing JSON city=_clean(it.get("location")) or specs.get("location", "") or self.city, description=_clean(ld.get("description"))[:4000], details=details, images=images[:MAX_IMAGES], ) # ------------------------------------------------------------------------------ # Concessionnaires PowerGo (1 sous-classe = 1 source) # ------------------------------------------------------------------------------ class MathiasSports(PowerGoMotoConnector): source_id = "mathiassports" base_url = "https://mathiassports.com" dealer_name = "Mathias Sports" city = "Saint-Mathias-sur-Richelieu" class SMSport(PowerGoMotoConnector): source_id = "smsport" base_url = "https://smsport.ca" dealer_name = "SM Sport" city = "Québec" class MotosIllimitees(PowerGoMotoConnector): source_id = "motosillimitees" base_url = "https://www.motosillimitees.com" dealer_name = "Motos Illimitées" city = "Terrebonne" class RMMotosport(PowerGoMotoConnector): source_id = "rmmotosport" base_url = "https://www.rmmotosport.com" dealer_name = "RM Motosport" city = "Victoriaville" class GLSport(PowerGoMotoConnector): source_id = "glsport" base_url = "https://www.glsport.ca" dealer_name = "GL Sport" city = "Saint-Gervais" class ExcelMoto(PowerGoMotoConnector): source_id = "excelmoto" base_url = "https://www.excelmoto.com" dealer_name = "Excel Moto" city = "Montréal" class GroupeContant(BaseConnector): """Groupe Contant — contant.ca (refonte 2026 : Next.js custom, plus PowerGo). Le JSON PowerGo /data/vehicles-inventory-fr-items.json est mort (404). Les usagés sont server-rendered dans le payload RSC (flight, self.__next_f.push) des pages /fr/vehicules-usages/ : tableau "vehicles" COMPLET (aucune pagination) — stock, make, model, year, price, salePrice, km, images S3, descriptionFr, location (succursale)… Les pages détail (/fr/vehicules-usages/) n'apportent rien de plus (vin/color y sont aussi null) : on n'en fait AUCUNE. Groupe multi-succursales : Mirabel, Repentigny, Vaudreuil, Beloeil, Ste-Agathe — la ville de chaque unité vient du listing (location). """ source_id = "groupecontant" base_url = "https://www.contant.ca" dealer_name = "Groupe Contant" city = "Repentigny" request_delay = 1.0 use_detail_cache = False # types usagés indexés : motos + trois-roues (Spyder/Ryker/Canyon, # immatriculées moto au Québec) ; pas de catégorie scooter sur ce site listing_paths = ("/fr/vehicules-usages/motos", "/fr/vehicules-usages/vehicules-3-roues") # slug de succursale du payload → libellés de ville d'avant la refonte _CITIES = {"mirabel": "Mirabel", "repentigny": "Repentigny", "vaudreuil": "Vaudreuil", "beloeil": "Beloeil", "ste-agathe": "Ste-Agathe"} # segments JS `self.__next_f.push([1,"…"])` (littéraux JSON.stringify) _FLIGHT_RE = re.compile( r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)', re.S) def _flight_vehicles(self, path: str) -> list[dict]: """Tableau "vehicles" du payload RSC d'une page listing.""" resp = self.get(self.base_url + path) if not resp.encoding or resp.encoding.lower() in ("iso-8859-1", "latin-1"): resp.encoding = "utf-8" blob = "".join(json.loads(f'"{m}"') for m in self._FLIGHT_RE.findall(resp.text)) i = blob.find('"vehicles":[') if i < 0: return [] arr, _ = json.JSONDecoder().raw_decode(blob, i + len('"vehicles":')) return [it for it in arr if isinstance(it, dict)] def fetch(self) -> list[Vehicle]: vehicles: list[Vehicle] = [] seen: set[str] = set() for path in self.listing_paths: for it in self._flight_vehicles(path): if it.get("condition") != "used" or it.get("isSold"): continue ext_id = str(it.get("stock") or it.get("tractionId") or "") if not ext_id or ext_id in seen: continue seen.add(ext_id) try: veh = self._to_vehicle(ext_id, it) except Exception: continue if veh is not None: vehicles.append(veh) return vehicles def _to_vehicle(self, ext_id: str, it: dict) -> Vehicle | None: make = _clean(str(it.get("make") or "")) model = _clean(str(it.get("model") or "")) year = it.get("year") try: year = int(str(year)[:4]) if year else None except ValueError: year = None title = " ".join(str(p) for p in (year, make, model) if p) if not title: return None # prix courant = salePrice (liquidation) sinon price ; l'écart entre # les deux devient le rabais affiché price = it.get("salePrice") or it.get("price") try: price = float(price) if price else None except (TypeError, ValueError): price = None base = it.get("price") try: base = float(base) if base else None except (TypeError, ValueError): base = None km = it.get("km") try: km = float(km) if km is not None else None except (TypeError, ValueError): km = None cat = it.get("categoryLabel") or {} cat_fr = _clean(str(cat.get("fr") or "")) if isinstance(cat, dict) \ else "" kind = moto_kind(str(it.get("categorySlug") or ""), str(it.get("rawCategory") or ""), model, title) details = {k: v for k, v in ( ("category", cat_fr), ("type", _clean(str(it.get("typeGroup") or "")))) if v} if base and price and base > price: details["regular_price"] = base details["rebate"] = round(base - price, 2) images = [u for u in (it.get("images") or []) if isinstance(u, str) and u.startswith("http")] loc = str(it.get("location") or "").lower() vin = str(it.get("vin") or "").upper().replace(" ", "") if len(vin) < 11: vin = "" blob = " ".join(filter(None, (make, model, title))) fuel = "Électrique" if _PG_ELECTRIC_RE.search(blob) else "Essence" return Vehicle( source=self.source_id, external_id=ext_id, url=f"{self.base_url}/fr/vehicules-usages/{ext_id}", kind=kind, title=title, make=make, # explicite : marques moto absentes model=model, year=year, price=price, price_label=f"{price:,.0f} $".replace(",", " ") if price else "", mileage_km=km, mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", transmission="Automatique" if kind == "scooter" else "", fuel=fuel, drivetrain="Propulsion", body_type=cat_fr, exterior_color=_clean(str(it.get("color") or "")), vin=vin, stock_number=str(it.get("stock") or ""), dealer_name=self.dealer_name, city=self._CITIES.get(loc) or _clean(loc).title() or self.city, description=_clean(str(it.get("descriptionFr") or ""))[:4000], details=details, images=images[:MAX_IMAGES], ) class GabrielHarleyDavidson(PowerGoMotoConnector): source_id = "gabrielharleydavidson" base_url = "https://gabrielharleydavidsonmtl.com" dealer_name = "Gabriel Harley-Davidson Montréal" city = "Montréal" class StJeromeHarleyDavidson(PowerGoMotoConnector): source_id = "stjeromeharleydavidson" base_url = "https://stjeromeharleydavidson.com" dealer_name = "St-Jérôme Harley-Davidson" city = "Saint-Jérôme" class Mecamoto(PowerGoMotoConnector): # spécialiste Vespa / Piaggio / Moto Guzzi — la plupart des scooters # usagés arrivent en catégorie « Cyclomoteurs » source_id = "mecamoto" base_url = "https://www.mecamoto.ca" dealer_name = "Mecamoto" city = "Montréal" class MotosThibaultTR(PowerGoMotoConnector): # concessionnaire Vespa / Aprilia / Suzuki de la Mauricie source_id = "motosthibaulttr" base_url = "https://www.motosthibault.ca" dealer_name = "Motos Thibault Trois-Rivières" city = "Trois-Rivières" class GroupeImperium(PowerGoMotoConnector): # groupe multi-succursales : Chicoutimi, Dolbeau-Mistassini, Chibougamau, # Rouyn-Noranda — succursale par véhicule via location source_id = "groupeimperium" base_url = "https://www.groupeimperium.ca" dealer_name = "Groupe Imperium" city = "Chicoutimi" # ============================================================================== # 3. Moto Ducharme (Joliette) — Magento (thème Traction) # Listing par catégorie server-rendered ; détail : JSON-LD @type Product + # table de spécifications (Kilométrage, Couleur…) + galerie JSON Magento. # ============================================================================== _MD_DETAIL_RE = re.compile( r'href="(https://www\.motoducharme\.com/fr/vehicules-d-occasion/' r'motocyclettes/[a-z0-9-]+-cs-(\d+))"') _MD_SPEC_RE = re.compile( r']*>\s*([^<]+?)\s*\s*' r']*>\s*([^<]+?)\s*', re.S) _MD_GALLERY_RE = re.compile(r'"data":\s*(\[\{"thumb".*?\}\])') _MD_YEAR_RE = re.compile(r"\b(19[89]\d|20[0-2]\d)\b") class MotoDucharme(BaseConnector): source_id = "motoducharme" base_url = "https://www.motoducharme.com" listing_url = "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes" dealer_name = "Moto Ducharme" city = "Joliette" request_delay = 1.0 max_details = 150 max_pages = 5 # ~15 motos affichées, marge pour grandir def _listing_urls(self) -> list[tuple[str, str]]: """[(url, ext_id)] des motos usagées, pages ?p=N de Magento.""" seen: dict[str, str] = {} max_pages = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) for p in range(1, max_pages + 1): url = self.listing_url if p == 1 else f"{self.listing_url}?p={p}" try: html = self.get(url).text except Exception: break before = len(seen) for detail_url, ext_id in _MD_DETAIL_RE.findall(html): seen.setdefault(ext_id, detail_url) if len(seen) == before: # plus rien de neuf : fin break return [(u, i) for i, u in seen.items()] def _fetch_detail(self, url: str) -> dict: html = self.get(url).text data: dict = {} for block in _LD_RE.findall(html): try: cand = json.loads(block.strip()) except ValueError: continue if isinstance(cand, dict) and cand.get("@type") == "Product": data = cand break if not data: return {} specs = {_clean(k): _clean(v) for k, v in _MD_SPEC_RE.findall(html)} if specs: data["_specs"] = specs m = _MD_GALLERY_RE.search(html) if m: try: gallery = json.loads(m.group(1)) data["_gallery"] = [g.get("full") or g.get("img") for g in gallery if isinstance(g, dict) and (g.get("full") or g.get("img"))] except ValueError: pass return data def fetch(self) -> list[Vehicle]: week = datetime.date.today().isocalendar() cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdo cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) vehicles: list[Vehicle] = [] real_fetches = 0 for url, ext_id in self._listing_urls(): if real_fetches >= cap: from .. import db if self._detail_con is None: self._detail_con = db.connect() data = db.get_cached_detail(self._detail_con, self.source_id, ext_id, cache_key) if data is None: continue else: before = self._last_request try: data = self.detail(ext_id, cache_key, lambda u=url: self._fetch_detail(u)) except Exception: if self._last_request != before: real_fetches += 1 continue if self._last_request != before: real_fetches += 1 if not data: continue try: veh = self._to_vehicle(ext_id, url, data) except Exception: continue if veh is not None: vehicles.append(veh) return vehicles def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: title = _clean(d.get("name")) if not title: return None specs = d.get("_specs") or {} offers = d.get("offers") or {} try: price = float(offers.get("price")) if price <= 0: price = None except (TypeError, ValueError): price = None km = None for label, value in specs.items(): if "kilom" in label.lower(): digits = re.sub(r"[^\d]", "", value) if digits: km = float(digits) break years = _MD_YEAR_RE.findall(title) year = int(years[-1]) if years else None brand = d.get("brand") or {} make = _clean(brand.get("name") if isinstance(brand, dict) else str(brand)) if make.isupper(): # « KAWASAKI » → « Kawasaki » make = make.title() # modèle = titre sans la marque (préfixe, parfois doublée) ni l'année model = title if make: model = re.sub(rf"^(?:{re.escape(make)}\s+)+", "", model, flags=re.I) model = _MD_YEAR_RE.sub("", model).strip(" -") images = d.get("_gallery") or [] if not images and d.get("image"): images = [d["image"]] if isinstance(d["image"], str) else d["image"] color = next((v for k, v in specs.items() if "couleur" in k.lower()), "") return Vehicle( source=self.source_id, external_id=ext_id, url=url, kind=moto_kind(url, title), title=title, make=make, model=" ".join(model.split()), year=year, price=price, price_label=f"{price:,.0f} $".replace(",", " ") if price else "", mileage_km=km, mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", exterior_color=color, stock_number=str(d.get("sku") or ""), dealer_name=self.dealer_name, city=self.city, description=_clean(d.get("description"))[:4000], details={k: v for k, v in specs.items() if v and v.upper() not in ("N.D.", "N/A")}, images=[u for u in images if isinstance(u, str)][:MAX_IMAGES], )