SPB Git

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%
24.0 KB · 601 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#   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