SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
33.7 KB · 834 lines python
Raw Blame History
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