spb/ora-ka Public
Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)
Python 80%
TypeScript 12.9%
CSS 6.8%
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# 3. D2C Media — aucun des concessionnaires moto candidats sondés n'y est36# (PowerGo domine cette verticale), mais la base D2CMotoConnector est37# prête : mêmes mécanismes que D2CConnector (sitemap /fr/sitemap.xml +38# JSON-LD des pages /occasion/…-idNNN.html), avec un filtre de segments39# INVERSÉ (inclut motos/scooters, exclut le reste) et kind rempli.40# -----------------------------------------------------------------------------41from __future__ import annotations4243import datetime44import json45import os46import re4748from ..schema import Vehicle49from .base import BaseConnector50from .d2c_dealers import D2CConnector, _LD_RE, _VEH_URL_RE, _clean5152# --- détection moto vs scooter ------------------------------------------------53# scooter si la catégorie/le segment d'URL/le titre évoque un scooter ou un54# cyclomoteur (Vespa, Burgman, BWS, Kymco…) — sinon moto55_SCOOTER_RE = re.compile(56 r"scooter|cyclomoteur|moped|vespa|piaggio\b|burgman|bws\b|kymco|"57 r"primavera|typhoon\b|ruckus|metropolitan\b|elite\s*125|majesty|"58 r"xmax|x-max|nmax|address\b|like\s*200|agility\b|super\s*8\b", re.I)596061def moto_kind(*texts: str) -> str:62 """kind Auto-Ka ("moto" | "scooter") d'après des indices textuels."""63 blob = " ".join(t for t in texts if t)64 return "scooter" if _SCOOTER_RE.search(blob) else "moto"656667# ==============================================================================68# 1. D2C Media — base moto/scooter (aucun site connecté pour l'instant ; voir69# l'en-tête — prête pour le premier concessionnaire moto rencontré sur D2C)70# ==============================================================================7172class D2CMotoConnector(D2CConnector):73 """Base D2C Media pour les concessionnaires de MOTOS/SCOOTERS.7475 Même stratégie que D2CConnector (sitemap + JSON-LD des pages détail),76 mais le filtre de segments /occasion/<segment>/ est inversé : on ne77 garde QUE les catégories moto/scooter, et on exclut explicitement les78 motoneiges, VTT, côte-à-côte, motomarines, VR, etc.79 """8081 # catégories INCLUSES (\b après moto : « motoneige »/« motomarine » ne82 # matchent pas)83 _MOTO_SEGMENTS = re.compile(84 r"scooter|cyclomoteur|moto(?:s)?\b|motocyclette", re.I)85 # catégories toujours EXCLUES, même si un segment voisin est moto86 _NON_MOTO_SEGMENTS = re.compile(87 r"motoneige|vtt|c[oô]te[s]?-?[aà]-?c[oô]te|motomarine|spyder|ryker|"88 r"side|atv|snow|bateau|ponton|nautique|roulotte|remorque|\bvr\b|"89 r"campeur|tracteur|souffleuse|v[ée]lo|auto", re.I)9091 def _vehicle_urls(self) -> list[str]:92 xml = self._get_text(f"{self.base_url}/fr/sitemap.xml")93 urls: list[str] = []94 for loc in re.findall(r"<loc>([^<]+)</loc>", xml):95 loc = loc.strip()96 if "/occasion/" not in loc or not _VEH_URL_RE.search(loc):97 continue98 segments = loc.split("/occasion/", 1)[1].split("/")[:-1]99 if any(self._NON_MOTO_SEGMENTS.search(s) for s in segments):100 continue101 if not any(self._MOTO_SEGMENTS.search(s) for s in segments):102 continue # autos et catégories inconnues103 urls.append(loc)104 return urls105106 def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:107 veh = super()._to_vehicle(ext_id, url, d)108 if veh is None:109 return None110 path = url.split("/occasion/", 1)[-1]111 veh.kind = moto_kind(path, str(d.get("bodyType") or ""), veh.title)112 return veh113114115# ==============================================================================116# 2. PowerGo — base commune (JSON inventaire + JSON-LD des pages détail)117# ==============================================================================118119# gabarit CDN de la 1re photo du listing : {imgWidth} → largeur désirée120_PG_IMG_WIDTH_RE = re.compile(r"\{imgWidth\}")121122MAX_IMAGES = 25123124125def _pg_extract_ld_vehicle(html: str) -> dict:126 """JSON-LD @type Vehicle/Car/Motorcycle d'une page détail PowerGo.127128 Selon le gabarit, le nœud est au premier niveau, dans une liste, ou dans129 un @graph (ex. sites Harley-Davidson Gabriel/St-Jérôme).130 """131 for block in _LD_RE.findall(html):132 try:133 cand = json.loads(block.strip())134 except ValueError:135 continue136 stack = cand if isinstance(cand, list) else [cand]137 for item in stack:138 if not isinstance(item, dict):139 continue140 nodes = item.get("@graph") if isinstance(item.get("@graph"), list) \141 else [item]142 for node in nodes:143 if not isinstance(node, dict):144 continue145 types = node.get("@type")146 types = types if isinstance(types, list) else [types]147 if any(t in ("Vehicle", "Car", "Motorcycle") for t in types):148 return node149 return {}150151152class PowerGoMotoConnector(BaseConnector):153 """Base commune aux sites PowerGo. Sous-classes : base_url + ville.154155 fetch() : JSON inventaire complet → filtre usagé + motos/scooters →156 enrichissement JSON-LD par page détail (cache BD, clé prix+km : seuls les157 nouveaux véhicules et les changements de prix/km génèrent des requêtes).158 Au-delà du plafond de requêtes détail, le véhicule est quand même émis159 avec les champs (déjà riches) du listing JSON.160 """161162 base_url: str = ""163 dealer_name: str = ""164 city: str = ""165 request_delay: float = 1.0166 max_details = 400167168 # unaccentedType inclus (préfixes, insensible au singulier/pluriel) :169 # motocyclette(s) et scooters ; « motos trois roues » (Spyder/Ryker,170 # immatriculées moto au Québec) incluses aussi. Tout le reste (motoneige,171 # vtt, côte-à-côte, motomarine, bateau, vélo, remorque…) est exclu.172 include_types = ("motocyclette", "motos trois roues", "scooter")173174 def _kind(self, item: dict) -> str:175 return moto_kind(str(item.get("unaccentedCategory") or ""),176 str(item.get("unaccentedType") or ""),177 str(item.get("label") or ""))178179 # -- page détail -> JSON-LD --------------------------------------------------180 def _get_utf8(self, url: str) -> str:181 """GET dont le texte est décodé en UTF-8.182183 Les serveurs PowerGo n'envoient pas de charset ; requests retombe184 alors sur ISO-8859-1 et les accents deviennent du mojibake185 (« Numéro » au lieu de « Numéro »).186 """187 resp = self.get(url)188 if not resp.encoding or resp.encoding.lower() in ("iso-8859-1", "latin-1"):189 resp.encoding = "utf-8"190 return resp.text191192 def _fetch_detail(self, url: str) -> dict:193 return _pg_extract_ld_vehicle(self._get_utf8(url))194195 # -- contrat -------------------------------------------------------------196 def fetch(self) -> list[Vehicle]:197 items = self.get(198 f"{self.base_url}/data/vehicles-inventory-fr-items.json").json()199 if not isinstance(items, list):200 return []201 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))202203 vehicles: list[Vehicle] = []204 real_fetches = 0205 for it in items:206 if not isinstance(it, dict):207 continue208 if it.get("conditionCode") != "used" or it.get("isSold"):209 continue210 utype = str(it.get("unaccentedType") or "").lower().strip()211 if not utype.startswith(self.include_types):212 continue213 page = (it.get("page") or {}).get("url") or ""214 if not page:215 continue216 url = self.base_url + page217 ext_id = str(it.get("vehicle_id") or it.get("stockNumber") or page)218 # clé de cache = prix + km : revalidation automatique au changement219 key = f"v1:{it.get('salePriceValue')}:{it.get('usageValue')}"220221 ld: dict = {}222 if real_fetches < cap:223 before = self._last_request224 try:225 ld = self.detail(ext_id, key,226 lambda u=url: self._fetch_detail(u))227 except Exception:228 ld = {} # page détail disparue : listing seul229 if self._last_request != before:230 real_fetches += 1231 else: # plafond atteint : cache BD sinon listing seul232 from .. import db233 if self._detail_con is None:234 self._detail_con = db.connect()235 ld = db.get_cached_detail(self._detail_con, self.source_id,236 ext_id, key) or {}237 try:238 veh = self._to_vehicle(ext_id, url, it, ld or {})239 except Exception:240 continue241 if veh is not None:242 vehicles.append(veh)243 return vehicles244245 def _to_vehicle(self, ext_id: str, url: str, it: dict,246 ld: dict) -> Vehicle | None:247 title = _clean(it.get("label")) or _clean(ld.get("name"))248 if not title:249 return None250251 price = it.get("salePriceValue") or it.get("basePriceValue")252 if price in (None, "", 0, "0") and isinstance(ld.get("offers"), dict):253 price = ld["offers"].get("price")254 try:255 price = float(price) if price not in (None, "", 0, "0") else None256 except (TypeError, ValueError):257 price = None258259 # usage : PowerGo mélange km et heures (motocross) — km seulement si260 # l'étiquette le confirme ; sinon repli JSON-LD (unitCode KMT)261 km = None262 usage_label = _clean(it.get("usageLabel"))263 if it.get("usageValue") and "km" in usage_label.lower():264 try:265 km = float(it["usageValue"])266 except (TypeError, ValueError):267 km = None268 if km is None:269 odo = ld.get("mileageFromOdometer")270 if isinstance(odo, dict) and odo.get("unitCode") in ("KMT", None):271 try:272 km = float(odo.get("value"))273 except (TypeError, ValueError):274 km = None275276 year = it.get("year")277 if not year and ld.get("vehicleModelDate"):278 year = ld["vehicleModelDate"]279 try:280 year = int(str(year)[:4]) if year else None281 except ValueError:282 year = None283284 make = _clean(it.get("make"))285 if not make:286 brand = ld.get("brand") or {}287 make = _clean(brand.get("name") if isinstance(brand, dict)288 else str(brand)) or _clean(ld.get("manufacturer"))289290 images = ld.get("image") or []291 if isinstance(images, str):292 images = [images]293 images = [u for u in images if isinstance(u, str) and u.startswith("http")]294 if not images: # repli : 1re photo du listing295 img = it.get("image") or {}296 src = str(img.get("source") or "")297 dims = [d for d in (img.get("dimensions") or []) if isinstance(d, int)]298 if src:299 images = [_PG_IMG_WIDTH_RE.sub(str(max(dims) if dims else 1000),300 src)]301302 details = {k: v for k, v in (("type", _clean(it.get("type"))),303 ("category", _clean(it.get("category"))))304 if v}305306 return Vehicle(307 source=self.source_id,308 external_id=ext_id,309 url=url,310 kind=self._kind(it),311 title=title,312 make=make, # explicite : marques moto absentes313 model=_clean(it.get("model")) or _clean(ld.get("model")),314 year=year,315 price=price,316 price_label=_clean(it.get("salePriceLabel")317 or it.get("basePriceLabel")),318 mileage_km=km,319 mileage_label=usage_label,320 exterior_color=_clean(ld.get("color")),321 stock_number=_clean(it.get("stockNumber")) or _clean(ld.get("sku")),322 dealer_name=self.dealer_name,323 # groupes multi-succursales (Contant, Imperium…) : la succursale324 # du véhicule est dans le listing JSON325 city=_clean(it.get("location")) or self.city,326 description=_clean(ld.get("description"))[:4000],327 details=details,328 images=images[:MAX_IMAGES],329 )330331332# ------------------------------------------------------------------------------333# Concessionnaires PowerGo (1 sous-classe = 1 source)334# ------------------------------------------------------------------------------335336class MathiasSports(PowerGoMotoConnector):337 source_id = "mathiassports"338 base_url = "https://mathiassports.com"339 dealer_name = "Mathias Sports"340 city = "Saint-Mathias-sur-Richelieu"341342343class SMSport(PowerGoMotoConnector):344 source_id = "smsport"345 base_url = "https://smsport.ca"346 dealer_name = "SM Sport"347 city = "Québec"348349350class MotosIllimitees(PowerGoMotoConnector):351 source_id = "motosillimitees"352 base_url = "https://www.motosillimitees.com"353 dealer_name = "Motos Illimitées"354 city = "Terrebonne"355356357class RMMotosport(PowerGoMotoConnector):358 source_id = "rmmotosport"359 base_url = "https://www.rmmotosport.com"360 dealer_name = "RM Motosport"361 city = "Victoriaville"362363364class GLSport(PowerGoMotoConnector):365 source_id = "glsport"366 base_url = "https://www.glsport.ca"367 dealer_name = "GL Sport"368 city = "Saint-Gervais"369370371class ExcelMoto(PowerGoMotoConnector):372 source_id = "excelmoto"373 base_url = "https://www.excelmoto.com"374 dealer_name = "Excel Moto"375 city = "Montréal"376377378class GroupeContant(PowerGoMotoConnector):379 # groupe multi-succursales : Mirabel, Repentigny, Vaudreuil, Beloeil,380 # Ste-Agathe — la ville de chaque unité vient du listing (location)381 source_id = "groupecontant"382 base_url = "https://www.contant.ca"383 dealer_name = "Groupe Contant"384 city = "Repentigny"385386387class GabrielHarleyDavidson(PowerGoMotoConnector):388 source_id = "gabrielharleydavidson"389 base_url = "https://gabrielharleydavidsonmtl.com"390 dealer_name = "Gabriel Harley-Davidson Montréal"391 city = "Montréal"392393394class StJeromeHarleyDavidson(PowerGoMotoConnector):395 source_id = "stjeromeharleydavidson"396 base_url = "https://stjeromeharleydavidson.com"397 dealer_name = "St-Jérôme Harley-Davidson"398 city = "Saint-Jérôme"399400401class Mecamoto(PowerGoMotoConnector):402 # spécialiste Vespa / Piaggio / Moto Guzzi — la plupart des scooters403 # usagés arrivent en catégorie « Cyclomoteurs »404 source_id = "mecamoto"405 base_url = "https://www.mecamoto.ca"406 dealer_name = "Mecamoto"407 city = "Montréal"408409410class MotosThibaultTR(PowerGoMotoConnector):411 # concessionnaire Vespa / Aprilia / Suzuki de la Mauricie412 source_id = "motosthibaulttr"413 base_url = "https://www.motosthibault.ca"414 dealer_name = "Motos Thibault Trois-Rivières"415 city = "Trois-Rivières"416417418class GroupeImperium(PowerGoMotoConnector):419 # groupe multi-succursales : Chicoutimi, Dolbeau-Mistassini, Chibougamau,420 # Rouyn-Noranda — succursale par véhicule via location421 source_id = "groupeimperium"422 base_url = "https://www.groupeimperium.ca"423 dealer_name = "Groupe Imperium"424 city = "Chicoutimi"425426427# ==============================================================================428# 3. Moto Ducharme (Joliette) — Magento (thème Traction)429# Listing par catégorie server-rendered ; détail : JSON-LD @type Product +430# table de spécifications (Kilométrage, Couleur…) + galerie JSON Magento.431# ==============================================================================432433_MD_DETAIL_RE = re.compile(434 r'href="(https://www\.motoducharme\.com/fr/vehicules-d-occasion/'435 r'motocyclettes/[a-z0-9-]+-cs-(\d+))"')436_MD_SPEC_RE = re.compile(437 r'<th class="col label"[^>]*>\s*([^<]+?)\s*</th>\s*'438 r'<td class="col data"[^>]*>\s*([^<]+?)\s*</td>', re.S)439_MD_GALLERY_RE = re.compile(r'"data":\s*(\[\{"thumb".*?\}\])')440_MD_YEAR_RE = re.compile(r"\b(19[89]\d|20[0-2]\d)\b")441442443class MotoDucharme(BaseConnector):444 source_id = "motoducharme"445 base_url = "https://www.motoducharme.com"446 listing_url = "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes"447 dealer_name = "Moto Ducharme"448 city = "Joliette"449 request_delay = 1.0450 max_details = 150451 max_pages = 5 # ~15 motos affichées, marge pour grandir452453 def _listing_urls(self) -> list[tuple[str, str]]:454 """[(url, ext_id)] des motos usagées, pages ?p=N de Magento."""455 seen: dict[str, str] = {}456 max_pages = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))457 for p in range(1, max_pages + 1):458 url = self.listing_url if p == 1 else f"{self.listing_url}?p={p}"459 try:460 html = self.get(url).text461 except Exception:462 break463 before = len(seen)464 for detail_url, ext_id in _MD_DETAIL_RE.findall(html):465 seen.setdefault(ext_id, detail_url)466 if len(seen) == before: # plus rien de neuf : fin467 break468 return [(u, i) for i, u in seen.items()]469470 def _fetch_detail(self, url: str) -> dict:471 html = self.get(url).text472 data: dict = {}473 for block in _LD_RE.findall(html):474 try:475 cand = json.loads(block.strip())476 except ValueError:477 continue478 if isinstance(cand, dict) and cand.get("@type") == "Product":479 data = cand480 break481 if not data:482 return {}483 specs = {_clean(k): _clean(v) for k, v in _MD_SPEC_RE.findall(html)}484 if specs:485 data["_specs"] = specs486 m = _MD_GALLERY_RE.search(html)487 if m:488 try:489 gallery = json.loads(m.group(1))490 data["_gallery"] = [g.get("full") or g.get("img")491 for g in gallery492 if isinstance(g, dict)493 and (g.get("full") or g.get("img"))]494 except ValueError:495 pass496 return data497498 def fetch(self) -> list[Vehicle]:499 week = datetime.date.today().isocalendar()500 cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdo501 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))502503 vehicles: list[Vehicle] = []504 real_fetches = 0505 for url, ext_id in self._listing_urls():506 if real_fetches >= cap:507 from .. import db508 if self._detail_con is None:509 self._detail_con = db.connect()510 data = db.get_cached_detail(self._detail_con, self.source_id,511 ext_id, cache_key)512 if data is None:513 continue514 else:515 before = self._last_request516 try:517 data = self.detail(ext_id, cache_key,518 lambda u=url: self._fetch_detail(u))519 except Exception:520 if self._last_request != before:521 real_fetches += 1522 continue523 if self._last_request != before:524 real_fetches += 1525 if not data:526 continue527 try:528 veh = self._to_vehicle(ext_id, url, data)529 except Exception:530 continue531 if veh is not None:532 vehicles.append(veh)533 return vehicles534535 def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:536 title = _clean(d.get("name"))537 if not title:538 return None539 specs = d.get("_specs") or {}540541 offers = d.get("offers") or {}542 try:543 price = float(offers.get("price"))544 if price <= 0:545 price = None546 except (TypeError, ValueError):547 price = None548549 km = None550 for label, value in specs.items():551 if "kilom" in label.lower():552 digits = re.sub(r"[^\d]", "", value)553 if digits:554 km = float(digits)555 break556557 years = _MD_YEAR_RE.findall(title)558 year = int(years[-1]) if years else None559560 brand = d.get("brand") or {}561 make = _clean(brand.get("name") if isinstance(brand, dict)562 else str(brand))563 if make.isupper(): # « KAWASAKI » → « Kawasaki »564 make = make.title()565 # modèle = titre sans la marque (préfixe, parfois doublée) ni l'année566 model = title567 if make:568 model = re.sub(rf"^(?:{re.escape(make)}\s+)+", "", model,569 flags=re.I)570 model = _MD_YEAR_RE.sub("", model).strip(" -")571572 images = d.get("_gallery") or []573 if not images and d.get("image"):574 images = [d["image"]] if isinstance(d["image"], str) else d["image"]575576 color = next((v for k, v in specs.items()577 if "couleur" in k.lower()), "")578579 return Vehicle(580 source=self.source_id,581 external_id=ext_id,582 url=url,583 kind=moto_kind(url, title),584 title=title,585 make=make,586 model=" ".join(model.split()),587 year=year,588 price=price,589 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",590 mileage_km=km,591 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",592 exterior_color=color,593 stock_number=str(d.get("sku") or ""),594 dealer_name=self.dealer_name,595 city=self.city,596 description=_clean(d.get("description"))[:4000],597 details={k: v for k, v in specs.items()598 if v and v.upper() not in ("N.D.", "N/A")},599 images=[u for u in images if isinstance(u, str)][:MAX_IMAGES],600 )601