spb/auto-ka Public
Python 81.8%
TypeScript 12.4%
CSS 5.5%
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(?:é|é)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