SPB Git

spb/auto-ka Public

Python 82.8% TypeScript 11.9% CSS 5.1%

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>
Simon-Pierre Boucher committed 1 h ago (Aug 12, 2026) parent 5783c01

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