Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de véhicules usagés à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/moto_dealers.py : verticale MOTOS & SCOOTERS usagés — connecteurs5# des concessionnaires de motocyclettes/scooters du Québec.6#7# Chaque véhicule émis porte kind="moto" ou kind="scooter" (jamais "auto").8# Les motoneiges, VTT, côte-à-côte, motomarines, bateaux, VR et vélos sont9# systématiquement EXCLUS. Les marques moto (Harley-Davidson, Indian, KTM,10# Vespa…) ne sont pas dans normalize.MAKES : make est TOUJOURS rempli11# explicitement depuis la source (JSON inventaire ou brand JSON-LD), on ne12# compte jamais sur split_title().13#14# Plateformes rencontrées dans cette verticale :15#16# 1. PowerGo (powergo.ca) — de très loin la plus répandue chez les17# concessionnaires de sports motorisés au Québec (l'équivalent moto de18# D2C Media côté autos). Sites Next.js ; l'inventaire COMPLET (neuf +19# usagé, tous types) est publié dans un JSON public :20# <base>/data/vehicles-inventory-fr-items.json21# → type, category, conditionCode (new/used), make, model, year, prix,22# usage (km), stock, URL de la page détail, 1re photo (gabarit CDN).23# Chaque page détail expose en plus un JSON-LD schema.org @type Vehicle24# (parfois dans un @graph) : galerie complète, description, couleur,25# kilométrage… Pages détail en cache BD, clé = prix+km du listing (donc26# revalidation automatique quand le prix ou le km change).27# NB : les sites PowerGo « ancien gabarit » (premonthd.com, lavalmoto.com,28# motojmf.com, motopro.ca…) n'exposent PAS ce JSON (listing 100 % JS).29#30# 2. Magento/Traction (Moto Ducharme) — listing HTML server-rendered31# par catégorie (/fr/vehicules-d-occasion/motocyclettes), pages détail32# avec JSON-LD @type Product + table de spécifications (Kilométrage,33# Couleur…) + galerie JSON Magento.34#35# 2b. Next.js custom (Groupe Contant, refonte 2026 — ex-PowerGo) : usagés36# server-rendered dans le payload RSC (self.__next_f.push) des pages37# /fr/vehicules-usages/<type> ; voir la classe GroupeContant.38#39# 3. D2C Media — aucun des concessionnaires moto candidats sondés n'y est40# (PowerGo domine cette verticale), mais la base D2CMotoConnector est41# prête : mêmes mécanismes que D2CConnector (sitemap /fr/sitemap.xml +42# JSON-LD des pages /occasion/…-idNNN.html), avec un filtre de segments43# INVERSÉ (inclut motos/scooters, exclut le reste) et kind rempli.44# -----------------------------------------------------------------------------45from __future__ import annotations4647import datetime48import json49import os50import re5152from ..schema import Vehicle53from .base import BaseConnector54from .d2c_dealers import D2CConnector, _LD_RE, _VEH_URL_RE, _clean5556# --- détection moto vs scooter ------------------------------------------------57# scooter si la catégorie/le segment d'URL/le titre évoque un scooter ou un58# cyclomoteur (Vespa, Burgman, BWS, Kymco…) — sinon moto59_SCOOTER_RE = re.compile(60 r"scooter|cyclomoteur|moped|vespa|piaggio\b|burgman|bws\b|kymco|"61 r"primavera|typhoon\b|ruckus|metropolitan\b|elite\s*125|majesty|"62 r"xmax|x-max|nmax|address\b|like\s*200|agility\b|super\s*8\b", re.I)636465def moto_kind(*texts: str) -> str:66 """kind Auto-Ka ("moto" | "scooter") d'après des indices textuels."""67 blob = " ".join(t for t in texts if t)68 return "scooter" if _SCOOTER_RE.search(blob) else "moto"697071# ==============================================================================72# 1. D2C Media — base moto/scooter (aucun site connecté pour l'instant ; voir73# l'en-tête — prête pour le premier concessionnaire moto rencontré sur D2C)74# ==============================================================================7576class D2CMotoConnector(D2CConnector):77 """Base D2C Media pour les concessionnaires de MOTOS/SCOOTERS.7879 Même stratégie que D2CConnector (sitemap + JSON-LD des pages détail),80 mais le filtre de segments /occasion/<segment>/ est inversé : on ne81 garde QUE les catégories moto/scooter, et on exclut explicitement les82 motoneiges, VTT, côte-à-côte, motomarines, VR, etc.83 """8485 # catégories INCLUSES (\b après moto : « motoneige »/« motomarine » ne86 # matchent pas)87 _MOTO_SEGMENTS = re.compile(88 r"scooter|cyclomoteur|moto(?:s)?\b|motocyclette", re.I)89 # catégories toujours EXCLUES, même si un segment voisin est moto90 _NON_MOTO_SEGMENTS = re.compile(91 r"motoneige|vtt|c[oô]te[s]?-?[aà]-?c[oô]te|motomarine|spyder|ryker|"92 r"side|atv|snow|bateau|ponton|nautique|roulotte|remorque|\bvr\b|"93 r"campeur|tracteur|souffleuse|v[ée]lo|auto", re.I)9495 def _vehicle_urls(self) -> list[str]:96 xml = self._get_text(f"{self.base_url}/fr/sitemap.xml")97 urls: list[str] = []98 for loc in re.findall(r"<loc>([^<]+)</loc>", xml):99 loc = loc.strip()100 if "/occasion/" not in loc or not _VEH_URL_RE.search(loc):101 continue102 segments = loc.split("/occasion/", 1)[1].split("/")[:-1]103 if any(self._NON_MOTO_SEGMENTS.search(s) for s in segments):104 continue105 if not any(self._MOTO_SEGMENTS.search(s) for s in segments):106 continue # autos et catégories inconnues107 urls.append(loc)108 return urls109110 def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:111 veh = super()._to_vehicle(ext_id, url, d)112 if veh is None:113 return None114 path = url.split("/occasion/", 1)[-1]115 veh.kind = moto_kind(path, str(d.get("bodyType") or ""), veh.title)116 return veh117118119# ==============================================================================120# 2. PowerGo — base commune (JSON inventaire + JSON-LD des pages détail)121# ==============================================================================122123# gabarit CDN de la 1re photo du listing : {imgWidth} → largeur désirée124_PG_IMG_WIDTH_RE = re.compile(r"\{imgWidth\}")125126# liste « Spécifications » de la page détail PowerGo : le JSON-LD n'expose127# NI VIN, NI carburant, NI catégorie — mais le HTML server-rendered liste128# <li class="… spec-vin">NIV: <span …>538XXFZ…</span></li>, spec-color,129# spec-category, spec-engine-size, spec-submodel, spec-weight…130_PG_SPEC_RE = re.compile(131 r'<li class="[^"]*\bspec-([a-z0-9_-]+)[^"]*">(.*?)</li>', re.S)132_PG_SPEC_VAL_RE = re.compile(r"<span[^>]*>(.*?)</span>", re.S)133134# moto électrique (make/modèle/titre) : ne jamais leur inférer Essence135_PG_ELECTRIC_RE = re.compile(136 r"\b(électrique|electrique|electric|livewire|zero motorcycles|energica|"137 r"elettrica|e-?scooter|can-?am\s+(?:origin|pulse))\b", re.I)138139MAX_IMAGES = 25140141142def _pg_extract_specs(html: str) -> dict:143 """Spécifications de la page détail : {'vin': …, 'category': …, …}."""144 specs: dict[str, str] = {}145 for name, body in _PG_SPEC_RE.findall(html):146 m = _PG_SPEC_VAL_RE.search(body)147 if not m:148 continue149 val = _clean(m.group(1))150 if val and val.upper() not in ("-", "N/D", "N.D.", "N/A"):151 specs.setdefault(name.lower(), val)152 return specs153154155def _pg_extract_ld_vehicle(html: str) -> dict:156 """JSON-LD @type Vehicle/Car/Motorcycle d'une page détail PowerGo.157158 Selon le gabarit, le nœud est au premier niveau, dans une liste, ou dans159 un @graph (ex. sites Harley-Davidson Gabriel/St-Jérôme).160 """161 for block in _LD_RE.findall(html):162 try:163 cand = json.loads(block.strip())164 except ValueError:165 continue166 stack = cand if isinstance(cand, list) else [cand]167 for item in stack:168 if not isinstance(item, dict):169 continue170 nodes = item.get("@graph") if isinstance(item.get("@graph"), list) \171 else [item]172 for node in nodes:173 if not isinstance(node, dict):174 continue175 types = node.get("@type")176 types = types if isinstance(types, list) else [types]177 if any(t in ("Vehicle", "Car", "Motorcycle") for t in types):178 return node179 return {}180181182class PowerGoMotoConnector(BaseConnector):183 """Base commune aux sites PowerGo. Sous-classes : base_url + ville.184185 fetch() : JSON inventaire complet → filtre usagé + motos/scooters →186 enrichissement JSON-LD par page détail (cache BD, clé prix+km : seuls les187 nouveaux véhicules et les changements de prix/km génèrent des requêtes).188 Au-delà du plafond de requêtes détail, le véhicule est quand même émis189 avec les champs (déjà riches) du listing JSON.190 """191192 base_url: str = ""193 dealer_name: str = ""194 city: str = ""195 request_delay: float = 1.0196 max_details = 400197198 # unaccentedType inclus (préfixes, insensible au singulier/pluriel) :199 # motocyclette(s) et scooters ; « motos trois roues » (Spyder/Ryker,200 # immatriculées moto au Québec) incluses aussi. Tout le reste (motoneige,201 # vtt, côte-à-côte, motomarine, bateau, vélo, remorque…) est exclu.202 include_types = ("motocyclette", "motos trois roues", "scooter")203204 def _kind(self, item: dict) -> str:205 return moto_kind(str(item.get("unaccentedCategory") or ""),206 str(item.get("unaccentedType") or ""),207 str(item.get("label") or ""))208209 # -- page détail -> JSON-LD --------------------------------------------------210 def _get_utf8(self, url: str) -> str:211 """GET dont le texte est décodé en UTF-8.212213 Les serveurs PowerGo n'envoient pas de charset ; requests retombe214 alors sur ISO-8859-1 et les accents deviennent du mojibake215 (« Numéro » au lieu de « Numéro »).216 """217 resp = self.get(url)218 if not resp.encoding or resp.encoding.lower() in ("iso-8859-1", "latin-1"):219 resp.encoding = "utf-8"220 return resp.text221222 def _fetch_detail(self, url: str) -> dict:223 """Payload détail : JSON-LD + liste « Spécifications » du HTML.224225 Le JSON-LD PowerGo est pauvre (ni VIN, ni carburant, ni catégorie) ;226 les spécifications server-rendered comblent VIN, couleur, catégorie,227 cylindrée, sous-modèle.228 """229 html = self._get_utf8(url)230 return {"ld": _pg_extract_ld_vehicle(html),231 "specs": _pg_extract_specs(html)}232233 # -- contrat -------------------------------------------------------------234 def fetch(self) -> list[Vehicle]:235 items = self.get(236 f"{self.base_url}/data/vehicles-inventory-fr-items.json").json()237 if not isinstance(items, list):238 return []239 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))240241 vehicles: list[Vehicle] = []242 real_fetches = 0243 for it in items:244 if not isinstance(it, dict):245 continue246 if it.get("conditionCode") != "used" or it.get("isSold"):247 continue248 utype = str(it.get("unaccentedType") or "").lower().strip()249 if not utype.startswith(self.include_types):250 continue251 page = (it.get("page") or {}).get("url") or ""252 if not page:253 continue254 url = self.base_url + page255 ext_id = str(it.get("vehicle_id") or it.get("stockNumber") or page)256 # clé de cache = prix + km : revalidation automatique au changement257 # (v2 : + specs HTML — VIN, catégorie, cylindrée, couleur)258 key = f"v2:{it.get('salePriceValue')}:{it.get('usageValue')}"259260 det: dict = {}261 if real_fetches < cap:262 before = self._last_request263 try:264 det = self.detail(ext_id, key,265 lambda u=url: self._fetch_detail(u))266 except Exception:267 det = {} # page détail disparue : listing seul268 if self._last_request != before:269 real_fetches += 1270 else: # plafond atteint : cache BD sinon listing seul271 from .. import db272 if self._detail_con is None:273 self._detail_con = db.connect()274 det = db.get_cached_detail(self._detail_con, self.source_id,275 ext_id, key) or {}276 try:277 veh = self._to_vehicle(ext_id, url, it, det or {})278 except Exception:279 continue280 if veh is not None:281 vehicles.append(veh)282 return vehicles283284 def _to_vehicle(self, ext_id: str, url: str, it: dict,285 det: dict) -> Vehicle | None:286 ld = det.get("ld") or {}287 specs = det.get("specs") or {}288289 title = _clean(it.get("label")) or _clean(ld.get("name"))290 if not title:291 return None292293 price = it.get("salePriceValue") or it.get("basePriceValue")294 if price in (None, "", 0, "0") and isinstance(ld.get("offers"), dict):295 price = ld["offers"].get("price")296 try:297 price = float(price) if price not in (None, "", 0, "0") else None298 except (TypeError, ValueError):299 price = None300301 # usage : PowerGo mélange km et heures (motocross) — km seulement si302 # l'étiquette le confirme ; sinon repli JSON-LD (unitCode KMT)303 km = None304 usage_label = _clean(it.get("usageLabel"))305 if it.get("usageValue") and "km" in usage_label.lower():306 try:307 km = float(it["usageValue"])308 except (TypeError, ValueError):309 km = None310 if km is None:311 odo = ld.get("mileageFromOdometer")312 if isinstance(odo, dict) and odo.get("unitCode") in ("KMT", None):313 try:314 km = float(odo.get("value"))315 except (TypeError, ValueError):316 km = None317318 year = it.get("year")319 if not year and ld.get("vehicleModelDate"):320 year = ld["vehicleModelDate"]321 try:322 year = int(str(year)[:4]) if year else None323 except ValueError:324 year = None325326 make = _clean(it.get("make"))327 if not make:328 brand = ld.get("brand") or {}329 make = _clean(brand.get("name") if isinstance(brand, dict)330 else str(brand)) or _clean(ld.get("manufacturer"))331332 images = ld.get("image") or []333 if isinstance(images, str):334 images = [images]335 images = [u for u in images if isinstance(u, str) and u.startswith("http")]336 if not images: # repli : 1re photo du listing337 img = it.get("image") or {}338 src = str(img.get("source") or "")339 dims = [d for d in (img.get("dimensions") or []) if isinstance(d, int)]340 if src:341 images = [_PG_IMG_WIDTH_RE.sub(str(max(dims) if dims else 1000),342 src)]343344 details = {k: v for k, v in (("type", _clean(it.get("type"))),345 ("category", _clean(it.get("category"))))346 if v}347348 # prix courant vs prix régulier du listing -> rabais affiché349 base = it.get("basePriceValue")350 try:351 base = float(base) if base not in (None, "", 0, "0") else None352 except (TypeError, ValueError):353 base = None354 if base and price and base > price:355 details["regular_price"] = base356 details["rebate"] = round(base - price, 2)357358 # vidéos du listing (visite 360 / YouTube) — URL quand la source la359 # publie, sinon simple drapeau booléen (cas motosillimitees)360 video = it.get("video360Url") or it.get("videoYoutubeUrl")361 if isinstance(video, str) and video.startswith("http"):362 details["video_url"] = video363 elif video:364 details["has_video"] = True365366 if specs.get("weight"):367 details["weight"] = specs["weight"]368369 kind = self._kind(it)370371 # VIN / catégorie (carrosserie moto) / cylindrée / couleur : la liste372 # « Spécifications » de la page détail (le JSON-LD ne les a pas)373 vin = (specs.get("vin") or "").upper().replace(" ", "")374 if len(vin) < 11: # « - », tronqué, absent375 vin = ""376 body_type = specs.get("category") or _clean(it.get("category"))377 engine = specs.get("engine-size", "")378379 # carburant : Électrique si la marque/le modèle l'indique, sinon380 # Essence (aucune moto/scooter thermique n'est diesel/hybride)381 blob = " ".join(filter(None, (make, title, _clean(it.get("model")))))382 fuel = "Électrique" if _PG_ELECTRIC_RE.search(blob) else "Essence"383384 # rouage : roue arrière motrice = Propulsion (universel en moto,385 # y compris les trois roues Spyder/Ryker) ; boîte : les scooters386 # sont tous à variateur (CVT) -> Automatique, motos trop variées387 transmission = "Automatique" if kind == "scooter" else ""388389 return Vehicle(390 source=self.source_id,391 external_id=ext_id,392 url=url,393 kind=kind,394 title=title,395 make=make, # explicite : marques moto absentes396 model=_clean(it.get("model")) or _clean(ld.get("model")),397 trim=specs.get("submodel", ""),398 year=year,399 price=price,400 price_label=_clean(it.get("salePriceLabel")401 or it.get("basePriceLabel")),402 mileage_km=km,403 mileage_label=usage_label,404 transmission=transmission,405 fuel=fuel,406 drivetrain="Propulsion",407 body_type=body_type,408 exterior_color=_clean(ld.get("color")) or specs.get("color", ""),409 engine=engine,410 vin=vin,411 stock_number=_clean(it.get("stockNumber")) or _clean(ld.get("sku")),412 dealer_name=self.dealer_name,413 # groupes multi-succursales (Contant, Imperium…) : la succursale414 # du véhicule est dans le listing JSON415 city=_clean(it.get("location")) or specs.get("location", "")416 or self.city,417 description=_clean(ld.get("description"))[:4000],418 details=details,419 images=images[:MAX_IMAGES],420 )421422423# ------------------------------------------------------------------------------424# Concessionnaires PowerGo (1 sous-classe = 1 source)425# ------------------------------------------------------------------------------426427class MathiasSports(PowerGoMotoConnector):428 source_id = "mathiassports"429 base_url = "https://mathiassports.com"430 dealer_name = "Mathias Sports"431 city = "Saint-Mathias-sur-Richelieu"432433434class SMSport(PowerGoMotoConnector):435 source_id = "smsport"436 base_url = "https://smsport.ca"437 dealer_name = "SM Sport"438 city = "Québec"439440441class MotosIllimitees(PowerGoMotoConnector):442 source_id = "motosillimitees"443 base_url = "https://www.motosillimitees.com"444 dealer_name = "Motos Illimitées"445 city = "Terrebonne"446447448class RMMotosport(PowerGoMotoConnector):449 source_id = "rmmotosport"450 base_url = "https://www.rmmotosport.com"451 dealer_name = "RM Motosport"452 city = "Victoriaville"453454455class GLSport(PowerGoMotoConnector):456 source_id = "glsport"457 base_url = "https://www.glsport.ca"458 dealer_name = "GL Sport"459 city = "Saint-Gervais"460461462class ExcelMoto(PowerGoMotoConnector):463 source_id = "excelmoto"464 base_url = "https://www.excelmoto.com"465 dealer_name = "Excel Moto"466 city = "Montréal"467468469class GroupeContant(BaseConnector):470 """Groupe Contant — contant.ca (refonte 2026 : Next.js custom, plus PowerGo).471472 Le JSON PowerGo /data/vehicles-inventory-fr-items.json est mort (404).473 Les usagés sont server-rendered dans le payload RSC (flight,474 self.__next_f.push) des pages /fr/vehicules-usages/<type> : tableau475 "vehicles" COMPLET (aucune pagination) — stock, make, model, year, price,476 salePrice, km, images S3, descriptionFr, location (succursale)… Les pages477 détail (/fr/vehicules-usages/<stock>) n'apportent rien de plus (vin/color478 y sont aussi null) : on n'en fait AUCUNE.479 Groupe multi-succursales : Mirabel, Repentigny, Vaudreuil, Beloeil,480 Ste-Agathe — la ville de chaque unité vient du listing (location).481 """482483 source_id = "groupecontant"484 base_url = "https://www.contant.ca"485 dealer_name = "Groupe Contant"486 city = "Repentigny"487 request_delay = 1.0488 use_detail_cache = False489490 # types usagés indexés : motos + trois-roues (Spyder/Ryker/Canyon,491 # immatriculées moto au Québec) ; pas de catégorie scooter sur ce site492 listing_paths = ("/fr/vehicules-usages/motos",493 "/fr/vehicules-usages/vehicules-3-roues")494495 # slug de succursale du payload → libellés de ville d'avant la refonte496 _CITIES = {"mirabel": "Mirabel", "repentigny": "Repentigny",497 "vaudreuil": "Vaudreuil", "beloeil": "Beloeil",498 "ste-agathe": "Ste-Agathe"}499500 # segments JS `self.__next_f.push([1,"…"])` (littéraux JSON.stringify)501 _FLIGHT_RE = re.compile(502 r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)', re.S)503504 def _flight_vehicles(self, path: str) -> list[dict]:505 """Tableau "vehicles" du payload RSC d'une page listing."""506 resp = self.get(self.base_url + path)507 if not resp.encoding or resp.encoding.lower() in ("iso-8859-1",508 "latin-1"):509 resp.encoding = "utf-8"510 blob = "".join(json.loads(f'"{m}"')511 for m in self._FLIGHT_RE.findall(resp.text))512 i = blob.find('"vehicles":[')513 if i < 0:514 return []515 arr, _ = json.JSONDecoder().raw_decode(blob, i + len('"vehicles":'))516 return [it for it in arr if isinstance(it, dict)]517518 def fetch(self) -> list[Vehicle]:519 vehicles: list[Vehicle] = []520 seen: set[str] = set()521 for path in self.listing_paths:522 for it in self._flight_vehicles(path):523 if it.get("condition") != "used" or it.get("isSold"):524 continue525 ext_id = str(it.get("stock") or it.get("tractionId") or "")526 if not ext_id or ext_id in seen:527 continue528 seen.add(ext_id)529 try:530 veh = self._to_vehicle(ext_id, it)531 except Exception:532 continue533 if veh is not None:534 vehicles.append(veh)535 return vehicles536537 def _to_vehicle(self, ext_id: str, it: dict) -> Vehicle | None:538 make = _clean(str(it.get("make") or ""))539 model = _clean(str(it.get("model") or ""))540 year = it.get("year")541 try:542 year = int(str(year)[:4]) if year else None543 except ValueError:544 year = None545 title = " ".join(str(p) for p in (year, make, model) if p)546 if not title:547 return None548549 # prix courant = salePrice (liquidation) sinon price ; l'écart entre550 # les deux devient le rabais affiché551 price = it.get("salePrice") or it.get("price")552 try:553 price = float(price) if price else None554 except (TypeError, ValueError):555 price = None556 base = it.get("price")557 try:558 base = float(base) if base else None559 except (TypeError, ValueError):560 base = None561562 km = it.get("km")563 try:564 km = float(km) if km is not None else None565 except (TypeError, ValueError):566 km = None567568 cat = it.get("categoryLabel") or {}569 cat_fr = _clean(str(cat.get("fr") or "")) if isinstance(cat, dict) \570 else ""571 kind = moto_kind(str(it.get("categorySlug") or ""),572 str(it.get("rawCategory") or ""), model, title)573574 details = {k: v for k, v in (575 ("category", cat_fr),576 ("type", _clean(str(it.get("typeGroup") or "")))) if v}577 if base and price and base > price:578 details["regular_price"] = base579 details["rebate"] = round(base - price, 2)580581 images = [u for u in (it.get("images") or [])582 if isinstance(u, str) and u.startswith("http")]583584 loc = str(it.get("location") or "").lower()585 vin = str(it.get("vin") or "").upper().replace(" ", "")586 if len(vin) < 11:587 vin = ""588589 blob = " ".join(filter(None, (make, model, title)))590 fuel = "Électrique" if _PG_ELECTRIC_RE.search(blob) else "Essence"591592 return Vehicle(593 source=self.source_id,594 external_id=ext_id,595 url=f"{self.base_url}/fr/vehicules-usages/{ext_id}",596 kind=kind,597 title=title,598 make=make, # explicite : marques moto absentes599 model=model,600 year=year,601 price=price,602 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",603 mileage_km=km,604 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",605 transmission="Automatique" if kind == "scooter" else "",606 fuel=fuel,607 drivetrain="Propulsion",608 body_type=cat_fr,609 exterior_color=_clean(str(it.get("color") or "")),610 vin=vin,611 stock_number=str(it.get("stock") or ""),612 dealer_name=self.dealer_name,613 city=self._CITIES.get(loc) or _clean(loc).title() or self.city,614 description=_clean(str(it.get("descriptionFr") or ""))[:4000],615 details=details,616 images=images[:MAX_IMAGES],617 )618619620class GabrielHarleyDavidson(PowerGoMotoConnector):621 source_id = "gabrielharleydavidson"622 base_url = "https://gabrielharleydavidsonmtl.com"623 dealer_name = "Gabriel Harley-Davidson Montréal"624 city = "Montréal"625626627class StJeromeHarleyDavidson(PowerGoMotoConnector):628 source_id = "stjeromeharleydavidson"629 base_url = "https://stjeromeharleydavidson.com"630 dealer_name = "St-Jérôme Harley-Davidson"631 city = "Saint-Jérôme"632633634class Mecamoto(PowerGoMotoConnector):635 # spécialiste Vespa / Piaggio / Moto Guzzi — la plupart des scooters636 # usagés arrivent en catégorie « Cyclomoteurs »637 source_id = "mecamoto"638 base_url = "https://www.mecamoto.ca"639 dealer_name = "Mecamoto"640 city = "Montréal"641642643class MotosThibaultTR(PowerGoMotoConnector):644 # concessionnaire Vespa / Aprilia / Suzuki de la Mauricie645 source_id = "motosthibaulttr"646 base_url = "https://www.motosthibault.ca"647 dealer_name = "Motos Thibault Trois-Rivières"648 city = "Trois-Rivières"649650651class GroupeImperium(PowerGoMotoConnector):652 # groupe multi-succursales : Chicoutimi, Dolbeau-Mistassini, Chibougamau,653 # Rouyn-Noranda — succursale par véhicule via location654 source_id = "groupeimperium"655 base_url = "https://www.groupeimperium.ca"656 dealer_name = "Groupe Imperium"657 city = "Chicoutimi"658659660# ==============================================================================661# 3. Moto Ducharme (Joliette) — Magento (thème Traction)662# Listing par catégorie server-rendered ; détail : JSON-LD @type Product +663# table de spécifications (Kilométrage, Couleur…) + galerie JSON Magento.664# ==============================================================================665666_MD_DETAIL_RE = re.compile(667 r'href="(https://www\.motoducharme\.com/fr/vehicules-d-occasion/'668 r'motocyclettes/[a-z0-9-]+-cs-(\d+))"')669_MD_SPEC_RE = re.compile(670 r'<th class="col label"[^>]*>\s*([^<]+?)\s*</th>\s*'671 r'<td class="col data"[^>]*>\s*([^<]+?)\s*</td>', re.S)672_MD_GALLERY_RE = re.compile(r'"data":\s*(\[\{"thumb".*?\}\])')673_MD_YEAR_RE = re.compile(r"\b(19[89]\d|20[0-2]\d)\b")674675676class MotoDucharme(BaseConnector):677 source_id = "motoducharme"678 base_url = "https://www.motoducharme.com"679 listing_url = "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes"680 dealer_name = "Moto Ducharme"681 city = "Joliette"682 request_delay = 1.0683 max_details = 150684 max_pages = 5 # ~15 motos affichées, marge pour grandir685686 def _listing_urls(self) -> list[tuple[str, str]]:687 """[(url, ext_id)] des motos usagées, pages ?p=N de Magento."""688 seen: dict[str, str] = {}689 max_pages = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))690 for p in range(1, max_pages + 1):691 url = self.listing_url if p == 1 else f"{self.listing_url}?p={p}"692 try:693 html = self.get(url).text694 except Exception:695 break696 before = len(seen)697 for detail_url, ext_id in _MD_DETAIL_RE.findall(html):698 seen.setdefault(ext_id, detail_url)699 if len(seen) == before: # plus rien de neuf : fin700 break701 return [(u, i) for i, u in seen.items()]702703 def _fetch_detail(self, url: str) -> dict:704 html = self.get(url).text705 data: dict = {}706 for block in _LD_RE.findall(html):707 try:708 cand = json.loads(block.strip())709 except ValueError:710 continue711 if isinstance(cand, dict) and cand.get("@type") == "Product":712 data = cand713 break714 if not data:715 return {}716 specs = {_clean(k): _clean(v) for k, v in _MD_SPEC_RE.findall(html)}717 if specs:718 data["_specs"] = specs719 m = _MD_GALLERY_RE.search(html)720 if m:721 try:722 gallery = json.loads(m.group(1))723 data["_gallery"] = [g.get("full") or g.get("img")724 for g in gallery725 if isinstance(g, dict)726 and (g.get("full") or g.get("img"))]727 except ValueError:728 pass729 return data730731 def fetch(self) -> list[Vehicle]:732 week = datetime.date.today().isocalendar()733 cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdo734 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))735736 vehicles: list[Vehicle] = []737 real_fetches = 0738 for url, ext_id in self._listing_urls():739 if real_fetches >= cap:740 from .. import db741 if self._detail_con is None:742 self._detail_con = db.connect()743 data = db.get_cached_detail(self._detail_con, self.source_id,744 ext_id, cache_key)745 if data is None:746 continue747 else:748 before = self._last_request749 try:750 data = self.detail(ext_id, cache_key,751 lambda u=url: self._fetch_detail(u))752 except Exception:753 if self._last_request != before:754 real_fetches += 1755 continue756 if self._last_request != before:757 real_fetches += 1758 if not data:759 continue760 try:761 veh = self._to_vehicle(ext_id, url, data)762 except Exception:763 continue764 if veh is not None:765 vehicles.append(veh)766 return vehicles767768 def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:769 title = _clean(d.get("name"))770 if not title:771 return None772 specs = d.get("_specs") or {}773774 offers = d.get("offers") or {}775 try:776 price = float(offers.get("price"))777 if price <= 0:778 price = None779 except (TypeError, ValueError):780 price = None781782 km = None783 for label, value in specs.items():784 if "kilom" in label.lower():785 digits = re.sub(r"[^\d]", "", value)786 if digits:787 km = float(digits)788 break789790 years = _MD_YEAR_RE.findall(title)791 year = int(years[-1]) if years else None792793 brand = d.get("brand") or {}794 make = _clean(brand.get("name") if isinstance(brand, dict)795 else str(brand))796 if make.isupper(): # « KAWASAKI » → « Kawasaki »797 make = make.title()798 # modèle = titre sans la marque (préfixe, parfois doublée) ni l'année799 model = title800 if make:801 model = re.sub(rf"^(?:{re.escape(make)}\s+)+", "", model,802 flags=re.I)803 model = _MD_YEAR_RE.sub("", model).strip(" -")804805 images = d.get("_gallery") or []806 if not images and d.get("image"):807 images = [d["image"]] if isinstance(d["image"], str) else d["image"]808809 color = next((v for k, v in specs.items()810 if "couleur" in k.lower()), "")811812 return Vehicle(813 source=self.source_id,814 external_id=ext_id,815 url=url,816 kind=moto_kind(url, title),817 title=title,818 make=make,819 model=" ".join(model.split()),820 year=year,821 price=price,822 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",823 mileage_km=km,824 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",825 exterior_color=color,826 stock_number=str(d.get("sku") or ""),827 dealer_name=self.dealer_name,828 city=self.city,829 description=_clean(d.get("description"))[:4000],830 details={k: v for k, v in specs.items()831 if v and v.upper() not in ("N.D.", "N/A")},832 images=[u for u in images if isinstance(u, str)][:MAX_IMAGES],833 )834