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