SPB Git

spb/auto-ka Public

Python 81.8% TypeScript 12.4% CSS 5.5%
10.7 KB · 253 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/desmeules.py : connecteur Desmeules Chrysler (desmeules.ca),5#   gros concessionnaire Chrysler/Dodge/Jeep/RAM de Laval — ~220 véhicules6#   d'occasion.7#8#   Plateforme : ColdFusion maison + inventaire AutoJini (photos9#   images.autojini.net). La page occasion expose un flux RSS complet :10#   /fr/vehicules-doccasion/?fuseaction=inventory.feedRSS&ajaxCall=111#   -> un <item> par véhicule : titre (année marque modèle + prix), lien12#   détail (…-v<id>.html), no de stock, VIN, photo, longue description.13#14#   La page détail complète : kilométrage (detailsMileageData), moteur,15#   transmission/entraînement (microdata) et galerie photos. Elle porte aussi16#   la liste d'équipements AutoJini (« Équipement standard », divs .column :17#   h3 catégorie + ul d'items) dont la section « Transmission & Moteur »18#   contient le carburant (« Essence », « Plug-in électric / gas »…) et la19#   motricité (« Traction Intégrale », « Quatre roues motrices »…), ainsi20#   qu'une boîte « Catégorie » (Car/VUS/SUV/Truck) pour la carrosserie.21#   Ces pages sont mises en cache BD à vie (clé stable — les specs ne22#   changent pas ; le prix frais vient du RSS). AUTOKA_MAX_DETAILS (env)23#   plafonne les vraies requêtes détail par synchronisation.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import html as htmllib28import os29import re3031from ..schema import (Vehicle, normalize_body, normalize_drivetrain,32                      normalize_fuel, strip_accents)33from .base import BaseConnector3435_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S)36_TITLE_RE = re.compile(r"<title>\s*<!\[CDATA\[(.*?)\]\]>", re.S)37_LINK_RE = re.compile(r'href="(https?://[^"]+-v(\d+)\.html)"')38_STOCK_RE = re.compile(r"Stock #:\s*([^<]+)</div>")39_VIN_RE = re.compile(r"VIN #:\s*([^<]+)</div>")40_IMG_RE = re.compile(r'img[^>]*src="([^"]+)"')41_PRICE_RE = re.compile(r"-\s*\$([\d,]+)\s*$")4243# page détail (la transmission/couleur vient de la meta description :44# « … - Engine: V8 4.0L Cylinder Engine - Color : . - Transmission: Automatique »)45_KM_RE = re.compile(r'detailsMileageData">\s*([\d,\s]+)\s*km', re.I)46_ENGINE_RE = re.compile(r"Moteur:\s*</div>\s*<div[^>]*>\s*([^<]+)", re.S)47_TRANS_RE = re.compile(r"Transmission:\s*([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]*)")48_DRIVE_RE = re.compile(r"Entra[îi]nement\s*:\s*<span>\s*([^<]+)", re.I)49_COLOR_RE = re.compile(r"Color\s*:\s*([A-Za-zÀ-ÿ][^.<\"]*)[.<\"]")50_GALLERY_RE = re.compile(51    r'https?://images\.autojini\.[a-z]+/[^"\s\\<>]+?\.jpg(?!/)', re.I)5253# liste d'équipements AutoJini : divs .column (h3 catégorie + ul d'items)54_FEATURE_SECTION_RE = re.compile(55    r'<div class="column[^"]*">\s*<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>', re.S)56_LI_RE = re.compile(r"<li>(.*?)</li>", re.S)57# boîte « Catégorie » (Car / VUS / SUV / Truck…) -> carrosserie58_CATEGORY_RE = re.compile(59    r'colorBoxHead">\s*Cat(?:é|&eacute;)gorie\s*</div>'60    r'.*?colorBoxText">(.*?)</div>', re.S)6162_EXCLUDE_RE = re.compile(63    r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|"64    r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I)656667def _clean(text) -> str:68    if not text:69        return ""70    out = htmllib.unescape(str(text))71    out = re.sub(r"<[^>]+>", " ", out)72    return re.sub(r"\s+", " ", out).strip()737475# clé de cache détail stable (bump si le parsing change)76# v3 : + équipements, carburant/motricité (Transmission & Moteur), carrosserie77_DETAIL_KEY = "v3"787980class DesmeulesChrysler(BaseConnector):81    """Desmeules Chrysler — flux RSS inventaire + pages détail (km, specs)."""8283    source_id = "desmeules"84    base_url = "https://www.desmeules.ca"85    dealer_name = "Desmeules Chrysler"86    city = "Laval"87    request_delay = 1.088    max_details = 300                  # plafond de vraies requêtes par sync8990    # -- flux RSS ----------------------------------------------------------------91    def _rss_items(self) -> list[dict]:92        xml = self.get(93            f"{self.base_url}/fr/vehicules-doccasion/"94            "?fuseaction=inventory.feedRSS&ajaxCall=1").text95        items: list[dict] = []96        for chunk in _ITEM_RE.findall(xml):97            link = _LINK_RE.search(chunk)98            if not link:99                continue100            entry: dict = {"url": link.group(1).replace("http://", "https://"),101                           "id": link.group(2)}102            m = _TITLE_RE.search(chunk)103            title = _clean(m.group(1)) if m else ""104            m = _PRICE_RE.search(title)105            if m:106                entry["price"] = float(m.group(1).replace(",", ""))107                title = title[:m.start()].strip()108            title = re.sub(r"^For Sale\s+", "", title, flags=re.I)109            entry["title"] = title110            m = _STOCK_RE.search(chunk)111            if m:112                entry["stock"] = _clean(m.group(1))113            m = _VIN_RE.search(chunk)114            if m:115                entry["vin"] = _clean(m.group(1))116            m = _IMG_RE.search(chunk)117            if m:118                entry["photo"] = m.group(1).split("/Resize")[0]119            # description longue : le 2e bloc CDATA de la description120            blocks = re.findall(r"<!\[CDATA\[(.*?)\]\]>", chunk, re.S)121            if len(blocks) >= 3:122                entry["description"] = _clean(blocks[2])[:4000]123            items.append(entry)124        return items125126    # -- page détail -> payload ---------------------------------------------------127    def _fetch_detail(self, url: str) -> dict:128        html = self.get(url).text129        payload: dict = {}130        m = _KM_RE.search(html)131        if m:132            payload["km"] = float(re.sub(r"[^\d]", "", m.group(1)))133        m = _ENGINE_RE.search(html)134        if m:135            payload["engine"] = _clean(m.group(1))136        m = _TRANS_RE.search(html)137        if m:138            payload["transmission"] = _clean(m.group(1))139        m = _DRIVE_RE.search(html)140        if m:141            payload["drivetrain"] = _clean(m.group(1))142        m = _COLOR_RE.search(html)143        if m:144            payload["color"] = _clean(m.group(1))145146        # boîte « Catégorie » -> carrosserie canonique (Car reste ambigu)147        m = _CATEGORY_RE.search(html)148        if m:149            body = normalize_body(_clean(m.group(1)))150            if body:151                payload["body"] = body152153        # équipements par catégorie ; « Transmission & Moteur » porte le154        # carburant et la motricité, absents partout ailleurs155        features: list[str] = []156        for head, ul in _FEATURE_SECTION_RE.findall(html):157            items = [_clean(li) for li in _LI_RE.findall(ul)]158            items = [it for it in items if it]159            features.extend(items)160            if not strip_accents(head).lower().startswith("transmission"):161                continue162            for it in items:163                if "fuel" not in payload:164                    fuel = normalize_fuel(it)165                    if fuel:166                        payload["fuel"] = fuel167                if "drivetrain" not in payload:168                    low = strip_accents(it).lower()169                    if "quatre roues" in low or "4 roues" in low:170                        payload["drivetrain"] = "Intégrale / 4x4"171                    elif "controle" not in low:   # « Contrôle de traction »172                        drive = normalize_drivetrain(it)173                        if drive:174                            payload["drivetrain"] = drive175        if features:176            payload["features"] = features[:60]177178        images, seen = [], set()179        for u in _GALLERY_RE.findall(html):180            base = u.replace("_x.jpg", ".jpg")181            if base not in seen:182                seen.add(base)183                images.append(base)184        payload["images"] = images[:20]185        return payload186187    # -- contrat ---------------------------------------------------------------188    def fetch(self) -> list[Vehicle]:189        cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))190        vehicles: list[Vehicle] = []191        real_fetches = 0192        for item in self._rss_items():193            if _EXCLUDE_RE.search(item.get("title", "")):194                continue195            detail: dict = {}196            if real_fetches >= cap:            # plafond : cache seulement197                from .. import db198                if self._detail_con is None:199                    self._detail_con = db.connect()200                detail = db.get_cached_detail(self._detail_con, self.source_id,201                                              item["id"], _DETAIL_KEY) or {}202            else:203                def _fetch(u=item["url"]):204                    return self._fetch_detail(u)205                before = self._last_request206                try:207                    detail = self.detail(item["id"], _DETAIL_KEY, _fetch)208                except Exception:209                    detail = {}                # page disparue : données RSS210                if self._last_request != before:211                    real_fetches += 1212            veh = self._to_vehicle(item, detail or {})213            if veh is not None:214                vehicles.append(veh)215        return vehicles216217    # -- item RSS + détail -> Vehicle ----------------------------------------------218    def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None:219        title = item.get("title") or ""220        if not title:221            return None222223        price = item.get("price")224        km = detail.get("km")225226        images = list(detail.get("images") or [])227        if not images and item.get("photo"):228            images = [item["photo"]]229230        return Vehicle(231            source=self.source_id,232            external_id=item["id"],233            url=item["url"],234            title=title,                       # « 2021 Aston Martin DBX … »235            price=price,236            price_label=f"{price:,.0f} $".replace(",", " ") if price else "",237            mileage_km=km,238            mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",239            transmission=detail.get("transmission", ""),240            fuel=detail.get("fuel", ""),241            drivetrain=detail.get("drivetrain", ""),242            body_type=detail.get("body", ""),243            exterior_color=detail.get("color", ""),244            engine=detail.get("engine", ""),245            vin=item.get("vin", ""),246            stock_number=item.get("stock", ""),247            dealer_name=self.dealer_name,248            city=self.city,249            description=item.get("description", ""),250            features=list(detail.get("features") or []),251            images=images,252        )253