# -----------------------------------------------------------------------------
# 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.
#
# 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\}")
MAX_IMAGES = 25
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:
return _pg_extract_ld_vehicle(self._get_utf8(url))
# -- 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
key = f"v1:{it.get('salePriceValue')}:{it.get('usageValue')}"
ld: dict = {}
if real_fetches < cap:
before = self._last_request
try:
ld = self.detail(ext_id, key,
lambda u=url: self._fetch_detail(u))
except Exception:
ld = {} # 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()
ld = db.get_cached_detail(self._detail_con, self.source_id,
ext_id, key) or {}
try:
veh = self._to_vehicle(ext_id, url, it, ld 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,
ld: dict) -> Vehicle | None:
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}
return Vehicle(
source=self.source_id,
external_id=ext_id,
url=url,
kind=self._kind(it),
title=title,
make=make, # explicite : marques moto absentes
model=_clean(it.get("model")) or _clean(ld.get("model")),
year=year,
price=price,
price_label=_clean(it.get("salePriceLabel")
or it.get("basePriceLabel")),
mileage_km=km,
mileage_label=usage_label,
exterior_color=_clean(ld.get("color")),
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 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(PowerGoMotoConnector):
# 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"
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],
)