# -----------------------------------------------------------------------------
# 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],
)