Verticales motos et scooters : onglets dédiés + 13 concessionnaires
- Champ kind (auto/moto/scooter) : schéma, migration SQLite, index, API (/api/vehicles?kind=, facets, stats filtrées autos) - Plateforme PowerGo découverte (JSON public vehicles-inventory-fr-items + JSON-LD détail) : 12 concessionnaires moto/powersports + Moto Ducharme (Magento) — 596 motos + 24 scooters - moto_dealers.py : PowerGoMotoConnector, D2CMotoConnector (prêt), détection scooter (segment/catégorie/titre), marques moto canoniques (KTM, Harley-Davidson, CFMOTO…) sans impact sur split_title autos - Frontend : onglets Autos / Motos / Scooters, héros par verticale, facettes par kind Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 10 changed files with +869 and −57
added
autoka/connectors/moto_dealers.py
+588 −0
@@ -0,0 +1,588 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de véhicules usagés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/moto_dealers.py : verticale MOTOS & SCOOTERS usagés — connecteurs | |
| 5 | +# 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 sont | |
| 9 | +# systématiquement EXCLUS. Les marques moto (Harley-Davidson, Indian, KTM, | |
| 10 | +# Vespa…) ne sont pas dans normalize.MAKES : make est TOUJOURS rempli | |
| 11 | +# explicitement depuis la source (JSON inventaire ou brand JSON-LD), on ne | |
| 12 | +# 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 les | |
| 17 | +# concessionnaires de sports motorisés au Québec (l'équivalent moto de | |
| 18 | +# 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.json | |
| 21 | +# → 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 Vehicle | |
| 24 | +# (parfois dans un @graph) : galerie complète, description, couleur, | |
| 25 | +# kilométrage… Pages détail en cache BD, clé = prix+km du listing (donc | |
| 26 | +# 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-rendered | |
| 31 | +# par catégorie (/fr/vehicules-d-occasion/motocyclettes), pages détail | |
| 32 | +# 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 est | |
| 36 | +# (PowerGo domine cette verticale), mais la base D2CMotoConnector est | |
| 37 | +# prête : mêmes mécanismes que D2CConnector (sitemap /fr/sitemap.xml + | |
| 38 | +# JSON-LD des pages /occasion/…-idNNN.html), avec un filtre de segments | |
| 39 | +# INVERSÉ (inclut motos/scooters, exclut le reste) et kind rempli. | |
| 40 | +# ----------------------------------------------------------------------------- | |
| 41 | +from __future__ import annotations | |
| 42 | + | |
| 43 | +import datetime | |
| 44 | +import json | |
| 45 | +import os | |
| 46 | +import re | |
| 47 | + | |
| 48 | +from ..schema import Vehicle | |
| 49 | +from .base import BaseConnector | |
| 50 | +from .d2c_dealers import D2CConnector, _LD_RE, _VEH_URL_RE, _clean | |
| 51 | + | |
| 52 | +# --- détection moto vs scooter ------------------------------------------------ | |
| 53 | +# scooter si la catégorie/le segment d'URL/le titre évoque un scooter ou un | |
| 54 | +# cyclomoteur (Vespa, Burgman, BWS, Kymco…) — sinon moto | |
| 55 | +_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) | |
| 59 | + | |
| 60 | + | |
| 61 | +def 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" | |
| 65 | + | |
| 66 | + | |
| 67 | +# ============================================================================== | |
| 68 | +# 1. D2C Media — base moto/scooter (aucun site connecté pour l'instant ; voir | |
| 69 | +# l'en-tête — prête pour le premier concessionnaire moto rencontré sur D2C) | |
| 70 | +# ============================================================================== | |
| 71 | + | |
| 72 | +class D2CMotoConnector(D2CConnector): | |
| 73 | + """Base D2C Media pour les concessionnaires de MOTOS/SCOOTERS. | |
| 74 | + | |
| 75 | + Même stratégie que D2CConnector (sitemap + JSON-LD des pages détail), | |
| 76 | + mais le filtre de segments /occasion/<segment>/ est inversé : on ne | |
| 77 | + garde QUE les catégories moto/scooter, et on exclut explicitement les | |
| 78 | + motoneiges, VTT, côte-à-côte, motomarines, VR, etc. | |
| 79 | + """ | |
| 80 | + | |
| 81 | + # catégories INCLUSES (\b après moto : « motoneige »/« motomarine » ne | |
| 82 | + # 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 moto | |
| 86 | + _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) | |
| 90 | + | |
| 91 | + 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 | + continue | |
| 98 | + segments = loc.split("/occasion/", 1)[1].split("/")[:-1] | |
| 99 | + if any(self._NON_MOTO_SEGMENTS.search(s) for s in segments): | |
| 100 | + continue | |
| 101 | + if not any(self._MOTO_SEGMENTS.search(s) for s in segments): | |
| 102 | + continue # autos et catégories inconnues | |
| 103 | + urls.append(loc) | |
| 104 | + return urls | |
| 105 | + | |
| 106 | + 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 None | |
| 110 | + path = url.split("/occasion/", 1)[-1] | |
| 111 | + veh.kind = moto_kind(path, str(d.get("bodyType") or ""), veh.title) | |
| 112 | + return veh | |
| 113 | + | |
| 114 | + | |
| 115 | +# ============================================================================== | |
| 116 | +# 2. PowerGo — base commune (JSON inventaire + JSON-LD des pages détail) | |
| 117 | +# ============================================================================== | |
| 118 | + | |
| 119 | +# gabarit CDN de la 1re photo du listing : {imgWidth} → largeur désirée | |
| 120 | +_PG_IMG_WIDTH_RE = re.compile(r"\{imgWidth\}") | |
| 121 | + | |
| 122 | +MAX_IMAGES = 25 | |
| 123 | + | |
| 124 | + | |
| 125 | +def _pg_extract_ld_vehicle(html: str) -> dict: | |
| 126 | + """JSON-LD @type Vehicle/Car/Motorcycle d'une page détail PowerGo. | |
| 127 | + | |
| 128 | + Selon le gabarit, le nœud est au premier niveau, dans une liste, ou dans | |
| 129 | + 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 | + continue | |
| 136 | + stack = cand if isinstance(cand, list) else [cand] | |
| 137 | + for item in stack: | |
| 138 | + if not isinstance(item, dict): | |
| 139 | + continue | |
| 140 | + 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 | + continue | |
| 145 | + 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 node | |
| 149 | + return {} | |
| 150 | + | |
| 151 | + | |
| 152 | +class PowerGoMotoConnector(BaseConnector): | |
| 153 | + """Base commune aux sites PowerGo. Sous-classes : base_url + ville. | |
| 154 | + | |
| 155 | + fetch() : JSON inventaire complet → filtre usagé + motos/scooters → | |
| 156 | + enrichissement JSON-LD par page détail (cache BD, clé prix+km : seuls les | |
| 157 | + 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 émis | |
| 159 | + avec les champs (déjà riches) du listing JSON. | |
| 160 | + """ | |
| 161 | + | |
| 162 | + base_url: str = "" | |
| 163 | + dealer_name: str = "" | |
| 164 | + city: str = "" | |
| 165 | + request_delay: float = 1.0 | |
| 166 | + max_details = 400 | |
| 167 | + | |
| 168 | + # 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") | |
| 173 | + | |
| 174 | + 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 "")) | |
| 178 | + | |
| 179 | + # -- page détail -> JSON-LD -------------------------------------------------- | |
| 180 | + def _fetch_detail(self, url: str) -> dict: | |
| 181 | + return _pg_extract_ld_vehicle(self.get(url).text) | |
| 182 | + | |
| 183 | + # -- contrat ------------------------------------------------------------- | |
| 184 | + def fetch(self) -> list[Vehicle]: | |
| 185 | + items = self.get( | |
| 186 | + f"{self.base_url}/data/vehicles-inventory-fr-items.json").json() | |
| 187 | + if not isinstance(items, list): | |
| 188 | + return [] | |
| 189 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 190 | + | |
| 191 | + vehicles: list[Vehicle] = [] | |
| 192 | + real_fetches = 0 | |
| 193 | + for it in items: | |
| 194 | + if not isinstance(it, dict): | |
| 195 | + continue | |
| 196 | + if it.get("conditionCode") != "used" or it.get("isSold"): | |
| 197 | + continue | |
| 198 | + utype = str(it.get("unaccentedType") or "").lower().strip() | |
| 199 | + if not utype.startswith(self.include_types): | |
| 200 | + continue | |
| 201 | + page = (it.get("page") or {}).get("url") or "" | |
| 202 | + if not page: | |
| 203 | + continue | |
| 204 | + url = self.base_url + page | |
| 205 | + ext_id = str(it.get("vehicle_id") or it.get("stockNumber") or page) | |
| 206 | + # clé de cache = prix + km : revalidation automatique au changement | |
| 207 | + key = f"v1:{it.get('salePriceValue')}:{it.get('usageValue')}" | |
| 208 | + | |
| 209 | + ld: dict = {} | |
| 210 | + if real_fetches < cap: | |
| 211 | + before = self._last_request | |
| 212 | + try: | |
| 213 | + ld = self.detail(ext_id, key, | |
| 214 | + lambda u=url: self._fetch_detail(u)) | |
| 215 | + except Exception: | |
| 216 | + ld = {} # page détail disparue : listing seul | |
| 217 | + if self._last_request != before: | |
| 218 | + real_fetches += 1 | |
| 219 | + else: # plafond atteint : cache BD sinon listing seul | |
| 220 | + from .. import db | |
| 221 | + if self._detail_con is None: | |
| 222 | + self._detail_con = db.connect() | |
| 223 | + ld = db.get_cached_detail(self._detail_con, self.source_id, | |
| 224 | + ext_id, key) or {} | |
| 225 | + try: | |
| 226 | + veh = self._to_vehicle(ext_id, url, it, ld or {}) | |
| 227 | + except Exception: | |
| 228 | + continue | |
| 229 | + if veh is not None: | |
| 230 | + vehicles.append(veh) | |
| 231 | + return vehicles | |
| 232 | + | |
| 233 | + def _to_vehicle(self, ext_id: str, url: str, it: dict, | |
| 234 | + ld: dict) -> Vehicle | None: | |
| 235 | + title = _clean(it.get("label")) or _clean(ld.get("name")) | |
| 236 | + if not title: | |
| 237 | + return None | |
| 238 | + | |
| 239 | + price = it.get("salePriceValue") or it.get("basePriceValue") | |
| 240 | + if price in (None, "", 0, "0") and isinstance(ld.get("offers"), dict): | |
| 241 | + price = ld["offers"].get("price") | |
| 242 | + try: | |
| 243 | + price = float(price) if price not in (None, "", 0, "0") else None | |
| 244 | + except (TypeError, ValueError): | |
| 245 | + price = None | |
| 246 | + | |
| 247 | + # usage : PowerGo mélange km et heures (motocross) — km seulement si | |
| 248 | + # l'étiquette le confirme ; sinon repli JSON-LD (unitCode KMT) | |
| 249 | + km = None | |
| 250 | + usage_label = _clean(it.get("usageLabel")) | |
| 251 | + if it.get("usageValue") and "km" in usage_label.lower(): | |
| 252 | + try: | |
| 253 | + km = float(it["usageValue"]) | |
| 254 | + except (TypeError, ValueError): | |
| 255 | + km = None | |
| 256 | + if km is None: | |
| 257 | + odo = ld.get("mileageFromOdometer") | |
| 258 | + if isinstance(odo, dict) and odo.get("unitCode") in ("KMT", None): | |
| 259 | + try: | |
| 260 | + km = float(odo.get("value")) | |
| 261 | + except (TypeError, ValueError): | |
| 262 | + km = None | |
| 263 | + | |
| 264 | + year = it.get("year") | |
| 265 | + if not year and ld.get("vehicleModelDate"): | |
| 266 | + year = ld["vehicleModelDate"] | |
| 267 | + try: | |
| 268 | + year = int(str(year)[:4]) if year else None | |
| 269 | + except ValueError: | |
| 270 | + year = None | |
| 271 | + | |
| 272 | + make = _clean(it.get("make")) | |
| 273 | + if not make: | |
| 274 | + brand = ld.get("brand") or {} | |
| 275 | + make = _clean(brand.get("name") if isinstance(brand, dict) | |
| 276 | + else str(brand)) or _clean(ld.get("manufacturer")) | |
| 277 | + | |
| 278 | + images = ld.get("image") or [] | |
| 279 | + if isinstance(images, str): | |
| 280 | + images = [images] | |
| 281 | + images = [u for u in images if isinstance(u, str) and u.startswith("http")] | |
| 282 | + if not images: # repli : 1re photo du listing | |
| 283 | + img = it.get("image") or {} | |
| 284 | + src = str(img.get("source") or "") | |
| 285 | + dims = [d for d in (img.get("dimensions") or []) if isinstance(d, int)] | |
| 286 | + if src: | |
| 287 | + images = [_PG_IMG_WIDTH_RE.sub(str(max(dims) if dims else 1000), | |
| 288 | + src)] | |
| 289 | + | |
| 290 | + details = {k: v for k, v in (("type", _clean(it.get("type"))), | |
| 291 | + ("category", _clean(it.get("category")))) | |
| 292 | + if v} | |
| 293 | + | |
| 294 | + return Vehicle( | |
| 295 | + source=self.source_id, | |
| 296 | + external_id=ext_id, | |
| 297 | + url=url, | |
| 298 | + kind=self._kind(it), | |
| 299 | + title=title, | |
| 300 | + make=make, # explicite : marques moto absentes | |
| 301 | + model=_clean(it.get("model")) or _clean(ld.get("model")), | |
| 302 | + year=year, | |
| 303 | + price=price, | |
| 304 | + price_label=_clean(it.get("salePriceLabel") | |
| 305 | + or it.get("basePriceLabel")), | |
| 306 | + mileage_km=km, | |
| 307 | + mileage_label=usage_label, | |
| 308 | + exterior_color=_clean(ld.get("color")), | |
| 309 | + stock_number=_clean(it.get("stockNumber")) or _clean(ld.get("sku")), | |
| 310 | + dealer_name=self.dealer_name, | |
| 311 | + # groupes multi-succursales (Contant, Imperium…) : la succursale | |
| 312 | + # du véhicule est dans le listing JSON | |
| 313 | + city=_clean(it.get("location")) or self.city, | |
| 314 | + description=_clean(ld.get("description"))[:4000], | |
| 315 | + details=details, | |
| 316 | + images=images[:MAX_IMAGES], | |
| 317 | + ) | |
| 318 | + | |
| 319 | + | |
| 320 | +# ------------------------------------------------------------------------------ | |
| 321 | +# Concessionnaires PowerGo (1 sous-classe = 1 source) | |
| 322 | +# ------------------------------------------------------------------------------ | |
| 323 | + | |
| 324 | +class MathiasSports(PowerGoMotoConnector): | |
| 325 | + source_id = "mathiassports" | |
| 326 | + base_url = "https://mathiassports.com" | |
| 327 | + dealer_name = "Mathias Sports" | |
| 328 | + city = "Saint-Mathias-sur-Richelieu" | |
| 329 | + | |
| 330 | + | |
| 331 | +class SMSport(PowerGoMotoConnector): | |
| 332 | + source_id = "smsport" | |
| 333 | + base_url = "https://smsport.ca" | |
| 334 | + dealer_name = "SM Sport" | |
| 335 | + city = "Québec" | |
| 336 | + | |
| 337 | + | |
| 338 | +class MotosIllimitees(PowerGoMotoConnector): | |
| 339 | + source_id = "motosillimitees" | |
| 340 | + base_url = "https://www.motosillimitees.com" | |
| 341 | + dealer_name = "Motos Illimitées" | |
| 342 | + city = "Terrebonne" | |
| 343 | + | |
| 344 | + | |
| 345 | +class RMMotosport(PowerGoMotoConnector): | |
| 346 | + source_id = "rmmotosport" | |
| 347 | + base_url = "https://www.rmmotosport.com" | |
| 348 | + dealer_name = "RM Motosport" | |
| 349 | + city = "Victoriaville" | |
| 350 | + | |
| 351 | + | |
| 352 | +class GLSport(PowerGoMotoConnector): | |
| 353 | + source_id = "glsport" | |
| 354 | + base_url = "https://www.glsport.ca" | |
| 355 | + dealer_name = "GL Sport" | |
| 356 | + city = "Saint-Gervais" | |
| 357 | + | |
| 358 | + | |
| 359 | +class ExcelMoto(PowerGoMotoConnector): | |
| 360 | + source_id = "excelmoto" | |
| 361 | + base_url = "https://www.excelmoto.com" | |
| 362 | + dealer_name = "Excel Moto" | |
| 363 | + city = "Montréal" | |
| 364 | + | |
| 365 | + | |
| 366 | +class GroupeContant(PowerGoMotoConnector): | |
| 367 | + # groupe multi-succursales : Mirabel, Repentigny, Vaudreuil, Beloeil, | |
| 368 | + # Ste-Agathe — la ville de chaque unité vient du listing (location) | |
| 369 | + source_id = "groupecontant" | |
| 370 | + base_url = "https://www.contant.ca" | |
| 371 | + dealer_name = "Groupe Contant" | |
| 372 | + city = "Repentigny" | |
| 373 | + | |
| 374 | + | |
| 375 | +class GabrielHarleyDavidson(PowerGoMotoConnector): | |
| 376 | + source_id = "gabrielharleydavidson" | |
| 377 | + base_url = "https://gabrielharleydavidsonmtl.com" | |
| 378 | + dealer_name = "Gabriel Harley-Davidson Montréal" | |
| 379 | + city = "Montréal" | |
| 380 | + | |
| 381 | + | |
| 382 | +class StJeromeHarleyDavidson(PowerGoMotoConnector): | |
| 383 | + source_id = "stjeromeharleydavidson" | |
| 384 | + base_url = "https://stjeromeharleydavidson.com" | |
| 385 | + dealer_name = "St-Jérôme Harley-Davidson" | |
| 386 | + city = "Saint-Jérôme" | |
| 387 | + | |
| 388 | + | |
| 389 | +class Mecamoto(PowerGoMotoConnector): | |
| 390 | + # spécialiste Vespa / Piaggio / Moto Guzzi — la plupart des scooters | |
| 391 | + # usagés arrivent en catégorie « Cyclomoteurs » | |
| 392 | + source_id = "mecamoto" | |
| 393 | + base_url = "https://www.mecamoto.ca" | |
| 394 | + dealer_name = "Mecamoto" | |
| 395 | + city = "Montréal" | |
| 396 | + | |
| 397 | + | |
| 398 | +class MotosThibaultTR(PowerGoMotoConnector): | |
| 399 | + # concessionnaire Vespa / Aprilia / Suzuki de la Mauricie | |
| 400 | + source_id = "motosthibaulttr" | |
| 401 | + base_url = "https://www.motosthibault.ca" | |
| 402 | + dealer_name = "Motos Thibault Trois-Rivières" | |
| 403 | + city = "Trois-Rivières" | |
| 404 | + | |
| 405 | + | |
| 406 | +class GroupeImperium(PowerGoMotoConnector): | |
| 407 | + # groupe multi-succursales : Chicoutimi, Dolbeau-Mistassini, Chibougamau, | |
| 408 | + # Rouyn-Noranda — succursale par véhicule via location | |
| 409 | + source_id = "groupeimperium" | |
| 410 | + base_url = "https://www.groupeimperium.ca" | |
| 411 | + dealer_name = "Groupe Imperium" | |
| 412 | + city = "Chicoutimi" | |
| 413 | + | |
| 414 | + | |
| 415 | +# ============================================================================== | |
| 416 | +# 3. Moto Ducharme (Joliette) — Magento (thème Traction) | |
| 417 | +# Listing par catégorie server-rendered ; détail : JSON-LD @type Product + | |
| 418 | +# table de spécifications (Kilométrage, Couleur…) + galerie JSON Magento. | |
| 419 | +# ============================================================================== | |
| 420 | + | |
| 421 | +_MD_DETAIL_RE = re.compile( | |
| 422 | + r'href="(https://www\.motoducharme\.com/fr/vehicules-d-occasion/' | |
| 423 | + r'motocyclettes/[a-z0-9-]+-cs-(\d+))"') | |
| 424 | +_MD_SPEC_RE = re.compile( | |
| 425 | + r'<th class="col label"[^>]*>\s*([^<]+?)\s*</th>\s*' | |
| 426 | + r'<td class="col data"[^>]*>\s*([^<]+?)\s*</td>', re.S) | |
| 427 | +_MD_GALLERY_RE = re.compile(r'"data":\s*(\[\{"thumb".*?\}\])') | |
| 428 | +_MD_YEAR_RE = re.compile(r"\b(19[89]\d|20[0-2]\d)\b") | |
| 429 | + | |
| 430 | + | |
| 431 | +class MotoDucharme(BaseConnector): | |
| 432 | + source_id = "motoducharme" | |
| 433 | + base_url = "https://www.motoducharme.com" | |
| 434 | + listing_url = "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes" | |
| 435 | + dealer_name = "Moto Ducharme" | |
| 436 | + city = "Joliette" | |
| 437 | + request_delay = 1.0 | |
| 438 | + max_details = 150 | |
| 439 | + max_pages = 5 # ~15 motos affichées, marge pour grandir | |
| 440 | + | |
| 441 | + def _listing_urls(self) -> list[tuple[str, str]]: | |
| 442 | + """[(url, ext_id)] des motos usagées, pages ?p=N de Magento.""" | |
| 443 | + seen: dict[str, str] = {} | |
| 444 | + max_pages = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) | |
| 445 | + for p in range(1, max_pages + 1): | |
| 446 | + url = self.listing_url if p == 1 else f"{self.listing_url}?p={p}" | |
| 447 | + try: | |
| 448 | + html = self.get(url).text | |
| 449 | + except Exception: | |
| 450 | + break | |
| 451 | + before = len(seen) | |
| 452 | + for detail_url, ext_id in _MD_DETAIL_RE.findall(html): | |
| 453 | + seen.setdefault(ext_id, detail_url) | |
| 454 | + if len(seen) == before: # plus rien de neuf : fin | |
| 455 | + break | |
| 456 | + return [(u, i) for i, u in seen.items()] | |
| 457 | + | |
| 458 | + def _fetch_detail(self, url: str) -> dict: | |
| 459 | + html = self.get(url).text | |
| 460 | + data: dict = {} | |
| 461 | + for block in _LD_RE.findall(html): | |
| 462 | + try: | |
| 463 | + cand = json.loads(block.strip()) | |
| 464 | + except ValueError: | |
| 465 | + continue | |
| 466 | + if isinstance(cand, dict) and cand.get("@type") == "Product": | |
| 467 | + data = cand | |
| 468 | + break | |
| 469 | + if not data: | |
| 470 | + return {} | |
| 471 | + specs = {_clean(k): _clean(v) for k, v in _MD_SPEC_RE.findall(html)} | |
| 472 | + if specs: | |
| 473 | + data["_specs"] = specs | |
| 474 | + m = _MD_GALLERY_RE.search(html) | |
| 475 | + if m: | |
| 476 | + try: | |
| 477 | + gallery = json.loads(m.group(1)) | |
| 478 | + data["_gallery"] = [g.get("full") or g.get("img") | |
| 479 | + for g in gallery | |
| 480 | + if isinstance(g, dict) | |
| 481 | + and (g.get("full") or g.get("img"))] | |
| 482 | + except ValueError: | |
| 483 | + pass | |
| 484 | + return data | |
| 485 | + | |
| 486 | + def fetch(self) -> list[Vehicle]: | |
| 487 | + week = datetime.date.today().isocalendar() | |
| 488 | + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdo | |
| 489 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 490 | + | |
| 491 | + vehicles: list[Vehicle] = [] | |
| 492 | + real_fetches = 0 | |
| 493 | + for url, ext_id in self._listing_urls(): | |
| 494 | + if real_fetches >= cap: | |
| 495 | + from .. import db | |
| 496 | + if self._detail_con is None: | |
| 497 | + self._detail_con = db.connect() | |
| 498 | + data = db.get_cached_detail(self._detail_con, self.source_id, | |
| 499 | + ext_id, cache_key) | |
| 500 | + if data is None: | |
| 501 | + continue | |
| 502 | + else: | |
| 503 | + before = self._last_request | |
| 504 | + try: | |
| 505 | + data = self.detail(ext_id, cache_key, | |
| 506 | + lambda u=url: self._fetch_detail(u)) | |
| 507 | + except Exception: | |
| 508 | + if self._last_request != before: | |
| 509 | + real_fetches += 1 | |
| 510 | + continue | |
| 511 | + if self._last_request != before: | |
| 512 | + real_fetches += 1 | |
| 513 | + if not data: | |
| 514 | + continue | |
| 515 | + try: | |
| 516 | + veh = self._to_vehicle(ext_id, url, data) | |
| 517 | + except Exception: | |
| 518 | + continue | |
| 519 | + if veh is not None: | |
| 520 | + vehicles.append(veh) | |
| 521 | + return vehicles | |
| 522 | + | |
| 523 | + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: | |
| 524 | + title = _clean(d.get("name")) | |
| 525 | + if not title: | |
| 526 | + return None | |
| 527 | + specs = d.get("_specs") or {} | |
| 528 | + | |
| 529 | + offers = d.get("offers") or {} | |
| 530 | + try: | |
| 531 | + price = float(offers.get("price")) | |
| 532 | + if price <= 0: | |
| 533 | + price = None | |
| 534 | + except (TypeError, ValueError): | |
| 535 | + price = None | |
| 536 | + | |
| 537 | + km = None | |
| 538 | + for label, value in specs.items(): | |
| 539 | + if "kilom" in label.lower(): | |
| 540 | + digits = re.sub(r"[^\d]", "", value) | |
| 541 | + if digits: | |
| 542 | + km = float(digits) | |
| 543 | + break | |
| 544 | + | |
| 545 | + years = _MD_YEAR_RE.findall(title) | |
| 546 | + year = int(years[-1]) if years else None | |
| 547 | + | |
| 548 | + brand = d.get("brand") or {} | |
| 549 | + make = _clean(brand.get("name") if isinstance(brand, dict) | |
| 550 | + else str(brand)) | |
| 551 | + if make.isupper(): # « KAWASAKI » → « Kawasaki » | |
| 552 | + make = make.title() | |
| 553 | + # modèle = titre sans la marque (préfixe, parfois doublée) ni l'année | |
| 554 | + model = title | |
| 555 | + if make: | |
| 556 | + model = re.sub(rf"^(?:{re.escape(make)}\s+)+", "", model, | |
| 557 | + flags=re.I) | |
| 558 | + model = _MD_YEAR_RE.sub("", model).strip(" -") | |
| 559 | + | |
| 560 | + images = d.get("_gallery") or [] | |
| 561 | + if not images and d.get("image"): | |
| 562 | + images = [d["image"]] if isinstance(d["image"], str) else d["image"] | |
| 563 | + | |
| 564 | + color = next((v for k, v in specs.items() | |
| 565 | + if "couleur" in k.lower()), "") | |
| 566 | + | |
| 567 | + return Vehicle( | |
| 568 | + source=self.source_id, | |
| 569 | + external_id=ext_id, | |
| 570 | + url=url, | |
| 571 | + kind=moto_kind(url, title), | |
| 572 | + title=title, | |
| 573 | + make=make, | |
| 574 | + model=" ".join(model.split()), | |
| 575 | + year=year, | |
| 576 | + price=price, | |
| 577 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 578 | + mileage_km=km, | |
| 579 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 580 | + exterior_color=color, | |
| 581 | + stock_number=str(d.get("sku") or ""), | |
| 582 | + dealer_name=self.dealer_name, | |
| 583 | + city=self.city, | |
| 584 | + description=_clean(d.get("description"))[:4000], | |
| 585 | + details={k: v for k, v in specs.items() | |
| 586 | + if v and v.upper() not in ("N.D.", "N/A")}, | |
| 587 | + images=[u for u in images if isinstance(u, str)][:MAX_IMAGES], | |
| 588 | + ) | |
modified
autoka/db.py
+35 −16
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS vehicles ( | ||
| 34 | 34 | source TEXT NOT NULL, |
| 35 | 35 | external_id TEXT NOT NULL, |
| 36 | 36 | url TEXT, |
| 37 | + kind TEXT DEFAULT 'auto', | |
| 37 | 38 | title TEXT, |
| 38 | 39 | make TEXT, |
| 39 | 40 | model TEXT, |
@@ -106,6 +107,14 @@ CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid); | ||
| 106 | 107 | """ |
| 107 | 108 | |
| 108 | 109 | |
| 110 | +# Colonnes ajoutées après la v1 — migration automatique des bases existantes. | |
| 111 | +_MIGRATIONS = { | |
| 112 | + "vehicles": { | |
| 113 | + "kind": "TEXT DEFAULT 'auto'", | |
| 114 | + }, | |
| 115 | +} | |
| 116 | + | |
| 117 | + | |
| 109 | 118 | def connect() -> sqlite3.Connection: |
| 110 | 119 | DB_PATH.parent.mkdir(parents=True, exist_ok=True) |
| 111 | 120 | con = sqlite3.connect(DB_PATH, timeout=30) |
@@ -114,6 +123,13 @@ def connect() -> sqlite3.Connection: | ||
| 114 | 123 | con.execute("PRAGMA journal_mode=WAL") |
| 115 | 124 | con.execute("PRAGMA busy_timeout=15000") |
| 116 | 125 | con.executescript(_SCHEMA) |
| 126 | + for table, cols in _MIGRATIONS.items(): | |
| 127 | + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} | |
| 128 | + for col, decl in cols.items(): | |
| 129 | + if col not in existing: | |
| 130 | + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") | |
| 131 | + # index sur des colonnes issues de migrations : après l'ALTER TABLE | |
| 132 | + con.execute("CREATE INDEX IF NOT EXISTS idx_vehicles_kind ON vehicles(kind)") | |
| 117 | 133 | con.commit() |
| 118 | 134 | return con |
| 119 | 135 | |
@@ -176,7 +192,8 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 176 | 192 | (veh.uid,)).fetchone() |
| 177 | 193 | params = dict( |
| 178 | 194 | uid=veh.uid, source=veh.source, external_id=veh.external_id, |
| 179 | − url=veh.url, title=veh.title, make=veh.make, model=veh.model, | |
| 195 | + url=veh.url, kind=veh.kind or "auto", | |
| 196 | + title=veh.title, make=veh.make, model=veh.model, | |
| 180 | 197 | trim=veh.trim, year=veh.year, price=veh.price, |
| 181 | 198 | price_label=veh.price_label, mileage_km=veh.mileage_km, |
| 182 | 199 | mileage_label=veh.mileage_label, transmission=veh.transmission, |
@@ -192,19 +209,21 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 192 | 209 | ) |
| 193 | 210 | if row is None: |
| 194 | 211 | con.execute( |
| 195 | − """INSERT INTO vehicles (uid, source, external_id, url, title, | |
| 196 | − make, model, trim, year, price, price_label, mileage_km, | |
| 197 | − mileage_label, transmission, fuel, drivetrain, body_type, | |
| 198 | − exterior_color, interior_color, engine, doors, seats, vin, | |
| 199 | − stock_number, dealer_name, city, region, description, | |
| 200 | − features, details, images, carfax_url, content_hash, | |
| 201 | − first_seen, last_seen, updated_at, miss_count, active) | |
| 202 | − VALUES (:uid,:source,:external_id,:url,:title,:make,:model, | |
| 203 | − :trim,:year,:price,:price_label,:mileage_km,:mileage_label, | |
| 204 | − :transmission,:fuel,:drivetrain,:body_type,:exterior_color, | |
| 205 | − :interior_color,:engine,:doors,:seats,:vin,:stock_number, | |
| 206 | − :dealer_name,:city,:region,:description,:features,:details, | |
| 207 | − :images,:carfax_url,:content_hash,:now,:now,:now,0,1)""", | |
| 212 | + """INSERT INTO vehicles (uid, source, external_id, url, kind, | |
| 213 | + title, make, model, trim, year, price, price_label, | |
| 214 | + mileage_km, mileage_label, transmission, fuel, drivetrain, | |
| 215 | + body_type, exterior_color, interior_color, engine, doors, | |
| 216 | + seats, vin, stock_number, dealer_name, city, region, | |
| 217 | + description, features, details, images, carfax_url, | |
| 218 | + content_hash, first_seen, last_seen, updated_at, | |
| 219 | + miss_count, active) | |
| 220 | + VALUES (:uid,:source,:external_id,:url,:kind,:title,:make, | |
| 221 | + :model,:trim,:year,:price,:price_label,:mileage_km, | |
| 222 | + :mileage_label,:transmission,:fuel,:drivetrain,:body_type, | |
| 223 | + :exterior_color,:interior_color,:engine,:doors,:seats,:vin, | |
| 224 | + :stock_number,:dealer_name,:city,:region,:description, | |
| 225 | + :features,:details,:images,:carfax_url,:content_hash, | |
| 226 | + :now,:now,:now,0,1)""", | |
| 208 | 227 | params) |
| 209 | 228 | if veh.price is not None: # prix initial = départ de l'historique |
| 210 | 229 | con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", |
@@ -212,8 +231,8 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 212 | 231 | added += 1 |
| 213 | 232 | elif row["content_hash"] != h: |
| 214 | 233 | con.execute( |
| 215 | − """UPDATE vehicles SET url=:url, title=:title, make=:make, | |
| 216 | − model=:model, trim=:trim, year=:year, price=:price, | |
| 234 | + """UPDATE vehicles SET url=:url, kind=:kind, title=:title, | |
| 235 | + make=:make, model=:model, trim=:trim, year=:year, price=:price, | |
| 217 | 236 | price_label=:price_label, mileage_km=:mileage_km, |
| 218 | 237 | mileage_label=:mileage_label, transmission=:transmission, |
| 219 | 238 | fuel=:fuel, drivetrain=:drivetrain, body_type=:body_type, |
modified
autoka/marketstats.py
+12 −12
@@ -22,9 +22,9 @@ def compute() -> dict: | ||
| 22 | 22 | now = time.time() |
| 23 | 23 | |
| 24 | 24 | prices = [r["price"] for r in con.execute( |
| 25 | − "SELECT price FROM vehicles WHERE active=1 AND price IS NOT NULL")] | |
| 25 | + "SELECT price FROM vehicles WHERE active=1 AND kind='auto' AND price IS NOT NULL")] | |
| 26 | 26 | kms = [r["mileage_km"] for r in con.execute( |
| 27 | − "SELECT mileage_km FROM vehicles WHERE active=1 AND mileage_km IS NOT NULL")] | |
| 27 | + "SELECT mileage_km FROM vehicles WHERE active=1 AND kind='auto' AND mileage_km IS NOT NULL")] | |
| 28 | 28 | |
| 29 | 29 | head = con.execute( |
| 30 | 30 | """SELECT COUNT(*) total, COUNT(DISTINCT source) sources, |
@@ -33,7 +33,7 @@ def compute() -> dict: | ||
| 33 | 33 | SUM(CASE WHEN first_seen > ? THEN 1 ELSE 0 END) new_7d, |
| 34 | 34 | SUM(CASE WHEN fuel IN ('Électrique') THEN 1 ELSE 0 END) ev, |
| 35 | 35 | SUM(CASE WHEN fuel LIKE 'Hybride%' THEN 1 ELSE 0 END) hybrid |
| 36 | − FROM vehicles WHERE active=1""", (now - 7 * 86400,)).fetchone() | |
| 36 | + FROM vehicles WHERE active=1 AND kind='auto'""", (now - 7 * 86400,)).fetchone() | |
| 37 | 37 | |
| 38 | 38 | # histogramme des prix |
| 39 | 39 | price_hist = [] |
@@ -55,7 +55,7 @@ def compute() -> dict: | ||
| 55 | 55 | |
| 56 | 56 | # répartition par année (2010+ ; avant regroupé) |
| 57 | 57 | year_rows = con.execute( |
| 58 | − "SELECT year, COUNT(*) n FROM vehicles WHERE active=1" | |
| 58 | + "SELECT year, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 59 | 59 | " AND year IS NOT NULL GROUP BY year ORDER BY year").fetchall() |
| 60 | 60 | year_hist, older = [], 0 |
| 61 | 61 | for r in year_rows: |
@@ -88,30 +88,30 @@ def compute() -> dict: | ||
| 88 | 88 | "year_hist": year_hist, |
| 89 | 89 | "by_make": _rows( |
| 90 | 90 | "SELECT make label, COUNT(*) n, ROUND(AVG(price)) avg_price" |
| 91 | − " FROM vehicles WHERE active=1 AND make<>''" | |
| 91 | + " FROM vehicles WHERE active=1 AND kind='auto' AND make<>''" | |
| 92 | 92 | " GROUP BY make ORDER BY n DESC LIMIT 14"), |
| 93 | 93 | "by_region": _rows( |
| 94 | 94 | "SELECT region label, COUNT(*) n, ROUND(AVG(price)) avg_price" |
| 95 | − " FROM vehicles WHERE active=1 AND region<>''" | |
| 95 | + " FROM vehicles WHERE active=1 AND kind='auto' AND region<>''" | |
| 96 | 96 | " GROUP BY region ORDER BY n DESC"), |
| 97 | 97 | "by_body": _rows( |
| 98 | − "SELECT body_type label, COUNT(*) n FROM vehicles WHERE active=1" | |
| 98 | + "SELECT body_type label, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 99 | 99 | " AND body_type<>'' GROUP BY body_type ORDER BY n DESC"), |
| 100 | 100 | "by_fuel": _rows( |
| 101 | − "SELECT fuel label, COUNT(*) n FROM vehicles WHERE active=1" | |
| 101 | + "SELECT fuel label, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 102 | 102 | " AND fuel<>'' GROUP BY fuel ORDER BY n DESC"), |
| 103 | 103 | "top_models": _rows( |
| 104 | 104 | "SELECT make || ' ' || model label, COUNT(*) n," |
| 105 | 105 | " ROUND(AVG(price)) avg_price, ROUND(AVG(mileage_km)) avg_km" |
| 106 | − " FROM vehicles WHERE active=1 AND make<>'' AND model<>''" | |
| 106 | + " FROM vehicles WHERE active=1 AND kind='auto' AND make<>'' AND model<>''" | |
| 107 | 107 | " GROUP BY make, model ORDER BY n DESC LIMIT 15"), |
| 108 | 108 | "top_dealers": _rows( |
| 109 | 109 | "SELECT dealer_name label, COUNT(*) n, ROUND(AVG(price)) avg_price" |
| 110 | − " FROM vehicles WHERE active=1 AND dealer_name<>''" | |
| 110 | + " FROM vehicles WHERE active=1 AND kind='auto' AND dealer_name<>''" | |
| 111 | 111 | " GROUP BY dealer_name ORDER BY n DESC LIMIT 12"), |
| 112 | 112 | "avg_price_by_year": _rows( |
| 113 | 113 | "SELECT year label, ROUND(AVG(price)) n FROM vehicles" |
| 114 | − " WHERE active=1 AND year>=2012 AND price IS NOT NULL" | |
| 114 | + " WHERE active=1 AND kind='auto' AND year>=2012 AND price IS NOT NULL" | |
| 115 | 115 | " GROUP BY year ORDER BY year"), |
| 116 | 116 | "price_drops": _rows( |
| 117 | 117 | """SELECT v.uid, v.title, v.year, v.price, v.dealer_name, v.city, |
@@ -120,7 +120,7 @@ def compute() -> dict: | ||
| 120 | 120 | SELECT uid, price prev_price, |
| 121 | 121 | ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn |
| 122 | 122 | FROM price_log) p ON p.uid=v.uid AND p.rn=2 |
| 123 | − WHERE v.active=1 AND v.price IS NOT NULL | |
| 123 | + WHERE v.active=1 AND kind='auto' AND v.price IS NOT NULL | |
| 124 | 124 | AND p.prev_price > v.price |
| 125 | 125 | ORDER BY (p.prev_price - v.price) DESC LIMIT 12"""), |
| 126 | 126 | } |
modified
autoka/normalize.py
+15 −0
@@ -111,6 +111,21 @@ _MAKE_LOOKUP = {strip_accents(m).lower().replace(" ", "").replace("-", ""): m | ||
| 111 | 111 | for m in MAKES} |
| 112 | 112 | _MAKE_LOOKUP.update(_MAKE_ALIASES) |
| 113 | 113 | |
| 114 | +# marques MOTO/SCOOTER (verticale moto — connectors/moto_dealers.py) : | |
| 115 | +# ajoutées au lookup de normalize_make SEULEMENT (pas à MAKES, pour ne pas | |
| 116 | +# influencer split_title côté autos). Sans elles, le repli .title() casserait | |
| 117 | +# les sigles : « KTM » → « Ktm », « CFMOTO » → « Cfmoto », etc. | |
| 118 | +_MOTO_MAKE_LOOKUP = { | |
| 119 | + "harleydavidson": "Harley-Davidson", "harley": "Harley-Davidson", | |
| 120 | + "indianmotorcycle": "Indian Motorcycle", "indian": "Indian Motorcycle", | |
| 121 | + "ktm": "KTM", "brp": "BRP", "canam": "Can-Am", | |
| 122 | + "cfmoto": "CFMOTO", "gasgas": "GASGAS", "kymco": "KYMCO", | |
| 123 | + "mvagusta": "MV Agusta", "motoguzzi": "Moto Guzzi", | |
| 124 | + "royalenfield": "Royal Enfield", "victorymotorcycles": "Victory", | |
| 125 | + "victory": "Victory", "ssr": "SSR", "surron": "Sur-Ron", | |
| 126 | +} | |
| 127 | +_MAKE_LOOKUP.update(_MOTO_MAKE_LOOKUP) | |
| 128 | + | |
| 114 | 129 | |
| 115 | 130 | def normalize_make(raw: str | None) -> str: |
| 116 | 131 | if not raw: |
modified
autoka/schema.py
+1 −0
@@ -46,6 +46,7 @@ class Vehicle: | ||
| 46 | 46 | source: str # id de la source (voir data/sources.json) |
| 47 | 47 | external_id: str # identifiant chez la source (stock, VIN, slug) |
| 48 | 48 | url: str # page de l'annonce chez le concessionnaire |
| 49 | + kind: str = "auto" # verticale : auto | moto | scooter | |
| 49 | 50 | title: str = "" # ex. "Toyota RAV4 XLE 2019" |
| 50 | 51 | make: str = "" # Toyota, Honda, ... |
| 51 | 52 | model: str = "" # RAV4, Civic, ... |
modified
autoka/web.py
+17 −13
@@ -89,6 +89,7 @@ _SORTS = { | ||
| 89 | 89 | |
| 90 | 90 | @app.get("/api/vehicles") |
| 91 | 91 | def list_vehicles( |
| 92 | + kind: str = "auto", | |
| 92 | 93 | make: str | None = None, |
| 93 | 94 | model: str | None = None, |
| 94 | 95 | body_type: str | None = None, |
@@ -114,6 +115,8 @@ def list_vehicles( | ||
| 114 | 115 | args: list = [] |
| 115 | 116 | if active in (0, 1): |
| 116 | 117 | sql += " AND active=?"; args.append(active) |
| 118 | + if kind and kind != "tous": | |
| 119 | + sql += " AND kind=?"; args.append(kind) | |
| 117 | 120 | sql = _apply_filters(sql, args, make=make, model=model, body_type=body_type, |
| 118 | 121 | fuel=fuel, transmission=transmission, |
| 119 | 122 | drivetrain=drivetrain, region=region, city=city, |
@@ -154,40 +157,41 @@ def get_vehicle(uid: str): | ||
| 154 | 157 | |
| 155 | 158 | |
| 156 | 159 | @app.get("/api/facets") |
| 157 | −def facets(make: str | None = None): | |
| 160 | +def facets(make: str | None = None, kind: str = "auto"): | |
| 158 | 161 | """Valeurs distinctes pour construire les filtres du frontend. |
| 159 | 162 | |
| 160 | 163 | `make` (optionnel) restreint la liste des modèles à cette marque — |
| 161 | 164 | utilisé par le sélecteur « Modèle » dépendant de « Marque ». |
| 162 | 165 | """ |
| 163 | 166 | con = db.connect() |
| 167 | + kf = "" if kind in ("", "tous") else f" AND kind='{'moto' if kind=='moto' else 'scooter' if kind=='scooter' else 'auto'}'" | |
| 164 | 168 | model_sql = ("SELECT model, COUNT(*) n FROM vehicles" |
| 165 | − " WHERE active=1 AND model<>''") | |
| 169 | + " WHERE active=1 AND model<>''" + kf) | |
| 166 | 170 | model_args: list = [] |
| 167 | 171 | if make: |
| 168 | 172 | model_sql += " AND make=?" |
| 169 | 173 | model_args.append(make) |
| 170 | 174 | out = { |
| 171 | 175 | "makes": [dict(r) for r in con.execute( |
| 172 | − "SELECT make, COUNT(*) n FROM vehicles WHERE active=1 AND make<>''" | |
| 176 | + "SELECT make, COUNT(*) n FROM vehicles WHERE active=1" + kf + " AND make<>''" | |
| 173 | 177 | " GROUP BY make ORDER BY n DESC")], |
| 174 | 178 | "models": [dict(r) for r in con.execute( |
| 175 | 179 | model_sql + " GROUP BY model ORDER BY n DESC LIMIT 80", model_args)], |
| 176 | 180 | "body_types": [r["body_type"] for r in con.execute( |
| 177 | − "SELECT DISTINCT body_type FROM vehicles WHERE active=1" | |
| 181 | + "SELECT DISTINCT body_type FROM vehicles WHERE active=1" + kf + "" | |
| 178 | 182 | " AND body_type<>'' ORDER BY body_type")], |
| 179 | 183 | "fuels": [r["fuel"] for r in con.execute( |
| 180 | − "SELECT DISTINCT fuel FROM vehicles WHERE active=1 AND fuel<>''" | |
| 184 | + "SELECT DISTINCT fuel FROM vehicles WHERE active=1" + kf + " AND fuel<>''" | |
| 181 | 185 | " ORDER BY fuel")], |
| 182 | 186 | "regions": [dict(r) for r in con.execute( |
| 183 | − "SELECT region, COUNT(*) n FROM vehicles WHERE active=1 AND region<>''" | |
| 187 | + "SELECT region, COUNT(*) n FROM vehicles WHERE active=1" + kf + " AND region<>''" | |
| 184 | 188 | " GROUP BY region ORDER BY n DESC")], |
| 185 | 189 | "sources": [dict(r) for r in con.execute( |
| 186 | − "SELECT source, dealer_name, COUNT(*) n FROM vehicles WHERE active=1" | |
| 190 | + "SELECT source, dealer_name, COUNT(*) n FROM vehicles WHERE active=1" + kf + "" | |
| 187 | 191 | " GROUP BY source ORDER BY n DESC")], |
| 188 | 192 | "years": [dict(r) for r in con.execute( |
| 189 | 193 | "SELECT MIN(year) y_min, MAX(year) y_max FROM vehicles" |
| 190 | − " WHERE active=1 AND year IS NOT NULL")], | |
| 194 | + " WHERE active=1" + kf + " AND year IS NOT NULL")], | |
| 191 | 195 | } |
| 192 | 196 | con.close() |
| 193 | 197 | return out |
@@ -218,15 +222,15 @@ def stats(): | ||
| 218 | 222 | AVG(price) avg_price, |
| 219 | 223 | AVG(mileage_km) avg_km, |
| 220 | 224 | AVG(year) avg_year |
| 221 | − FROM vehicles WHERE active=1""").fetchone() | |
| 225 | + FROM vehicles WHERE active=1 AND kind='auto'""").fetchone() | |
| 222 | 226 | by_region = [dict(r) for r in con.execute( |
| 223 | 227 | "SELECT region, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles" |
| 224 | − " WHERE active=1 AND region<>'' GROUP BY region ORDER BY n DESC")] | |
| 228 | + " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region ORDER BY n DESC")] | |
| 225 | 229 | by_make = [dict(r) for r in con.execute( |
| 226 | 230 | "SELECT make, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles" |
| 227 | − " WHERE active=1 AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")] | |
| 231 | + " WHERE active=1 AND kind='auto' AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")] | |
| 228 | 232 | by_body = [dict(r) for r in con.execute( |
| 229 | − "SELECT body_type, COUNT(*) n FROM vehicles WHERE active=1" | |
| 233 | + "SELECT body_type, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 230 | 234 | " AND body_type<>'' GROUP BY body_type ORDER BY n DESC")] |
| 231 | 235 | # baisses de prix récentes (signal d'aubaine) |
| 232 | 236 | drops = [dict(r) for r in con.execute( |
@@ -236,7 +240,7 @@ def stats(): | ||
| 236 | 240 | SELECT uid, price prev_price, |
| 237 | 241 | ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn |
| 238 | 242 | FROM price_log) p ON p.uid=v.uid AND p.rn=2 |
| 239 | − WHERE v.active=1 AND v.price IS NOT NULL AND p.prev_price > v.price | |
| 243 | + WHERE v.active=1 AND kind='auto' AND v.price IS NOT NULL AND p.prev_price > v.price | |
| 240 | 244 | ORDER BY (p.prev_price - v.price) DESC LIMIT 12""")] |
| 241 | 245 | for d in drops: |
| 242 | 246 | d["images"] = json.loads(d.get("images") or "[]")[:1] |
modified
data/sources.json
+156 −0
@@ -1375,6 +1375,162 @@ | ||
| 1375 | 1375 | "platform": "OctoberCMS thème nerd2 / NerdAuto (cartes liste rendues serveur + bloc Autoverify data-av-* ; pages détail pour specs/carfax)", |
| 1376 | 1376 | "connector": "occasioncharlevoix", |
| 1377 | 1377 | "status": "actif" |
| 1378 | + }, | |
| 1379 | + { | |
| 1380 | + "id": "mathiassports", | |
| 1381 | + "name": "Mathias Sports", | |
| 1382 | + "url": "https://mathiassports.com", | |
| 1383 | + "listing_url": "https://mathiassports.com/fr/usage/motocyclette/", | |
| 1384 | + "city": "Saint-Mathias-sur-Richelieu", | |
| 1385 | + "region": "Montérégie", | |
| 1386 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1387 | + "connector": "moto_dealers", | |
| 1388 | + "vertical": "moto", | |
| 1389 | + "status": "actif" | |
| 1390 | + }, | |
| 1391 | + { | |
| 1392 | + "id": "smsport", | |
| 1393 | + "name": "SM Sport", | |
| 1394 | + "url": "https://smsport.ca", | |
| 1395 | + "listing_url": "https://smsport.ca/fr/usage/motocyclette/", | |
| 1396 | + "city": "Québec", | |
| 1397 | + "region": "Capitale-Nationale", | |
| 1398 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1399 | + "connector": "moto_dealers", | |
| 1400 | + "vertical": "moto", | |
| 1401 | + "status": "actif" | |
| 1402 | + }, | |
| 1403 | + { | |
| 1404 | + "id": "motosillimitees", | |
| 1405 | + "name": "Motos Illimitées", | |
| 1406 | + "url": "https://www.motosillimitees.com", | |
| 1407 | + "listing_url": "https://www.motosillimitees.com/fr/usage/motocyclette/", | |
| 1408 | + "city": "Terrebonne", | |
| 1409 | + "region": "Lanaudière", | |
| 1410 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1411 | + "connector": "moto_dealers", | |
| 1412 | + "vertical": "moto", | |
| 1413 | + "status": "actif" | |
| 1414 | + }, | |
| 1415 | + { | |
| 1416 | + "id": "rmmotosport", | |
| 1417 | + "name": "RM Motosport", | |
| 1418 | + "url": "https://www.rmmotosport.com", | |
| 1419 | + "listing_url": "https://www.rmmotosport.com/fr/usage/", | |
| 1420 | + "city": "Victoriaville", | |
| 1421 | + "region": "Centre-du-Québec", | |
| 1422 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1423 | + "connector": "moto_dealers", | |
| 1424 | + "vertical": "moto", | |
| 1425 | + "status": "actif" | |
| 1426 | + }, | |
| 1427 | + { | |
| 1428 | + "id": "glsport", | |
| 1429 | + "name": "GL Sport", | |
| 1430 | + "url": "https://www.glsport.ca", | |
| 1431 | + "listing_url": "https://www.glsport.ca/fr/usage/", | |
| 1432 | + "city": "Saint-Gervais", | |
| 1433 | + "region": "Chaudière-Appalaches", | |
| 1434 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1435 | + "connector": "moto_dealers", | |
| 1436 | + "vertical": "moto", | |
| 1437 | + "status": "actif" | |
| 1438 | + }, | |
| 1439 | + { | |
| 1440 | + "id": "excelmoto", | |
| 1441 | + "name": "Excel Moto", | |
| 1442 | + "url": "https://www.excelmoto.com", | |
| 1443 | + "listing_url": "https://www.excelmoto.com/fr/usage/", | |
| 1444 | + "city": "Montréal", | |
| 1445 | + "region": "Montréal", | |
| 1446 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1447 | + "connector": "moto_dealers", | |
| 1448 | + "vertical": "moto", | |
| 1449 | + "status": "actif" | |
| 1450 | + }, | |
| 1451 | + { | |
| 1452 | + "id": "groupecontant", | |
| 1453 | + "name": "Groupe Contant", | |
| 1454 | + "url": "https://www.contant.ca", | |
| 1455 | + "listing_url": "https://www.contant.ca/fr/usage/", | |
| 1456 | + "city": "Repentigny", | |
| 1457 | + "region": "Lanaudière", | |
| 1458 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; multi-succursales (Mirabel, Repentigny, Vaudreuil, Beloeil, Ste-Agathe) — ville par véhicule via location", | |
| 1459 | + "connector": "moto_dealers", | |
| 1460 | + "vertical": "moto", | |
| 1461 | + "status": "actif" | |
| 1462 | + }, | |
| 1463 | + { | |
| 1464 | + "id": "gabrielharleydavidson", | |
| 1465 | + "name": "Gabriel Harley-Davidson Montréal", | |
| 1466 | + "url": "https://gabrielharleydavidsonmtl.com", | |
| 1467 | + "listing_url": "https://gabrielharleydavidsonmtl.com/fr/usage/motocyclette/", | |
| 1468 | + "city": "Montréal", | |
| 1469 | + "region": "Montréal", | |
| 1470 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; JSON-LD dans un @graph", | |
| 1471 | + "connector": "moto_dealers", | |
| 1472 | + "vertical": "moto", | |
| 1473 | + "status": "actif" | |
| 1474 | + }, | |
| 1475 | + { | |
| 1476 | + "id": "stjeromeharleydavidson", | |
| 1477 | + "name": "St-Jérôme Harley-Davidson", | |
| 1478 | + "url": "https://stjeromeharleydavidson.com", | |
| 1479 | + "listing_url": "https://stjeromeharleydavidson.com/fr/usage/motocyclette/", | |
| 1480 | + "city": "Saint-Jérôme", | |
| 1481 | + "region": "Laurentides", | |
| 1482 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; JSON-LD dans un @graph", | |
| 1483 | + "connector": "moto_dealers", | |
| 1484 | + "vertical": "moto", | |
| 1485 | + "status": "actif" | |
| 1486 | + }, | |
| 1487 | + { | |
| 1488 | + "id": "mecamoto", | |
| 1489 | + "name": "Mecamoto", | |
| 1490 | + "url": "https://www.mecamoto.ca", | |
| 1491 | + "listing_url": "https://www.mecamoto.ca/fr/usage/", | |
| 1492 | + "city": "Montréal", | |
| 1493 | + "region": "Montréal", | |
| 1494 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; spécialiste Vespa/Piaggio/Moto Guzzi — scooters usagés en catégorie Cyclomoteurs", | |
| 1495 | + "connector": "moto_dealers", | |
| 1496 | + "vertical": "moto", | |
| 1497 | + "status": "actif" | |
| 1498 | + }, | |
| 1499 | + { | |
| 1500 | + "id": "motosthibaulttr", | |
| 1501 | + "name": "Motos Thibault Trois-Rivières", | |
| 1502 | + "url": "https://www.motosthibault.ca", | |
| 1503 | + "listing_url": "https://www.motosthibault.ca/fr/usage/", | |
| 1504 | + "city": "Trois-Rivières", | |
| 1505 | + "region": "Mauricie", | |
| 1506 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1507 | + "connector": "moto_dealers", | |
| 1508 | + "vertical": "moto", | |
| 1509 | + "status": "actif" | |
| 1510 | + }, | |
| 1511 | + { | |
| 1512 | + "id": "groupeimperium", | |
| 1513 | + "name": "Groupe Imperium", | |
| 1514 | + "url": "https://www.groupeimperium.ca", | |
| 1515 | + "listing_url": "https://www.groupeimperium.ca/fr/usage/", | |
| 1516 | + "city": "Chicoutimi", | |
| 1517 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 1518 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; multi-succursales (Chicoutimi, Dolbeau-Mistassini, Chibougamau, Rouyn-Noranda) — ville par véhicule via location", | |
| 1519 | + "connector": "moto_dealers", | |
| 1520 | + "vertical": "moto", | |
| 1521 | + "status": "actif" | |
| 1522 | + }, | |
| 1523 | + { | |
| 1524 | + "id": "motoducharme", | |
| 1525 | + "name": "Moto Ducharme", | |
| 1526 | + "url": "https://www.motoducharme.com", | |
| 1527 | + "listing_url": "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes", | |
| 1528 | + "city": "Joliette", | |
| 1529 | + "region": "Lanaudière", | |
| 1530 | + "platform": "Magento thème Traction (listing motos usagées server-rendered ?p=N ; détail : JSON-LD Product + table de spécifications Kilométrage/Couleur + galerie JSON Magento)", | |
| 1531 | + "connector": "moto_dealers", | |
| 1532 | + "vertical": "moto", | |
| 1533 | + "status": "actif" | |
| 1378 | 1534 | } |
| 1379 | 1535 | ] |
| 1380 | 1536 | } |
modified
frontend/src/App.tsx
+10 −2
@@ -58,7 +58,13 @@ function Header() { | ||
| 58 | 58 | </NavLink> |
| 59 | 59 | <nav className="nav" aria-label="Navigation principale"> |
| 60 | 60 | <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> |
| 61 | − Véhicules | |
| 61 | + Autos | |
| 62 | + </NavLink> | |
| 63 | + <NavLink to="/motos" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 64 | + Motos | |
| 65 | + </NavLink> | |
| 66 | + <NavLink to="/scooters" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 67 | + Scooters | |
| 62 | 68 | </NavLink> |
| 63 | 69 | <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}> |
| 64 | 70 | Stats |
@@ -102,7 +108,9 @@ export default function App() { | ||
| 102 | 108 | <Header /> |
| 103 | 109 | <main> |
| 104 | 110 | <Routes> |
| 105 | − <Route path="/" element={<Home />} /> | |
| 111 | + <Route path="/" element={<Home kind="auto" />} /> | |
| 112 | + <Route path="/motos" element={<Home kind="moto" />} /> | |
| 113 | + <Route path="/scooters" element={<Home kind="scooter" />} /> | |
| 106 | 114 | <Route path="/vehicule/:uid" element={<VehiclePage />} /> |
| 107 | 115 | <Route path="/stats" element={<StatsPage />} /> |
| 108 | 116 | <Route path="/sources" element={<SourcesPage />} /> |
modified
frontend/src/api.ts
+8 −3
@@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | export interface Vehicle { |
| 8 | 8 | uid: string; |
| 9 | + kind: string; | |
| 9 | 10 | source: string; |
| 10 | 11 | external_id: string; |
| 11 | 12 | url: string; |
@@ -91,6 +92,7 @@ export interface Stats { | ||
| 91 | 92 | } |
| 92 | 93 | |
| 93 | 94 | export interface VehicleQuery { |
| 95 | + kind?: string; | |
| 94 | 96 | make?: string; |
| 95 | 97 | model?: string; |
| 96 | 98 | body_type?: string; |
@@ -130,9 +132,12 @@ export function fetchVehicle(uid: string) { | ||
| 130 | 132 | return get<VehicleDetail>(`/api/vehicles/${encodeURIComponent(uid)}`); |
| 131 | 133 | } |
| 132 | 134 | |
| 133 | −export function fetchFacets(make?: string) { | |
| 134 | − const qs = make ? `?make=${encodeURIComponent(make)}` : ""; | |
| 135 | − return get<Facets>(`/api/facets${qs}`); | |
| 135 | +export function fetchFacets(make?: string, kind?: string) { | |
| 136 | + const params = new URLSearchParams(); | |
| 137 | + if (make) params.set("make", make); | |
| 138 | + if (kind) params.set("kind", kind); | |
| 139 | + const qs = params.toString(); | |
| 140 | + return get<Facets>(`/api/facets${qs ? `?${qs}` : ""}`); | |
| 136 | 141 | } |
| 137 | 142 | |
| 138 | 143 | export function fetchSources() { |
modified
frontend/src/pages/Home.tsx
+27 −11
@@ -23,7 +23,25 @@ const SORTS = [ | ||
| 23 | 23 | const PRICE_STEPS = [5000, 10000, 15000, 20000, 25000, 30000, 40000, 50000, 75000, 100000]; |
| 24 | 24 | const KM_STEPS = [20000, 40000, 60000, 80000, 100000, 130000, 160000, 200000]; |
| 25 | 25 | |
| 26 | −export default function Home() { | |
| 26 | +const KIND_COPY: Record<string, { label: string; hero: string; sub: string }> = { | |
| 27 | + auto: { | |
| 28 | + label: "voitures usagées", | |
| 29 | + hero: "voitures usagées", | |
| 30 | + sub: "Auto-Ka visite les sites des concessionnaires et marchands d'occasion de toutes les régions, normalise chaque annonce et détecte les nouveautés, les ventes et les baisses de prix — automatiquement.", | |
| 31 | + }, | |
| 32 | + moto: { | |
| 33 | + label: "motos usagées", | |
| 34 | + hero: "motos usagées", | |
| 35 | + sub: "Les inventaires des concessionnaires moto du Québec — Harley-Davidson, Honda, Yamaha, Kawasaki, BMW et plus — agrégés à la source et tenus à jour automatiquement.", | |
| 36 | + }, | |
| 37 | + scooter: { | |
| 38 | + label: "scooters usagés", | |
| 39 | + hero: "scooters usagés", | |
| 40 | + sub: "Les scooters usagés des concessionnaires du Québec — Vespa, Honda, Yamaha, Kymco et plus — agrégés à la source et tenus à jour automatiquement.", | |
| 41 | + }, | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export default function Home({ kind = "auto" }: { kind?: string }) { | |
| 27 | 45 | const [params, setParams] = useSearchParams(); |
| 28 | 46 | const [facets, setFacets] = useState<Facets | null>(null); |
| 29 | 47 | const [vehicles, setVehicles] = useState<Vehicle[]>([]); |
@@ -35,6 +53,7 @@ export default function Home() { | ||
| 35 | 53 | const g = (k: string) => params.get(k) || undefined; |
| 36 | 54 | const gn = (k: string) => (params.get(k) ? Number(params.get(k)) : undefined); |
| 37 | 55 | return { |
| 56 | + kind, | |
| 38 | 57 | make: g("make"), model: g("model"), body_type: g("body"), |
| 39 | 58 | fuel: g("fuel"), transmission: g("trans"), region: g("region"), |
| 40 | 59 | source: g("source"), year_min: gn("ymin"), year_max: gn("ymax"), |
@@ -43,14 +62,14 @@ export default function Home() { | ||
| 43 | 62 | limit: PAGE_SIZE, |
| 44 | 63 | offset: (Math.max(1, gn("page") || 1) - 1) * PAGE_SIZE, |
| 45 | 64 | }; |
| 46 | − }, [params]); | |
| 65 | + }, [params, kind]); | |
| 47 | 66 | |
| 48 | 67 | const page = Math.max(1, Number(params.get("page") || 1)); |
| 49 | 68 | const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); |
| 50 | 69 | |
| 51 | 70 | useEffect(() => { |
| 52 | − fetchFacets(params.get("make") || undefined).then(setFacets).catch(() => {}); | |
| 53 | − }, [params.get("make")]); | |
| 71 | + fetchFacets(params.get("make") || undefined, kind).then(setFacets).catch(() => {}); | |
| 72 | + }, [params.get("make"), kind]); | |
| 54 | 73 | |
| 55 | 74 | useEffect(() => { |
| 56 | 75 | fetchStats() |
@@ -88,15 +107,12 @@ export default function Home() { | ||
| 88 | 107 | <section className="hero"> |
| 89 | 108 | <span className="kicker">Agrégateur indépendant — direct des concessionnaires</span> |
| 90 | 109 | <h1> |
| 91 | − Toutes les <em>voitures usagées</em> à vendre au Québec. | |
| 110 | + {kind === "scooter" ? "Tous les " : "Toutes les "} | |
| 111 | + <em>{KIND_COPY[kind]?.hero ?? "véhicules"}</em> à vendre au Québec. | |
| 92 | 112 | Un seul endroit. |
| 93 | 113 | </h1> |
| 94 | − <p className="sub"> | |
| 95 | − Auto-Ka visite les sites des concessionnaires et marchands d'occasion | |
| 96 | − de toutes les régions, normalise chaque annonce et détecte les | |
| 97 | − nouveautés, les ventes et les baisses de prix — automatiquement. | |
| 98 | − </p> | |
| 99 | − {heroStats && ( | |
| 114 | + <p className="sub">{KIND_COPY[kind]?.sub}</p> | |
| 115 | + {kind === "auto" && heroStats && ( | |
| 100 | 116 | <div className="hero-stats"> |
| 101 | 117 | <div className="hstat"><b>{heroStats.total.toLocaleString("fr-CA")}</b> véhicules en vente</div> |
| 102 | 118 | <div className="hstat"><b>{heroStats.sources}</b> concessionnaires</div> |
| 103 | 119 | |