SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%

Enrichissement des fiches + Scrapfly + 25 concessionnaires + dashboard Stats/PDF + mobile

- Scrapfly intégré comme backend anti-blocage (base.py get_scrapfly)
- Fiches enrichies : galeries complètes (D2C /js/json/<id>.json, SM360
  GraphQL, AED photos.all, HGrégoire via Scrapfly), équipements 44 %,
  descriptions 72 %, moteur 66 % — photos 4 -> 11 en moyenne
- +25 concessionnaires (125 connecteurs) : Côte-Nord, Bas-Saint-Laurent,
  île de Montréal, Charlevoix, Gaspésie — 15 970 véhicules, 124 sources
- Page Stats : tuiles héros, histogrammes prix/km/années, tops
  marques/régions/modèles, donuts carrosseries/carburants (palette
  validée daltonisme), baisses de prix, infobulles + vues tableau
- Rapport PDF téléchargeable (/api/stats/rapport.pdf, reportlab)
- Fiche véhicule : panneau Équipements, bouton Carfax, ligne Moteur
- Rendu téléphone : filtres compacts, tuiles 2 col., graphiques
  défilables, entêtes empilés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Aug 12, 2026) parent f840db2

Showing 23 changed files with +2,337 and −87

modified autoka/connectors/automobileendirect.py +35 −11
@@ -15,9 +15,11 @@
15 15 # GET /cars/detailed/<vin> (optionnel, mise en cache BD, clé statique :
16 16 # contenu quasi immuable, le prix/km frais viennent de la liste)
17 17 # -> traction, portes, passagers, moteur, couleurs, description,
18 # options, anciens propriétaires.
18 +# options, anciens propriétaires ET la galerie complète
19 +# (photos.all ≈ 25 images vs 4 aperçus dans la liste).
19 20 # ~80 requêtes de liste pour tout l'inventaire ; les pages détail sont
20 # plafonnées par AUTOKA_MAX_DETAILS et complétées aux syncs suivants.
21 +# plafonnées par AUTOKA_MAX_DETAILS (défaut 1200 — API JSON rapide,
22 +# ~1 s/requête) et complétées aux syncs suivants via le cache BD.
21 23 #
22 24 # URL publique d'une fiche : /auto-usage/<slug>/<vin> (route SPA fr).
23 25 # AUTOKA_MAX_PAGES (env) limite le nombre de pages de liste (tests).
@@ -36,6 +38,9 @@ SITE = "https://www.automobileendirect.com"
36 38 _PER_PAGE = 22 # taille fixe côté serveur
37 39 _AWD_RE = re.compile(r"\b(awd|4wd|4x4|quattro|xdrive|4matic|4motion)\b", re.I)
38 40
41 +# clé de cache détail (v2 : ajout de la galerie photos.all au payload)
42 +_DETAIL_KEY = "v2"
43 +
39 44
40 45 def _fr(value) -> str:
41 46 """Champ bilingue {'fr':…,'en':…} ou {'id':…,'title':{…}} -> texte fr."""
@@ -53,7 +58,9 @@ class AutomobileEnDirect(BaseConnector):
53 58 dealer_name = "Automobile En Direct"
54 59 request_delay = 1.0
55 60 max_pages = 200 # garde-fou dur (~80 pages réelles)
56 max_details = 400 # plafond de vraies requêtes détail / sync
61 + max_details = 1200 # plafond de vraies requêtes détail / sync
62 + # (API JSON rapide ; inventaire complet
63 + # enrichi en ≤ 2 syncs, puis cache BD)
57 64
58 65 def __init__(self) -> None:
59 66 super().__init__()
@@ -69,7 +76,13 @@ class AutomobileEnDirect(BaseConnector):
69 76 keep = ("traction", "doors", "passengers", "engineDescription",
70 77 "engineSize", "cylinders", "colors", "description",
71 78 "options", "previousOwners", "fuelConsumption")
72 return {k: data[k] for k in keep if data.get(k) is not None}
79 + out = {k: data[k] for k in keep if data.get(k) is not None}
80 + # galerie complète (photos.all) — la liste n'a que 4 aperçus
81 + photos = data.get("photos") or {}
82 + gallery = [u for u in (photos.get("all") or []) if isinstance(u, str)]
83 + if gallery:
84 + out["photos"] = gallery[:30]
85 + return out
73 86
74 87 # -- contrat --------------------------------------------------------------
75 88 def fetch(self) -> list[Vehicle]:
@@ -99,12 +112,19 @@ class AutomobileEnDirect(BaseConnector):
99 112 for r in rows:
100 113 extra: dict = {}
101 114 vin = str(r["vin"])
102 if real_fetches < detail_cap:
115 + if real_fetches >= detail_cap:
116 + # budget épuisé : on sert quand même le cache BD existant
117 + from .. import db
118 + if self._detail_con is None:
119 + self._detail_con = db.connect()
120 + extra = db.get_cached_detail(self._detail_con, self.source_id,
121 + vin, _DETAIL_KEY) or {}
122 + else:
103 123 before = self._last_request
104 124 # clé statique : le détail (traction, moteur, options…) ne
105 125 # change pas ; prix/km frais viennent toujours de la liste
106 126 try:
107 extra = self.detail(vin, "v1",
127 + extra = self.detail(vin, _DETAIL_KEY,
108 128 lambda v=vin: self._fetch_detail(v))
109 129 except Exception:
110 130 extra = {} # détail indisponible : données liste suffisent
@@ -142,10 +162,14 @@ class AutomobileEnDirect(BaseConnector):
142 162 except (TypeError, ValueError):
143 163 km = None
144 164
145 photos = r.get("photos") or {}
146 images = [u for u in (photos.get("preview") or []) if isinstance(u, str)]
147 if not images and photos.get("main"):
148 images = [photos["main"]]
165 + # galerie complète depuis le détail (≈ 25 photos), sinon aperçus liste
166 + images = [u for u in (extra.get("photos") or []) if isinstance(u, str)]
167 + if not images:
168 + photos = r.get("photos") or {}
169 + images = [u for u in (photos.get("preview") or [])
170 + if isinstance(u, str)]
171 + if not images and photos.get("main"):
172 + images = [photos["main"]]
149 173
150 174 # traction : page détail si dispo, sinon indice AWD/4x4 dans la version
151 175 drivetrain = _fr(extra.get("traction"))
@@ -218,5 +242,5 @@ class AutomobileEnDirect(BaseConnector):
218 242 description=description[:4000],
219 243 features=features[:80],
220 244 details=details,
221 images=images[:20],
245 + images=images[:30],
222 246 )
modified autoka/connectors/base.py +28 −0
@@ -18,6 +18,7 @@ USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
18 18 "AutoKaBot/1.0 (+contact@spboucher.ai)")
19 19
20 20 FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
21 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
21 22
22 23
23 24 class BaseConnector:
@@ -86,6 +87,33 @@ class BaseConnector:
86 87 data = resp.json()
87 88 return (data.get("data") or {}).get("html", "")
88 89
90 + def get_scrapfly(self, url: str, render_js: bool = False,
91 + asp: bool = True) -> str:
92 + """Récupère une page via Scrapfly (anti-bot robuste, rendu JS optionnel).
93 +
94 + À privilégier sur Firecrawl quand le site bloque, est lent, ou que le
95 + rendu Firecrawl perd de l'information. Nécessite SCRAPFLY_API_KEY.
96 + - asp=True : contournement anti-bot (Cloudflare, etc.)
97 + - render_js=True : exécute le JavaScript (SPA)
98 + """
99 + key = os.environ.get("SCRAPFLY_API_KEY")
100 + if not key:
101 + raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")
102 + params = {
103 + "key": key,
104 + "url": url,
105 + "country": "ca",
106 + }
107 + if asp:
108 + params["asp"] = "true"
109 + if render_js:
110 + params["render_js"] = "true"
111 + resp = requests.get(SCRAPFLY_API, params=params, timeout=120)
112 + resp.raise_for_status()
113 + data = resp.json()
114 + result = data.get("result") or {}
115 + return result.get("content", "")
116 +
89 117 def detail(self, external_id: str, key: str, fetch_fn) -> dict:
90 118 """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
91 119 l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé.
modified autoka/connectors/central_dealers.py +25 −0
@@ -303,3 +303,28 @@ class FleuryAutoGroupe(CentralConnector):
303 303 base_url = "https://www.fleuryautogroupe.com"
304 304 dealer_name = "Fleury Auto Groupe"
305 305 city = "Gatineau"
306 +
307 +
308 +# ---------------------------------------------------------------------------
309 +# Expansion 3 — Bas-Saint-Laurent et île de Montréal
310 +# ---------------------------------------------------------------------------
311 +
312 +class AutoBSL(CentralConnector):
313 + source_id = "autobsl"
314 + base_url = "https://www.autobsl.com"
315 + dealer_name = "Auto BSL"
316 + city = "Rimouski"
317 +
318 +
319 +class AutoXtreme(CentralConnector):
320 + source_id = "autoxtreme"
321 + base_url = "https://www.autoxtreme.ca"
322 + dealer_name = "Auto Xtrême"
323 + city = "Montréal"
324 +
325 +
326 +class DAstousAuto(CentralConnector):
327 + source_id = "dastousauto"
328 + base_url = "https://www.dastousauto.com"
329 + dealer_name = "D'Astous Auto"
330 + city = "Montréal"
added autoka/connectors/classeauto.py +172 −0
@@ -0,0 +1,172 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/classeauto.py : connecteur Classe Auto (classeauto.ca),
5 +# marchand d'occasion de l'île de Montréal (boul. Industriel,
6 +# Montréal-Nord/Anjou) — ~140 véhicules.
7 +#
8 +# Plateforme : WordPress (thème Astra) + moteur d'inventaire DriveGood
9 +# (cdn.drivegood.com). Le site expose un export JSON STATIQUE de tout
10 +# l'inventaire :
11 +# /wp-content/themes/astra/car_single_page_data/cars_formatted.json
12 +# — une seule requête pour la liste complète, avec TOUS les champs :
13 +# prix, km, VIN, stock, transmission, carburant, motricité, carrosserie,
14 +# couleurs, portes, passagers, moteur, options, photos (cdn.drivegood.com),
15 +# description. Aucune page détail nécessaire.
16 +#
17 +# Filtres de sécurité : vehicle_type_id == 1 (automobile), post_status
18 +# publish/published, condition != NEW, deleted_at vide.
19 +# -----------------------------------------------------------------------------
20 +from __future__ import annotations
21 +
22 +import re
23 +
24 +from ..schema import Vehicle
25 +from .base import BaseConnector
26 +
27 +_JSON_PATH = "/wp-content/themes/astra/car_single_page_data/cars_formatted.json"
28 +
29 +# garde-fou non-automobile (le marchand ne vend que des autos)
30 +_EXCLUDE_RE = re.compile(
31 + r"motoneige|vtt|moto\b|motomarine|bateau|roulotte|remorque|spyder", re.I)
32 +
33 +
34 +def _s(value) -> str:
35 + if value is None:
36 + return ""
37 + return re.sub(r"\s+", " ", str(value)).strip()
38 +
39 +
40 +def _num(value) -> float | None:
41 + try:
42 + f = float(value)
43 + except (TypeError, ValueError):
44 + return None
45 + return f if f > 0 else None
46 +
47 +
48 +def _int(value) -> int | None:
49 + try:
50 + return int(str(value))
51 + except (TypeError, ValueError):
52 + return None
53 +
54 +
55 +class ClasseAuto(BaseConnector):
56 + """Classe Auto (Montréal) — inventaire complet en un JSON statique."""
57 +
58 + source_id = "classeauto"
59 + base_url = "https://www.classeauto.ca"
60 + dealer_name = "Classe Auto"
61 + city = "Montréal"
62 + request_delay = 1.0
63 +
64 + def fetch(self) -> list[Vehicle]:
65 + # cache-buster comme le fait le site (nouvelle valeur par minute)
66 + data = self.get(self.base_url + _JSON_PATH).json()
67 + vehicles: list[Vehicle] = []
68 + for d in data if isinstance(data, list) else []:
69 + try:
70 + veh = self._to_vehicle(d)
71 + except Exception:
72 + continue # enregistrement malformé isolé
73 + if veh is not None:
74 + vehicles.append(veh)
75 + return vehicles
76 +
77 + def _to_vehicle(self, d: dict) -> Vehicle | None:
78 + if not isinstance(d, dict):
79 + return None
80 + if d.get("vehicle_type_id") not in (1, "1", None):
81 + return None # 1 = automobile
82 + if _s(d.get("post_status")) not in ("publish", "published", ""):
83 + return None
84 + if d.get("deleted_at"):
85 + return None
86 + if _s(d.get("condition")).upper() == "NEW":
87 + return None # usagé seulement
88 +
89 + ext_id = _s(d.get("vid")) or _s(d.get("ID"))
90 + if not ext_id:
91 + return None
92 +
93 + make = _s(d.get("maker")).title()
94 + model = _s(d.get("model"))
95 + trim = _s(d.get("car_trim")) or _s(d.get("car_sub_model"))
96 + year = _int(d.get("car_year"))
97 + body = _s(d.get("car_body"))
98 + if _EXCLUDE_RE.search(f"{make} {model} {body}"):
99 + return None
100 +
101 + title = " ".join(x for x in (str(year or ""), make, model, trim) if x)
102 +
103 + price = _num(d.get("car_price"))
104 + km = _num(d.get("car_mileage"))
105 + unit = _s(d.get("car_mileage_unit")).upper()
106 + if km and unit.startswith("MI"):
107 + km = round(km * 1.609344)
108 +
109 + slug = _s(d.get("slug"))
110 + url = (f"{self.base_url}/cars/{slug}/" if slug
111 + else _s(d.get("guid")) or self.base_url)
112 +
113 + images = [u.strip() for u in _s(d.get("photos")).split(",")
114 + if u.strip().startswith("http")][:20]
115 +
116 + engine = ""
117 + size = _s(d.get("car_engine_size"))
118 + cyl = _s(d.get("car_cylinders"))
119 + if size:
120 + engine = f"{size}L" + (f" {cyl} cyl." if cyl else "")
121 +
122 + features = [f.strip().replace("_", " ")
123 + for f in _s(d.get("car_options")).split(",") if f.strip()]
124 +
125 + details: dict = {}
126 + if d.get("car_no_accident"):
127 + details["no_accident"] = True
128 + if d.get("car_one_owner"):
129 + details["unique_owner"] = True
130 + old_price = _num(d.get("car_old_price"))
131 + if old_price and price and old_price > price:
132 + details["old_price"] = old_price
133 +
134 + carfax = _s(d.get("carfax_url")) or _s(d.get("carfax_report"))
135 + if not carfax: # parfois rangé dans admin_note
136 + m = re.search(r"https?://vhr\.carfax\.ca/\S+",
137 + _s(d.get("admin_note")))
138 + if m:
139 + carfax = m.group(0)
140 +
141 + return Vehicle(
142 + source=self.source_id,
143 + external_id=ext_id,
144 + url=url,
145 + title=title,
146 + make=make,
147 + model=model,
148 + trim=trim,
149 + year=year,
150 + price=price,
151 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
152 + mileage_km=km,
153 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
154 + transmission=_s(d.get("car_transmission")),
155 + fuel=_s(d.get("car_fuel_type")),
156 + drivetrain=_s(d.get("car_drivetrain")),
157 + body_type=body,
158 + exterior_color=_s(d.get("car_exterior_color")).title(),
159 + interior_color=_s(d.get("car_interrior_color")).title(),
160 + engine=engine,
161 + doors=_int(d.get("car_doors_count")),
162 + seats=_int(d.get("number_of_passengers")),
163 + vin=_s(d.get("car_vin")).upper(),
164 + stock_number=_s(d.get("stock")),
165 + dealer_name=self.dealer_name,
166 + city=self.city,
167 + description=_s(d.get("post_content"))[:4000],
168 + features=features[:60],
169 + details=details,
170 + images=images,
171 + carfax_url=carfax,
172 + )
modified autoka/connectors/d2c_dealers.py +238 −8
@@ -12,6 +12,18 @@
12 12 # (clé hebdomadaire) : seuls les nouveaux véhicules — et une revalidation
13 13 # par semaine pour les prix — génèrent de vraies requêtes.
14 14 #
15 +# Enrichissement (clé de cache v2) — en plus du JSON-LD (qui ne liste que
16 +# 3 photos, parfois avec des ids corrompus — bug D2C) :
17 +# - galerie COMPLÈTE : les URLs imagescdn.d2cmedia.ca/<hash>/<dealer>/<id>/
18 +# <n>/… présentes dans le HTML (préférence à la variante « cb » grand
19 +# format, sinon « mb »/« s8 »), triées par index de photo ;
20 +# - endpoint JSON par véhicule /js/json/<id>.json (celui que le JS du site
21 +# appelle pour révéler la section « Options ») : optionsTextFR (liste
22 +# d'équipements en français), moteur + cylindre, carprooflink (rapport
23 +# Carfax direct quand le concessionnaire l'expose) ;
24 +# - repli équipements : titres des topFeatures/safetyFeatures du blob
25 +# window.__vdpJSON embarqué dans la page.
26 +#
15 27 # Le lastmod du sitemap est bumpé quotidiennement par D2C (inutilisable
16 28 # comme clé de cache) — d'où la clé temporelle hebdomadaire.
17 29 # -----------------------------------------------------------------------------
@@ -44,6 +56,50 @@ _DRIVE_MAP = {
44 56 _LD_RE = re.compile(
45 57 r'<script type=["\']application/ld\+json["\']>(.*?)</script>', re.S)
46 58
59 +# galerie CDN D2C : /<variante(2)+hash>/<dealer>/<idVéhicule>/<indexPhoto>/…
60 +# (le nom de fichier peut contenir des espaces : « Silverado 1500-2023.jpg »)
61 +_GALLERY_RE = re.compile(
62 + r'(https://imagescdn\.d2cmedia\.ca/'
63 + r'([a-z0-9]{2})[a-f0-9]+/\d+/(\d+)/(\d+)/[^"\'<>]+?\.(?:jpe?g|png|webp))',
64 + re.I)
65 +
66 +# variante d'image préférée : cb = grand format (1440px), mb = moyen, s8 = mini
67 +_IMG_VARIANT_RANK = {"cb": 0, "mb": 1, "s8": 2}
68 +
69 +_VDP_JSON_RE = re.compile(r'window\.__vdpJSON\s*=\s*')
70 +
71 +MAX_IMAGES = 25
72 +
73 +
74 +def _extract_gallery(html: str, ext_id: str) -> list[str]:
75 + """Galerie complète depuis le HTML, dédupliquée par index de photo,
76 + dans l'ordre de la galerie (index croissant), meilleure variante d'abord.
77 + """
78 + best: dict[int, tuple[int, str]] = {} # index photo -> (rang, url)
79 + for url, variant, veh_id, idx in _GALLERY_RE.findall(html):
80 + if veh_id != ext_id: # vignettes d'autres véhicules
81 + continue
82 + try:
83 + idx = int(idx)
84 + except ValueError:
85 + continue
86 + rank = _IMG_VARIANT_RANK.get(variant.lower(), 3)
87 + if idx not in best or rank < best[idx][0]:
88 + best[idx] = (rank, url.replace(" ", "%20"))
89 + return [best[i][1] for i in sorted(best)][:MAX_IMAGES]
90 +
91 +
92 +def _extract_vdp_json(html: str) -> dict:
93 + """Blob `window.__vdpJSON = {...}` embarqué dans la page (si présent)."""
94 + m = _VDP_JSON_RE.search(html)
95 + if not m:
96 + return {}
97 + try:
98 + obj, _ = json.JSONDecoder().raw_decode(html, m.end())
99 + except ValueError:
100 + return {}
101 + return obj if isinstance(obj, dict) else {}
102 +
47 103
48 104 def _clean(text: str | None) -> str:
49 105 """Désencode les entités HTML (souvent doublement encodées chez D2C)."""
@@ -88,25 +144,96 @@ class D2CConnector(BaseConnector):
88 144 urls.append(loc)
89 145 return urls
90 146
91 # -- page détail -> payload JSON-LD ----------------------------------------
147 + # -- page détail -> payload JSON-LD enrichi --------------------------------
92 148 def _fetch_detail(self, url: str) -> dict:
93 149 html = self._get_text(url)
150 + data: dict = {}
94 151 for block in _LD_RE.findall(html):
95 152 try:
96 data = json.loads(block.strip())
153 + cand = json.loads(block.strip())
97 154 except ValueError:
98 155 continue
99 types = data.get("@type")
156 + types = cand.get("@type")
100 157 types = types if isinstance(types, list) else [types]
101 158 if "Vehicle" in types or "Car" in types:
102 return data
103 return {}
159 + data = cand
160 + break
161 + if not data:
162 + return {}
163 +
164 + # --- enrichissements (tous optionnels : jamais bloquants) -------------
165 + m = _VEH_URL_RE.search(url)
166 + ext_id = m.group(1) if m else ""
167 +
168 + try:
169 + gallery = _extract_gallery(html, ext_id)
170 + except Exception:
171 + gallery = []
172 + if gallery:
173 + data["_gallery"] = gallery
174 +
175 + vdp = {}
176 + try:
177 + vdp = _extract_vdp_json(html)
178 + except Exception:
179 + pass
180 +
181 + # endpoint JSON par véhicule : équipements, moteur, lien Carfax
182 + extra: dict = {}
183 + if ext_id:
184 + try:
185 + extra = self.get(f"{self.base_url}/js/json/{ext_id}.json").json()
186 + if not isinstance(extra, dict):
187 + extra = {}
188 + except Exception:
189 + extra = {}
190 +
191 + features = [_clean(t) for t in (extra.get("optionsTextFR") or [])
192 + if isinstance(t, str) and _clean(t)]
193 + if not features: # repli : blob __vdpJSON de la page
194 + for group in (vdp.get("topFeatures"), vdp.get("safetyFeatures")):
195 + for item in group or []:
196 + if isinstance(item, dict):
197 + t = _clean(item.get("title"))
198 + if t and t not in features:
199 + features.append(t)
200 + if features:
201 + data["_features"] = features
202 +
203 + engine = _clean(str(extra.get("moteur") or vdp.get("engine") or ""))
204 + # D2C renvoie « N.D. » ou « . L » (véhicules électriques) : rejeter
205 + # toute cylindrée sans chiffre
206 + if engine.upper() in ("N.D.", "N/A", "ND") or not re.search(r"\d", engine):
207 + engine = ""
208 + cyl = _clean(str(extra.get("cylindre") or vdp.get("cylinder") or ""))
209 + if cyl and cyl.upper() not in ("N.D.", "N/A", "ND", "0"):
210 + if cyl.isdigit():
211 + # nombre nu (parfois erroné pour les VÉ) : seulement en
212 + # complément d'une cylindrée valide, jamais seul
213 + if engine:
214 + engine = f"{engine} {cyl} cyl."
215 + elif cyl.lower() not in engine.lower():
216 + engine = f"{engine} {cyl}".strip()
217 + if engine:
218 + data["_engine"] = engine
219 +
220 + carfax = str(extra.get("carprooflink") or "").strip()
221 + if not carfax:
222 + cp = vdp.get("carproof")
223 + if isinstance(cp, dict):
224 + carfax = str(cp.get("link") or "").strip()
225 + if carfax.startswith("http"):
226 + data["_carfax_url"] = carfax
227 +
228 + return data
104 229
105 230 # -- contrat ---------------------------------------------------------------
106 231 def fetch(self) -> list[Vehicle]:
107 232 urls = self._vehicle_urls()
108 233 week = datetime.date.today().isocalendar()
109 cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
234 + # v2 : payloads enrichis (galerie complète, équipements, moteur,
235 + # carfax) — le bump force la re-crawl progressive des pages en cache
236 + cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire
110 237 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
111 238
112 239 vehicles: list[Vehicle] = []
@@ -177,7 +304,9 @@ class D2CConnector(BaseConnector):
177 304 except ValueError:
178 305 pass
179 306
180 images = d.get("image") or []
307 + # galerie complète extraite du HTML (payloads v2) de préférence au
308 + # JSON-LD (limité à 3 photos, ids parfois corrompus — bug D2C)
309 + images = d.get("_gallery") or d.get("image") or []
181 310 if isinstance(images, str):
182 311 images = [images]
183 312
@@ -208,10 +337,14 @@ class D2CConnector(BaseConnector):
208 337 seats=d.get("vehicleSeatingCapacity"),
209 338 vin=_clean(d.get("vehicleIdentificationNumber")),
210 339 stock_number=str(d.get("sku") or ""),
340 + engine=_clean(d.get("_engine")),
211 341 dealer_name=self.dealer_name,
212 342 city=self.city,
213 343 description=_clean(d.get("description"))[:4000],
214 images=[u for u in images if isinstance(u, str)][:20],
344 + features=[f for f in (d.get("_features") or [])
345 + if isinstance(f, str) and f],
346 + images=[u for u in images if isinstance(u, str)][:MAX_IMAGES],
347 + carfax_url=str(d.get("_carfax_url") or ""),
215 348 )
216 349
217 350
@@ -601,3 +734,100 @@ class VWSherbrooke(D2CConnector):
601 734 base_url = "https://www.vwsherbrooke.com"
602 735 dealer_name = "VW Sherbrooke"
603 736 city = "Sherbrooke"
737 +
738 +
739 +# ---------------------------------------------------------------------------
740 +# Expansion 3 — régions sous-couvertes (Québec métro, Charlevoix, Saguenay–
741 +# Lac-Saint-Jean, Côte-Nord, Bas-Saint-Laurent, Gaspésie)
742 +#
743 +# Groupe Olivier : les quatre sites Olivier de Sept-Îles (Ford / Nissan /
744 +# Chrysler / Occasion) publient EXACTEMENT le même inventaire d'occasion
745 +# (mêmes ids D2C) — on ne connecte que olivieroccasionseptiles.com. Aucun
746 +# recoupement avec Olivier Chrysler Baie-Comeau (déjà connecté) ni avec
747 +# Olivier Hyundai Baie-Comeau.
748 +# ---------------------------------------------------------------------------
749 +
750 +class DupontAutomobile(D2CConnector):
751 + source_id = "dupontautomobile"
752 + base_url = "https://www.dupontautomobile.com"
753 + dealer_name = "Dupont Automobile"
754 + city = "Alma"
755 +
756 +
757 +class AlmaHonda(D2CConnector):
758 + source_id = "almahonda"
759 + base_url = "https://www.almahonda.com"
760 + dealer_name = "Alma Honda"
761 + city = "Alma"
762 +
763 +
764 +class HondaNewRichmond(D2CConnector):
765 + source_id = "hondanewrichmond"
766 + base_url = "https://www.hondanewrichmond.com"
767 + dealer_name = "Honda New Richmond"
768 + city = "New Richmond"
769 +
770 +
771 +class OlivierHyundaiBaieComeau(D2CConnector):
772 + source_id = "olivierhyundaibc"
773 + base_url = "https://www.olivierhyundaibaiecomeau.com"
774 + dealer_name = "Olivier Hyundai Baie-Comeau"
775 + city = "Baie-Comeau"
776 +
777 +
778 +class OlivierOccasionSeptIles(D2CConnector):
779 + # inventaire commun aux 4 sites Olivier de Sept-Îles (voir note ci-dessus)
780 + source_id = "olivieroccasionseptiles"
781 + base_url = "https://www.olivieroccasionseptiles.com"
782 + dealer_name = "Olivier Occasion Sept-Îles"
783 + city = "Sept-Îles"
784 +
785 +
786 +class CharlesbourgToyota(D2CConnector):
787 + source_id = "charlesbourgtoyota"
788 + base_url = "https://www.charlesbourgtoyota.com"
789 + dealer_name = "Charlesbourg Toyota"
790 + city = "Québec"
791 +
792 +
793 +class HondaCharlesbourg(D2CConnector):
794 + source_id = "hondacharlesbourg"
795 + base_url = "https://www.hondacharlesbourg.com"
796 + dealer_name = "Honda Charlesbourg"
797 + city = "Québec"
798 +
799 +
800 +class KiaBeauport(D2CConnector):
801 + source_id = "kiabeauport"
802 + base_url = "https://www.kiabeauport.com"
803 + dealer_name = "Kia Beauport"
804 + city = "Québec"
805 +
806 +
807 +class HondaCharlevoix(D2CConnector):
808 + source_id = "hondacharlevoix"
809 + base_url = "https://www.hondacharlevoix.com"
810 + dealer_name = "Honda Charlevoix"
811 + city = "La Malbaie"
812 +
813 +
814 +class HyundaiCharlevoix(D2CConnector):
815 + source_id = "hyundaicharlevoix"
816 + base_url = "https://www.hyundaicharlevoix.com"
817 + dealer_name = "Hyundai Charlevoix"
818 + city = "Baie-Saint-Paul"
819 +
820 +
821 +class CharlevoixToyota(D2CConnector):
822 + source_id = "charlevoixtoyota"
823 + base_url = "https://www.charlevoixtoyota.com"
824 + dealer_name = "Charlevoix Toyota"
825 + city = "La Malbaie"
826 +
827 +
828 +class AutoVilleneuve(D2CConnector):
829 + # concessionnaire Ford d'Amqui (vallée de la Matapédia) — petit inventaire
830 + source_id = "autovilleneuve"
831 + base_url = "https://www.autovilleneuve.com"
832 + dealer_name = "Automobile Villeneuve Amqui"
833 + city = "Amqui"
modified autoka/connectors/desmeules.py +58 −6
@@ -12,10 +12,15 @@
12 12 # détail (…-v<id>.html), no de stock, VIN, photo, longue description.
13 13 #
14 14 # La page détail complète : kilométrage (detailsMileageData), moteur,
15 # transmission/entraînement (microdata) et galerie photos. Ces pages sont
16 # mises en cache BD à vie (clé stable v1 — les specs ne changent pas ; le
17 # prix frais vient du RSS). AUTOKA_MAX_DETAILS (env) plafonne les vraies
18 # requêtes détail par synchronisation.
15 +# transmission/entraînement (microdata) et galerie photos. Elle porte aussi
16 +# la liste d'équipements AutoJini (« Équipement standard », divs .column :
17 +# h3 catégorie + ul d'items) dont la section « Transmission & Moteur »
18 +# contient le carburant (« Essence », « Plug-in électric / gas »…) et la
19 +# motricité (« Traction Intégrale », « Quatre roues motrices »…), ainsi
20 +# qu'une boîte « Catégorie » (Car/VUS/SUV/Truck) pour la carrosserie.
21 +# Ces pages sont mises en cache BD à vie (clé stable — les specs ne
22 +# changent pas ; le prix frais vient du RSS). AUTOKA_MAX_DETAILS (env)
23 +# plafonne les vraies requêtes détail par synchronisation.
19 24 # -----------------------------------------------------------------------------
20 25 from __future__ import annotations
21 26
@@ -23,7 +28,8 @@ import html as htmllib
23 28 import os
24 29 import re
25 30
26 from ..schema import Vehicle
31 +from ..schema import (Vehicle, normalize_body, normalize_drivetrain,
32 + normalize_fuel, strip_accents)
27 33 from .base import BaseConnector
28 34
29 35 _ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S)
@@ -44,6 +50,15 @@ _COLOR_RE = re.compile(r"Color\s*:\s*([A-Za-zÀ-ÿ][^.<\"]*)[.<\"]")
44 50 _GALLERY_RE = re.compile(
45 51 r'https?://images\.autojini\.[a-z]+/[^"\s\\<>]+?\.jpg(?!/)', re.I)
46 52
53 +# liste d'équipements AutoJini : divs .column (h3 catégorie + ul d'items)
54 +_FEATURE_SECTION_RE = re.compile(
55 + r'<div class="column[^"]*">\s*<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>', re.S)
56 +_LI_RE = re.compile(r"<li>(.*?)</li>", re.S)
57 +# boîte « Catégorie » (Car / VUS / SUV / Truck…) -> carrosserie
58 +_CATEGORY_RE = re.compile(
59 + r'colorBoxHead">\s*Cat(?:é|&eacute;)gorie\s*</div>'
60 + r'.*?colorBoxText">(.*?)</div>', re.S)
61 +
47 62 _EXCLUDE_RE = re.compile(
48 63 r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|"
49 64 r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I)
@@ -58,7 +73,8 @@ def _clean(text) -> str:
58 73
59 74
60 75 # clé de cache détail stable (bump si le parsing change)
61 _DETAIL_KEY = "v2"
76 +# v3 : + équipements, carburant/motricité (Transmission & Moteur), carrosserie
77 +_DETAIL_KEY = "v3"
62 78
63 79
64 80 class DesmeulesChrysler(BaseConnector):
@@ -126,6 +142,39 @@ class DesmeulesChrysler(BaseConnector):
126 142 m = _COLOR_RE.search(html)
127 143 if m:
128 144 payload["color"] = _clean(m.group(1))
145 +
146 + # boîte « Catégorie » -> carrosserie canonique (Car reste ambigu)
147 + m = _CATEGORY_RE.search(html)
148 + if m:
149 + body = normalize_body(_clean(m.group(1)))
150 + if body:
151 + payload["body"] = body
152 +
153 + # équipements par catégorie ; « Transmission & Moteur » porte le
154 + # carburant et la motricité, absents partout ailleurs
155 + features: list[str] = []
156 + for head, ul in _FEATURE_SECTION_RE.findall(html):
157 + items = [_clean(li) for li in _LI_RE.findall(ul)]
158 + items = [it for it in items if it]
159 + features.extend(items)
160 + if not strip_accents(head).lower().startswith("transmission"):
161 + continue
162 + for it in items:
163 + if "fuel" not in payload:
164 + fuel = normalize_fuel(it)
165 + if fuel:
166 + payload["fuel"] = fuel
167 + if "drivetrain" not in payload:
168 + low = strip_accents(it).lower()
169 + if "quatre roues" in low or "4 roues" in low:
170 + payload["drivetrain"] = "Intégrale / 4x4"
171 + elif "controle" not in low: # « Contrôle de traction »
172 + drive = normalize_drivetrain(it)
173 + if drive:
174 + payload["drivetrain"] = drive
175 + if features:
176 + payload["features"] = features[:60]
177 +
129 178 images, seen = [], set()
130 179 for u in _GALLERY_RE.findall(html):
131 180 base = u.replace("_x.jpg", ".jpg")
@@ -188,7 +237,9 @@ class DesmeulesChrysler(BaseConnector):
188 237 mileage_km=km,
189 238 mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
190 239 transmission=detail.get("transmission", ""),
240 + fuel=detail.get("fuel", ""),
191 241 drivetrain=detail.get("drivetrain", ""),
242 + body_type=detail.get("body", ""),
192 243 exterior_color=detail.get("color", ""),
193 244 engine=detail.get("engine", ""),
194 245 vin=item.get("vin", ""),
@@ -196,5 +247,6 @@ class DesmeulesChrysler(BaseConnector):
196 247 dealer_name=self.dealer_name,
197 248 city=self.city,
198 249 description=item.get("description", ""),
250 + features=list(detail.get("features") or []),
199 251 images=images,
200 252 )
modified autoka/connectors/hgregoire.py +102 −0
@@ -21,6 +21,16 @@
21 21 # robots.txt demande Crawl-delay: 10 → request_delay élevé (sync complet
22 22 # ≈ 12 min, acceptable pour une synchronisation quotidienne).
23 23 # AUTOKA_MAX_PAGES (env) limite le nombre de pages pour les tests.
24 +#
25 +# ENRICHISSEMENT DÉTAIL (galerie + équipements) : la carte liste n'a qu'UNE
26 +# photo et quelques équipements ; la page détail expose la galerie complète
27 +# (~25 photos) et l'onglet « Équipements » (~40 items). Avec Crawl-delay: 10
28 +# un crawl direct des ~1 700 fiches prendrait ~4,7 h → les pages détail
29 +# passent par SCRAPFLY (requêtes gérées par leur infra, non soumises au
30 +# crawl-delay de notre IP). Cache BD à vie (clé stable « v1 » — la galerie
31 +# et les équipements ne changent pas), plafond AUTOKA_MAX_DETAILS (défaut
32 +# 300/sync) : l'inventaire complet s'enrichit en ~6 synchronisations.
33 +# Le véhicule est toujours émis avec les données liste même sans détail.
24 34 # -----------------------------------------------------------------------------
25 35 from __future__ import annotations
26 36
@@ -42,6 +52,19 @@ _DOORS_RE = re.compile(r"(\d+)\s*portes?", re.I)
42 52 # photo de carte 448x252 -> version plus grande servie par le même CDN
43 53 _IMG_SIZE_RE = re.compile(r"/by-size/([^/]+)/\d+x\d+/")
44 54
55 +# page détail : galerie (chaque photo répétée en plusieurs tailles),
56 +# onglet « Équipements » et lien Carfax éventuel
57 +_GALLERY_RE = re.compile( # album parfois alphanumérique (ex. KA8107)
58 + r"https://[^\"'\s\\<>]*/photos/by-size/([\w-]+)/\d+x\d+/(\w+)\.(\w+)")
59 +_EQUIP_TAB_RE = re.compile(
60 + r'id="vdptab-equipment"(.*?)(?:show-all-equipment|</section>)', re.S)
61 +_EQUIP_ITEM_RE = re.compile(r"<span>\s*([^<]+?)\s*</span>")
62 +_CARFAX_RE = re.compile(
63 + r'https?://[^"\'\s<>]*carfax[^"\'\s<>]*', re.I)
64 +
65 +# clé de cache détail stable (bump si le parsing change)
66 +_DETAIL_KEY = "v1"
67 +
45 68 MILE_KM = 1.609344
46 69
47 70
@@ -73,6 +96,7 @@ class HGregoire(BaseConnector):
73 96 dealer_name = "HGrégoire"
74 97 request_delay = 10.0 # robots.txt : Crawl-delay: 10
75 98 max_pages = 200 # garde-fou dur (~74 pages réelles)
99 + max_details = 300 # plafond de requêtes Scrapfly / sync
76 100
77 101 # -- pages liste ------------------------------------------------------
78 102 def _list_html(self, page: int) -> str:
@@ -90,9 +114,46 @@ class HGregoire(BaseConnector):
90 114 continue
91 115 return out
92 116
117 + # -- page détail (via Scrapfly) -> payload -----------------------------
118 + def _fetch_detail(self, url: str) -> dict:
119 + """Galerie complète + équipements + carfax d'une fiche véhicule.
120 +
121 + Passe par Scrapfly (pas de crawl-delay pour notre IP). Lève une
122 + exception si la page ne ressemble pas à une fiche véhicule, pour ne
123 + jamais mettre en cache un payload vide issu d'une page d'erreur.
124 + """
125 + html = self.get_scrapfly(url)
126 + payload: dict = {"images": [], "features": []}
127 +
128 + seen: set[str] = set()
129 + for album, pid, ext in _GALLERY_RE.findall(html):
130 + if pid not in seen: # chaque photo répétée en N tailles
131 + seen.add(pid)
132 + payload["images"].append(
133 + f"https://cfwww.hgregoire.com/photos/by-size/"
134 + f"{album}/1010x568/{pid}.{ext}")
135 +
136 + m = _EQUIP_TAB_RE.search(html)
137 + if m:
138 + feats: list[str] = []
139 + for f in _EQUIP_ITEM_RE.findall(m.group(1)):
140 + f = " ".join(f.split())
141 + if f and f not in feats:
142 + feats.append(f)
143 + payload["features"] = feats
144 +
145 + m = _CARFAX_RE.search(html)
146 + if m:
147 + payload["carfax_url"] = m.group(0)
148 +
149 + if not payload["images"] and 'data-class="Vehicle"' not in html:
150 + raise RuntimeError(f"page détail HGrégoire illisible : {url}")
151 + return payload
152 +
93 153 # -- contrat ----------------------------------------------------------
94 154 def fetch(self) -> list[Vehicle]:
95 155 cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
156 + detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
96 157 vehicles: list[Vehicle] = []
97 158 seen: set[str] = set()
98 159
@@ -116,8 +177,49 @@ class HGregoire(BaseConnector):
116 177 if page > last_page or new == 0:
117 178 break
118 179 html = self._list_html(page)
180 +
181 + # enrichissement détail (Scrapfly) — cache BD, budget par sync ;
182 + # le véhicule reste émis avec les données liste même sans détail
183 + real_fetches = 0
184 + for veh in vehicles:
185 + detail: dict = {}
186 + if real_fetches >= detail_cap:
187 + from .. import db
188 + if self._detail_con is None:
189 + self._detail_con = db.connect()
190 + detail = db.get_cached_detail(self._detail_con, self.source_id,
191 + veh.external_id,
192 + _DETAIL_KEY) or {}
193 + else:
194 + did_fetch = False
195 +
196 + def _fetch(u=veh.url):
197 + nonlocal did_fetch
198 + did_fetch = True
199 + return self._fetch_detail(u)
200 +
201 + try:
202 + detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch)
203 + except Exception:
204 + detail = {} # détail indisponible : liste suffisante
205 + if did_fetch:
206 + real_fetches += 1
207 + self._apply_detail(veh, detail or {})
119 208 return vehicles
120 209
210 + @staticmethod
211 + def _apply_detail(veh: Vehicle, detail: dict) -> None:
212 + images = [u for u in detail.get("images") or [] if isinstance(u, str)]
213 + if images:
214 + veh.images = images[:30]
215 + feats = [f for f in detail.get("features") or [] if isinstance(f, str)]
216 + if feats: # union liste + détail, ordre préservé
217 + merged = list(veh.features)
218 + merged += [f for f in feats if f not in merged]
219 + veh.features = merged[:80]
220 + if detail.get("carfax_url"):
221 + veh.carfax_url = str(detail["carfax_url"])
222 +
121 223 # -- carte JSON -> Vehicle ----------------------------------------------
122 224 def _to_vehicle(self, d: dict) -> Vehicle | None:
123 225 if d.get("Sold") or d.get("Deleted") or d.get("IsForSale") is False:
modified autoka/connectors/leprixdugros.py +42 −3
@@ -18,6 +18,16 @@
18 18 # en cache BD à vie (clé stable — les specs ne changent pas ; le prix et le
19 19 # km frais viennent de la liste). AUTOKA_MAX_DETAILS (env) plafonne les
20 20 # vraies requêtes détail par sync ; AUTOKA_MAX_PAGES limite la pagination.
21 +#
22 +# NOTE : les pages détail n'ont NI description NI liste d'équipements par
23 +# véhicule (les blocs « cards » sont du boilerplate : inspection 150 points,
24 +# reconditionnement) — rien à extraire de plus que les specs/ville/photos.
25 +# Le site est parfois lent (timeouts ponctuels) : chaque page détail tente
26 +# d'abord un GET direct, puis bascule sur Scrapfly en cas d'échec ; le
27 +# try/except par annonce garantit l'émission avec les données liste.
28 +# Carburant : la source ne l'affiche jamais ; en plus des jetons explicites
29 +# (hybride/EV/diesel), un moteur « N cylindres X.XL » sans indice électrifié
30 +# => Essence (sauf modèles hybrides « sans badge » connus, ex. Prius).
21 31 # -----------------------------------------------------------------------------
22 32 from __future__ import annotations
23 33
@@ -42,6 +52,19 @@ _FUEL_HINT_RE = re.compile(
42 52 r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|electrique|"
43 53 r"électrique|electric|\bev\b|diesel|tdi)", re.I)
44 54
55 +# moteur thermique explicite (« 4 cylindres 2.00L ») -> Essence par défaut
56 +_CYL_ENGINE_RE = re.compile(r"\d+\s*cylindres?", re.I)
57 +# modèles électrifiés « sans badge » : ne jamais leur inférer Essence
58 +_ELECTRIFIED_MODELS = {
59 + "prius", "prius prime", "prius c", "prius v", "ioniq", "niro",
60 + "insight", "clarity", "volt", "bolt", "bolt euv", "leaf", "c-max",
61 + "cr-z", "ct 200h", "sienna", "venza", "crown", "sonata hybrid",
62 +}
63 +# camions HD & fourgons souvent diesel SANS mention explicite (ex. Ram 3500
64 +# « 6 cylindres 6.70L » = Cummins diesel) : ne pas inférer Essence
65 +_DIESEL_PRONE_RE = re.compile(
66 + r"\b(2500|3500|4500|5500|f-?[2-5]50|sprinter|promaster)\b", re.I)
67 +
45 68
46 69 def _clean(text: str | None) -> str:
47 70 if not text:
@@ -61,9 +84,11 @@ class LePrixDuGros(BaseConnector):
61 84 source_id = "leprixdugros"
62 85 base_url = "https://www.leprixdugros.com"
63 86 dealer_name = "Le Prix du Gros"
64 request_delay = 1.0
87 + request_delay = 1.0 # >= 1 s : le site tolère mal davantage
65 88 max_pages = 80 # garde-fou dur (~37 pages réelles)
66 max_details = 400 # plafond de vraies requêtes détail / sync
89 + max_details = 800 # plafond de vraies requêtes détail / sync
90 + # (couvre les ~690 véhicules en 1 sync ;
91 + # cache BD ensuite)
67 92
68 93 # -- liste JSON ----------------------------------------------------------
69 94 def _list_page(self, page: int) -> dict:
@@ -76,7 +101,13 @@ class LePrixDuGros(BaseConnector):
76 101
77 102 # -- page détail -> payload (specs, concession, photos) -------------------
78 103 def _fetch_detail(self, url: str) -> dict:
79 html = self.get(url).text
104 + try:
105 + html = self.get(url).text
106 + except Exception:
107 + # site lent / timeout ponctuel : repli Scrapfly (infra distincte)
108 + html = self.get_scrapfly(url)
109 + if not html:
110 + raise
80 111 payload: dict = {"specs": {}, "images": []}
81 112 for name, value in _SPEC_RE.findall(html):
82 113 payload["specs"][_clean(name)] = _clean(value)
@@ -101,6 +132,9 @@ class LePrixDuGros(BaseConnector):
101 132 if "_1000x750" in u and u not in seen:
102 133 seen.add(u)
103 134 payload["images"].append(u)
135 + if not payload["specs"] and not payload["images"]:
136 + # page d'erreur / interstitiel : ne pas mettre en cache du vide
137 + raise RuntimeError(f"page détail LPDG illisible : {url}")
104 138 return payload
105 139
106 140 # -- contrat ---------------------------------------------------------------
@@ -206,6 +240,11 @@ class LePrixDuGros(BaseConnector):
206 240 m = _FUEL_HINT_RE.search(f"{title} {trim} {engine}")
207 241 if m:
208 242 fuel = m.group(1)
243 + elif (_CYL_ENGINE_RE.search(engine)
244 + and model.lower() not in _ELECTRIFIED_MODELS
245 + and not _DIESEL_PRONE_RE.search(f"{title} {model} {trim}")):
246 + # moteur « N cylindres X.XL » sans aucun indice électrifié/diesel
247 + fuel = "Essence"
209 248
210 249 images = list(detail.get("images") or [])
211 250 if not images and item.get("photo"):
added autoka/connectors/montjolichrysler.py +34 −0
@@ -0,0 +1,34 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/montjolichrysler.py : connecteur Chrysler Mont-Joli
5 +# (montjolichrysler.com), concessionnaire Chrysler/Dodge/Jeep/RAM de
6 +# Mont-Joli (Bas-Saint-Laurent, près de Rimouski et Matane).
7 +#
8 +# Plateforme : Next.js App Router (autoroot.io), inventaire EvalAuto —
9 +# même moteur qu'Occasion Beaucage / Ste-Foy Chrysler : les pages liste
10 +# /auto-usage/<n>/ embarquent dans leur flux RSC (self.__next_f.push)
11 +# l'objet {"vehicles":[...],"count":N} complet (prix, km, VIN, stock,
12 +# transmission, carburant, carrosserie, couleurs, photos,
13 +# display_dealer_name / display_city). On réutilise OccasionBeaucage,
14 +# seuls l'URL et les libellés changent.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +from .occasionbeaucage import OccasionBeaucage
19 +
20 +
21 +class MontJoliChrysler(OccasionBeaucage):
22 + """Chrysler Mont-Joli — pages liste Next.js RSC (pattern Occasion Beaucage)."""
23 +
24 + source_id = "montjolichrysler"
25 + base_url = "https://www.montjolichrysler.com"
26 + dealer_name = "Chrysler Mont-Joli"
27 + city = "Mont-Joli"
28 + max_pages = 30
29 +
30 + def _to_vehicle(self, d, blob):
31 + veh = super()._to_vehicle(d, blob)
32 + if veh is not None and not veh.city:
33 + veh.city = self.city
34 + return veh
modified autoka/connectors/occasionbeaucage.py +126 −0
@@ -18,9 +18,19 @@
18 18 # Les refs de flux («$313») pointant vers d'autres lignes RSC (les listes
19 19 # de photos) sont résolues localement. AUTOKA_MAX_PAGES (env) limite la
20 20 # pagination pour les tests.
21 +#
22 +# Enrichissement détail : la description complète et la liste d'équipements
23 +# ne vivent PAS dans le flux liste — seulement dans le flux RSC de la page
24 +# détail /auto-usage/<slug>/, sous forme de refs ($21…) vers des lignes
25 +# longues «T<hex>,» (longueur en OCTETS UTF-8) : description = HTML brut,
26 +# options = JSON {"fr": "équip1|équip2|…"}. Ces payloads sont mis en cache
27 +# BD à clé stable (les équipements ne changent pas ; le prix frais vient de
28 +# la liste) et plafonnés par AUTOKA_MAX_DETAILS / max_details par sync —
29 +# l'inventaire complet se couvre en quelques synchronisations.
21 30 # -----------------------------------------------------------------------------
22 31 from __future__ import annotations
23 32
33 +import html as htmllib
24 34 import json
25 35 import os
26 36 import re
@@ -58,6 +68,20 @@ def _s(value) -> str:
58 68 return "" if text.startswith("$") else text
59 69
60 70
71 +def _strip_html(text: str | None) -> str:
72 + """HTML (avec entités) -> texte brut compact."""
73 + if not text:
74 + return ""
75 + out = htmllib.unescape(str(text))
76 + out = re.sub(r"<[^>]+>", " ", out)
77 + return re.sub(r"\s{2,}", " ", out).strip()
78 +
79 +
80 +# clé de cache détail stable : description/équipements ne changent pas pour
81 +# une annonce donnée (bump en cas de changement de parsing)
82 +_DETAIL_KEY = "v1"
83 +
84 +
61 85 class OccasionBeaucage(BaseConnector):
62 86 """Occasion Beaucage — inventaire complet via les pages liste (RSC)."""
63 87
@@ -66,6 +90,7 @@ class OccasionBeaucage(BaseConnector):
66 90 dealer_name = "Occasion Beaucage"
67 91 request_delay = 1.0
68 92 max_pages = 120 # garde-fou dur (~52 pages réelles)
93 + max_details = 200 # plafond de vraies requêtes détail/sync
69 94
70 95 # -- flux RSC ----------------------------------------------------------
71 96 def _flight_blob(self, html: str) -> str:
@@ -111,6 +136,73 @@ class OccasionBeaucage(BaseConnector):
111 136 return value
112 137 return None
113 138
139 + @staticmethod
140 + def _flight_row(blob: str, ref: str):
141 + """«$21» -> contenu de la ligne RSC 21, y compris les lignes longues
142 + «21:T6bc,<texte>» dont la longueur T<hex> est en OCTETS UTF-8 et dont
143 + le contenu est du texte brut (HTML ou JSON sérialisé), sans saut de
144 + ligne final. Les lignes JSON classiques sont décodées directement."""
145 + rid = ref[1:]
146 + if not rid or not re.fullmatch(r"[0-9a-f]+", rid):
147 + return None # $undefined, $D…, etc.
148 + decoder = json.JSONDecoder()
149 + pattern = re.compile(
150 + r"(?<![0-9a-zA-Z_$])" + re.escape(rid) + r":(?:T([0-9a-f]+),)?")
151 + for m in pattern.finditer(blob):
152 + if m.group(1): # ligne longue à préfixe T<hex>
153 + length = int(m.group(1), 16)
154 + raw = blob[m.end():].encode("utf-8")[:length]
155 + return raw.decode("utf-8", errors="ignore")
156 + if blob[m.end():m.end() + 1] not in '["{':
157 + continue
158 + try:
159 + value, _ = decoder.raw_decode(blob, m.end())
160 + except ValueError:
161 + continue
162 + return value
163 + return None
164 +
165 + # -- page détail -> payload (description complète + équipements) --------
166 + def _fetch_detail(self, slug: str) -> dict:
167 + blob = self._flight_blob(
168 + self.get(f"{self.base_url}/auto-usage/{slug}/").text)
169 + decoder = json.JSONDecoder()
170 + veh: dict | None = None
171 + for m in re.finditer(r'"vehicle":\{', blob):
172 + try:
173 + obj, _ = decoder.raw_decode(blob, m.start() + len('"vehicle":'))
174 + except ValueError:
175 + continue
176 + if isinstance(obj, dict) and obj.get("slug"):
177 + veh = obj
178 + break
179 + if not veh:
180 + return {}
181 + payload: dict = {}
182 +
183 + desc = veh.get("description")
184 + if isinstance(desc, str) and desc.startswith("$"):
185 + desc = self._flight_row(blob, desc)
186 + desc = _strip_html(desc if isinstance(desc, str) else "")
187 + if desc:
188 + payload["description"] = desc[:4000]
189 +
190 + opts = veh.get("options")
191 + if isinstance(opts, str) and opts.startswith("$"):
192 + opts = self._flight_row(blob, opts)
193 + if isinstance(opts, str) and opts.lstrip().startswith("{"):
194 + try: # ligne longue = JSON sérialisé
195 + opts = json.loads(opts)
196 + except ValueError:
197 + opts = None
198 + if isinstance(opts, dict):
199 + opts = opts.get("fr") or opts.get("en") or ""
200 + if isinstance(opts, str) and opts:
201 + features = [f.strip() for f in opts.split("|") if f.strip()]
202 + if features:
203 + payload["features"] = features[:60]
204 + return payload
205 +
114 206 # -- contrat -----------------------------------------------------------
115 207 def fetch(self) -> list[Vehicle]:
116 208 cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
@@ -136,8 +228,42 @@ class OccasionBeaucage(BaseConnector):
136 228 if new == 0: # page recyclée
137 229 break
138 230 page += 1
231 +
232 + self._enrich_details(vehicles)
139 233 return vehicles
140 234
235 + # -- enrichissement détail plafonné (cache stable) -----------------------
236 + def _enrich_details(self, vehicles: list[Vehicle]) -> None:
237 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
238 + real_fetches = 0
239 + for veh in vehicles:
240 + slug = veh.url.rstrip("/").rsplit("/", 1)[-1]
241 + detail: dict = {}
242 + if real_fetches >= cap: # plafond : cache seulement
243 + from .. import db
244 + if self._detail_con is None:
245 + self._detail_con = db.connect()
246 + detail = db.get_cached_detail(
247 + self._detail_con, self.source_id,
248 + str(veh.external_id), _DETAIL_KEY) or {}
249 + else:
250 + def _fetch(s=slug):
251 + return self._fetch_detail(s)
252 + before = self._last_request
253 + try:
254 + detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch)
255 + except Exception:
256 + detail = {} # page disparue : données liste
257 + if self._last_request != before:
258 + real_fetches += 1
259 + if not detail:
260 + continue
261 + desc = detail.get("description") or ""
262 + if len(desc) > len(veh.description or ""):
263 + veh.description = desc
264 + if detail.get("features") and not veh.features:
265 + veh.features = list(detail["features"])
266 +
141 267 # -- enregistrement liste -> Vehicle -------------------------------------
142 268 def _to_vehicle(self, d: dict, blob: str) -> Vehicle | None:
143 269 if d.get("is_sold") or d.get("is_active") is False:
added autoka/connectors/occasioncharlevoix.py +199 −0
@@ -0,0 +1,199 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/occasioncharlevoix.py : connecteur Occasion Charlevoix
5 +# (occasioncharlevoix.com), regroupement de concessionnaires d'occasion de
6 +# Baie-Saint-Paul (Charlevoix, Capitale-Nationale) — ~30 véhicules.
7 +#
8 +# Plateforme : OctoberCMS, thème « nerd2 » + plugins nerd/inventory
9 +# (plateforme québécoise NerdAuto). La page liste /fr/inventaire/occasion
10 +# est ENTIÈREMENT rendue côté serveur : chaque carte (div.vrp4-card)
11 +# contient marque, modèle, version, année, no de stock, kilométrage, photos
12 +# (carrousel) et un bloc « Autoverify » (div.av-srp-ecomm) avec VIN et prix
13 +# réel (data-av-price — le prix affiché en gros est un paiement périodique).
14 +#
15 +# La page détail complète (cache BD, clé statique v1) : transmission,
16 +# cylindrée, motricité, couleurs, catégorie, lien Carfax.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import os
21 +import re
22 +
23 +from ..schema import Vehicle, parse_mileage
24 +from .base import BaseConnector
25 +
26 +# garde-fou non-automobile
27 +_EXCLUDE_RE = re.compile(
28 + r"motoneige|vtt|moto\b|motomarine|bateau|roulotte|remorque|spyder", re.I)
29 +
30 +_SPEC_RE = re.compile(
31 + r'class="specs-name[^"]*">\s*([^<]+?)\s*</div>\s*'
32 + r'<div class="specs-info[^"]*">\s*([^<]*?)\s*<', re.S)
33 +
34 +_CARFAX_RE = re.compile(r'data-lien-carfax="([^"]+)"')
35 +
36 +_AV_DETAIL_RE = re.compile(
37 + r'id="av_vehicle_information"[^>]*data-av-mileage="([^"]*)"')
38 +
39 +
40 +def _txt(node) -> str:
41 + return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if node else ""
42 +
43 +
44 +class OccasionCharlevoix(BaseConnector):
45 + """Occasion Charlevoix — cartes liste rendues serveur (OctoberCMS nerd)."""
46 +
47 + source_id = "occasioncharlevoix"
48 + base_url = "https://www.occasioncharlevoix.com"
49 + dealer_name = "Occasion Charlevoix"
50 + city = "Baie-Saint-Paul"
51 + request_delay = 1.0
52 + max_details = 200
53 +
54 + def fetch(self) -> list[Vehicle]:
55 + from bs4 import BeautifulSoup
56 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
57 + html = self.get(f"{self.base_url}/fr/inventaire/occasion").text
58 + soup = BeautifulSoup(html, "html.parser")
59 +
60 + vehicles: list[Vehicle] = []
61 + seen: set[str] = set()
62 + real_fetches = 0
63 + for card in soup.select("div.vrp4-card"):
64 + try:
65 + veh = self._card_to_vehicle(card)
66 + except Exception:
67 + continue # carte malformée isolée
68 + if veh is None or veh.external_id in seen:
69 + continue
70 + seen.add(veh.external_id)
71 +
72 + # enrichissement page détail (transmission, couleurs, carfax…)
73 + if real_fetches < cap:
74 + url = veh.url
75 +
76 + def _fetch(u=url):
77 + return self._fetch_detail(u)
78 +
79 + try:
80 + extra = self.detail(veh.external_id, "v1", _fetch)
81 + real_fetches += 1
82 + self._apply_detail(veh, extra)
83 + except Exception:
84 + real_fetches += 1 # ne jamais bloquer la source
85 + vehicles.append(veh)
86 + return vehicles
87 +
88 + # -- carte liste -> Vehicle -------------------------------------------------
89 + def _card_to_vehicle(self, card) -> Vehicle | None:
90 + link = card.select_one("a.vrp4-card-header[href]")
91 + if link is None:
92 + return None
93 + url = link["href"]
94 + if "/inventaire/vehicule/" not in url:
95 + return None
96 + if url.startswith("/"):
97 + url = self.base_url + url
98 +
99 + # /fr/inventaire/vehicule/occasion/<stock>/<marque>/<modèle>/…
100 + m = re.search(r"/vehicule/[^/]+/([^/]+)/", url)
101 + ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]
102 +
103 + make = _txt(card.select_one(".car-make"))
104 + model = _txt(card.select_one(".car-model"))
105 + trim = _txt(card.select_one(".car-trim"))
106 + if _EXCLUDE_RE.search(f"{make} {model} {trim}"):
107 + return None
108 +
109 + year_txt = _txt(card.select_one(".car-year"))
110 + year = int(year_txt) if year_txt.isdigit() else None
111 +
112 + stock = _txt(card.select_one(".car-stock")).lstrip("#")
113 + odo_label = _txt(card.select_one(".car-odometer"))
114 + km = parse_mileage(odo_label)
115 +
116 + # bloc Autoverify : VIN + prix réel (le prix visible est un paiement)
117 + vin, price = "", None
118 + av = card.select_one(".av-srp-ecomm")
119 + if av is not None:
120 + vin = (av.get("data-av-vin") or "").strip().upper()
121 + try:
122 + price = float(av.get("data-av-price") or 0) or None
123 + except ValueError:
124 + price = None
125 + # garde-fou : < 1 500 $ = paiement périodique saisi comme prix
126 + if price is not None and price < 1500:
127 + price = None
128 +
129 + images = []
130 + for img in card.select("img.car-img[src]"):
131 + src = img["src"]
132 + if src.startswith("/"):
133 + src = self.base_url + src
134 + if src not in images:
135 + images.append(src)
136 +
137 + title = " ".join(x for x in (make, model, trim, year_txt) if x)
138 + if not title:
139 + return None
140 +
141 + return Vehicle(
142 + source=self.source_id,
143 + external_id=ext_id,
144 + url=url,
145 + title=title,
146 + make=make,
147 + model=model,
148 + trim=trim,
149 + year=year,
150 + price=price,
151 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
152 + mileage_km=km,
153 + mileage_label=odo_label,
154 + vin=vin,
155 + stock_number=stock or ext_id.upper(),
156 + dealer_name=self.dealer_name,
157 + city=self.city,
158 + images=images[:20],
159 + )
160 +
161 + # -- page détail -------------------------------------------------------------
162 + def _fetch_detail(self, url: str) -> dict:
163 + html = self.get(url).text
164 + payload: dict = {"specs": {}}
165 + for name, value in _SPEC_RE.findall(html):
166 + payload["specs"][name.rstrip(": ").strip()] = value.strip()
167 + m = _CARFAX_RE.search(html)
168 + if m:
169 + payload["carfax"] = m.group(1)
170 + m = _AV_DETAIL_RE.search(html)
171 + if m and m.group(1):
172 + payload["mileage"] = m.group(1)
173 + return payload
174 +
175 + def _apply_detail(self, veh: Vehicle, extra: dict) -> None:
176 + specs = (extra or {}).get("specs") or {}
177 + for name, value in specs.items():
178 + low = name.lower()
179 + if not value or value.lower() in ("autre", "n/d", "s/o"):
180 + continue
181 + if "transmission" in low and not veh.transmission:
182 + veh.transmission = value
183 + elif "motricit" in low and not veh.drivetrain:
184 + veh.drivetrain = value
185 + elif "cat" in low and not veh.body_type:
186 + veh.body_type = value
187 + elif "cylindr" in low and not veh.engine:
188 + veh.engine = value
189 + elif "ext" in low and "couleur" in low and not veh.exterior_color:
190 + veh.exterior_color = value
191 + elif "int" in low and "couleur" in low and not veh.interior_color:
192 + veh.interior_color = value
193 + if extra.get("carfax") and not veh.carfax_url:
194 + veh.carfax_url = extra["carfax"]
195 + if veh.mileage_km is None and extra.get("mileage"):
196 + try:
197 + veh.mileage_km = float(extra["mileage"])
198 + except ValueError:
199 + pass
modified autoka/connectors/sm360_dealers.py +123 −2
@@ -18,6 +18,14 @@
18 18 # nouveaux véhicules — et une revalidation par semaine pour les prix —
19 19 # génèrent de vraies requêtes.
20 20 #
21 +# En complément, l'API GraphQL publique que le site utilise lui-même
22 +# (webauto-supplier-api.sm360.ca — celle des widgets VDP) donne pour chaque
23 +# id : la liste d'équipements (options.labels), la galerie complète
24 +# (multimedia.pictures — indispensable pour les gabarits dont la galerie
25 +# n'est PAS server-rendered : touteslesmarques, spinelli, rivesudchrysler,
26 +# toyotagatineau) et une description/tagline de repli. Un POST par véhicule,
27 +# mis en cache avec le même payload hebdomadaire.
28 +#
21 29 # Le listing lui-même est un widget JS (GraphQL webauto-supplier-api) —
22 30 # inutile ici, le sitemap suffit. robots.txt : Allow /*?page=* et /*/api/*,
23 31 # Crawl-delay 10 → délai généreux + cache détail.
@@ -65,6 +73,12 @@ _IMG_RE = re.compile(
65 73
66 74 _CARFAX_RE = re.compile(r'https?://(?:vhr\.)?carfax\.ca/[^\s"\'<>]+', re.I)
67 75
76 +# API GraphQL publique des widgets SM360 (celle que la page détail utilise
77 +# elle-même pour afficher équipements et galerie) — id = id inventaire global
78 +_GRAPHQL_API = "https://webauto-supplier-api.sm360.ca/webauto/graphql"
79 +_GQL_VEHICLE = ("{ vehicle(id: %d) { description tagline "
80 + "options { labels } multimedia { pictures { url } } } }")
81 +
68 82 # clés extraites du dataLayer JS de la page détail (valeurs 'entre quotes')
69 83 _DL_KEYS = (
70 84 "salePrice", "regularPrice", "mileage", "stockNo", "vehicleVin",
@@ -188,8 +202,51 @@ class SM360Connector(BaseConnector):
188 202 m = _CARFAX_RE.search(html)
189 203 if m:
190 204 payload["carfax"] = htmllib.unescape(m.group(0))
205 +
206 + payload["gql"] = self._fetch_gql(ext_id)
191 207 return payload
192 208
209 + # -- API GraphQL : équipements + galerie + description de repli -------------
210 + def _fetch_gql(self, ext_id: str) -> dict:
211 + """options.labels, multimedia.pictures et description/tagline du
212 + véhicule via l'API publique des widgets. Tolérant : {} en cas d'échec
213 + (le payload HTML reste utilisable seul)."""
214 + wait = self.request_delay - (time.time() - self._last_request)
215 + if wait > 0:
216 + time.sleep(wait)
217 + try:
218 + resp = self.session.post(
219 + _GRAPHQL_API,
220 + json={"query": _GQL_VEHICLE % int(ext_id)},
221 + timeout=self.timeout)
222 + self._last_request = time.time()
223 + resp.raise_for_status()
224 + vehicle = (resp.json().get("data") or {}).get("vehicle") or {}
225 + except Exception:
226 + self._last_request = time.time()
227 + return {}
228 + if not isinstance(vehicle, dict):
229 + return {}
230 + labels = (vehicle.get("options") or {}).get("labels") or []
231 + options = [_clean(lb) for lb in labels if _clean(lb)]
232 +
233 + images: list[str] = []
234 + pictures = (vehicle.get("multimedia") or {}).get("pictures") or []
235 + for pic in pictures:
236 + u = str((pic or {}).get("url") or "").strip()
237 + if not u:
238 + continue
239 + if not u.startswith("http"): # chemin relatif au serveur d'images
240 + u = "https://img.sm360.ca/images/inventory" + u
241 + images.append(u)
242 +
243 + return {
244 + "options": options[:60],
245 + "images": images[:20],
246 + "description": _clean(vehicle.get("description")),
247 + "tagline": _clean(vehicle.get("tagline")),
248 + }
249 +
193 250 # -- multi-succursales (hook, surchargé par TLM) -----------------------------
194 251 def _dealer_city(self, dl: dict, ld: dict) -> tuple[str, str]:
195 252 return self.dealer_name, self.city
@@ -198,7 +255,8 @@ class SM360Connector(BaseConnector):
198 255 def fetch(self) -> list[Vehicle]:
199 256 urls = self._vehicle_urls()
200 257 week = datetime.date.today().isocalendar()
201 cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
258 + # v2 : + équipements/galerie GraphQL (bump = re-crawl complet forcé)
259 + cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire
202 260 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
203 261
204 262 vehicles: list[Vehicle] = []
@@ -304,7 +362,14 @@ class SM360Connector(BaseConnector):
304 362 if not trim:
305 363 trim = _clean(dl.get("trim")).upper()
306 364
365 + gql: dict = data.get("gql") or {}
366 +
307 367 images = list(data.get("images") or [])
368 + # la galerie GraphQL est complète même quand le gabarit ne la rend pas
369 + # côté serveur (touteslesmarques, spinelli, rive-sud, toyotagatineau)
370 + gql_images = list(gql.get("images") or [])
371 + if len(gql_images) > len(images):
372 + images = gql_images
308 373 if not images:
309 374 ld_img = ld.get("image") or []
310 375 images = [ld_img] if isinstance(ld_img, str) else list(ld_img)
@@ -354,7 +419,10 @@ class SM360Connector(BaseConnector):
354 419 stock_number=_clean(dl.get("stockNo")) or _clean(ld.get("sku")),
355 420 dealer_name=dealer_name,
356 421 city=city,
357 description=_clean(ld.get("description"))[:4000],
422 + description=(_clean(ld.get("description"))
423 + or gql.get("description", "")
424 + or gql.get("tagline", ""))[:4000],
425 + features=list(gql.get("options") or []),
358 426 details=details,
359 427 images=images,
360 428 carfax_url=data.get("carfax") or "",
@@ -444,3 +512,56 @@ class ToyotaGatineau(SM360Connector):
444 512 base_url = "https://www.toyotagatineau.ca"
445 513 dealer_name = "Toyota Gatineau"
446 514 city = "Gatineau"
515 +
516 +
517 +# ---------------------------------------------------------------------------
518 +# Expansion 3 — Côte-Nord, Bas-Saint-Laurent (Matane) et île de Montréal
519 +# ---------------------------------------------------------------------------
520 +
521 +class SeptIlesHonda(SM360Connector):
522 + source_id = "septileshonda"
523 + base_url = "https://www.septileshonda.com"
524 + dealer_name = "Sept-Îles Honda"
525 + city = "Sept-Îles"
526 +
527 +
528 +class SeptIlesGM(SM360Connector):
529 + source_id = "septilesgm"
530 + base_url = "https://www.sept-ilesgm.com"
531 + dealer_name = "Sept-Îles Chevrolet Buick GMC"
532 + city = "Sept-Îles"
533 +
534 +
535 +class BaieComeauGM(SM360Connector):
536 + source_id = "baiecomeaugm"
537 + base_url = "https://www.baiecomeaugm.com"
538 + dealer_name = "Baie-Comeau Chevrolet Buick GMC"
539 + city = "Baie-Comeau"
540 +
541 +
542 +class VolvoCarsSaintLeonard(SM360Connector):
543 + source_id = "volvosaintleonard"
544 + base_url = "https://www.volvocarssaintleonard.com"
545 + dealer_name = "Volvo Cars Saint-Léonard"
546 + city = "Montréal"
547 +
548 +
549 +class KiaMatane(SM360Connector):
550 + source_id = "kiamatane"
551 + base_url = "https://www.kiamatane.com"
552 + dealer_name = "Kia Matane"
553 + city = "Matane"
554 +
555 +
556 +class MarquisAutomobiles(SM360Connector):
557 + source_id = "marquisautomobiles"
558 + base_url = "https://www.marquisautomobiles.com"
559 + dealer_name = "Marquis Automobiles"
560 + city = "Matane"
561 +
562 +
563 +class VilleneuveFord(SM360Connector):
564 + source_id = "villeneuveford"
565 + base_url = "https://www.villeneuveford.com"
566 + dealer_name = "Villeneuve Ford"
567 + city = "Matane"
added autoka/marketstats.py +128 −0
@@ -0,0 +1,128 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# marketstats.py : agrégats du marché — source unique pour /api/stats/detailed
5 +# et le rapport PDF (autoka/pdfgen.py).
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import statistics
10 +import time
11 +
12 +from . import db
13 +
14 +# bornes des classes de prix (k$) — dernière classe ouverte
15 +PRICE_BUCKETS = [0, 5, 10, 15, 20, 25, 30, 40, 50, 75, 100]
16 +KM_BUCKETS = [0, 20, 40, 60, 80, 100, 130, 160, 200] # en milliers de km
17 +
18 +
19 +def compute() -> dict:
20 + """Tous les agrégats du marché sur l'inventaire actif."""
21 + con = db.connect()
22 + now = time.time()
23 +
24 + prices = [r["price"] for r in con.execute(
25 + "SELECT price FROM vehicles WHERE active=1 AND price IS NOT NULL")]
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")]
28 +
29 + head = con.execute(
30 + """SELECT COUNT(*) total, COUNT(DISTINCT source) sources,
31 + COUNT(DISTINCT region) regions, AVG(price) avg_price,
32 + AVG(mileage_km) avg_km, AVG(year) avg_year,
33 + SUM(CASE WHEN first_seen > ? THEN 1 ELSE 0 END) new_7d,
34 + SUM(CASE WHEN fuel IN ('Électrique') THEN 1 ELSE 0 END) ev,
35 + SUM(CASE WHEN fuel LIKE 'Hybride%' THEN 1 ELSE 0 END) hybrid
36 + FROM vehicles WHERE active=1""", (now - 7 * 86400,)).fetchone()
37 +
38 + # histogramme des prix
39 + price_hist = []
40 + for i, lo in enumerate(PRICE_BUCKETS):
41 + hi = PRICE_BUCKETS[i + 1] if i + 1 < len(PRICE_BUCKETS) else None
42 + n = sum(1 for p in prices
43 + if p >= lo * 1000 and (hi is None or p < hi * 1000))
44 + label = f"{lo}{hi}k" if hi else f"{lo}k+"
45 + price_hist.append({"label": label, "n": n})
46 +
47 + # histogramme kilométrage
48 + km_hist = []
49 + for i, lo in enumerate(KM_BUCKETS):
50 + hi = KM_BUCKETS[i + 1] if i + 1 < len(KM_BUCKETS) else None
51 + n = sum(1 for k in kms
52 + if k >= lo * 1000 and (hi is None or k < hi * 1000))
53 + label = f"{lo}{hi}k" if hi else f"{lo}k+"
54 + km_hist.append({"label": label, "n": n})
55 +
56 + # répartition par année (2010+ ; avant regroupé)
57 + year_rows = con.execute(
58 + "SELECT year, COUNT(*) n FROM vehicles WHERE active=1"
59 + " AND year IS NOT NULL GROUP BY year ORDER BY year").fetchall()
60 + year_hist, older = [], 0
61 + for r in year_rows:
62 + if r["year"] < 2012:
63 + older += r["n"]
64 + else:
65 + year_hist.append({"label": str(r["year"]), "n": r["n"]})
66 + if older:
67 + year_hist.insert(0, {"label": "≤2011", "n": older})
68 +
69 + def _rows(sql, args=()):
70 + return [dict(r) for r in con.execute(sql, args).fetchall()]
71 +
72 + out = {
73 + "generated_at": now,
74 + "total": head["total"],
75 + "sources": head["sources"],
76 + "regions": head["regions"],
77 + "avg_price": head["avg_price"],
78 + "median_price": statistics.median(prices) if prices else None,
79 + "avg_km": head["avg_km"],
80 + "median_km": statistics.median(kms) if kms else None,
81 + "avg_year": head["avg_year"],
82 + "new_7d": head["new_7d"],
83 + "electrified_pct": round(100.0 * (head["ev"] + head["hybrid"])
84 + / head["total"], 1) if head["total"] else 0,
85 + "ev": head["ev"], "hybrid": head["hybrid"],
86 + "price_hist": price_hist,
87 + "km_hist": km_hist,
88 + "year_hist": year_hist,
89 + "by_make": _rows(
90 + "SELECT make label, COUNT(*) n, ROUND(AVG(price)) avg_price"
91 + " FROM vehicles WHERE active=1 AND make<>''"
92 + " GROUP BY make ORDER BY n DESC LIMIT 14"),
93 + "by_region": _rows(
94 + "SELECT region label, COUNT(*) n, ROUND(AVG(price)) avg_price"
95 + " FROM vehicles WHERE active=1 AND region<>''"
96 + " GROUP BY region ORDER BY n DESC"),
97 + "by_body": _rows(
98 + "SELECT body_type label, COUNT(*) n FROM vehicles WHERE active=1"
99 + " AND body_type<>'' GROUP BY body_type ORDER BY n DESC"),
100 + "by_fuel": _rows(
101 + "SELECT fuel label, COUNT(*) n FROM vehicles WHERE active=1"
102 + " AND fuel<>'' GROUP BY fuel ORDER BY n DESC"),
103 + "top_models": _rows(
104 + "SELECT make || ' ' || model label, COUNT(*) n,"
105 + " ROUND(AVG(price)) avg_price, ROUND(AVG(mileage_km)) avg_km"
106 + " FROM vehicles WHERE active=1 AND make<>'' AND model<>''"
107 + " GROUP BY make, model ORDER BY n DESC LIMIT 15"),
108 + "top_dealers": _rows(
109 + "SELECT dealer_name label, COUNT(*) n, ROUND(AVG(price)) avg_price"
110 + " FROM vehicles WHERE active=1 AND dealer_name<>''"
111 + " GROUP BY dealer_name ORDER BY n DESC LIMIT 12"),
112 + "avg_price_by_year": _rows(
113 + "SELECT year label, ROUND(AVG(price)) n FROM vehicles"
114 + " WHERE active=1 AND year>=2012 AND price IS NOT NULL"
115 + " GROUP BY year ORDER BY year"),
116 + "price_drops": _rows(
117 + """SELECT v.uid, v.title, v.year, v.price, v.dealer_name, v.city,
118 + p.prev_price
119 + FROM vehicles v JOIN (
120 + SELECT uid, price prev_price,
121 + ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn
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
124 + AND p.prev_price > v.price
125 + ORDER BY (p.prev_price - v.price) DESC LIMIT 12"""),
126 + }
127 + con.close()
128 + return out
added autoka/pdfgen.py +280 −0
@@ -0,0 +1,280 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# pdfgen.py : rapport PDF « Le marché de l'occasion » — vue d'ensemble des
5 +# statistiques (héros, distributions, marques, régions, aubaines).
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import datetime
10 +import io
11 +
12 +from reportlab.lib.colors import HexColor
13 +from reportlab.lib.pagesizes import letter
14 +from reportlab.lib.units import mm
15 +from reportlab.pdfgen.canvas import Canvas
16 +
17 +from . import marketstats
18 +
19 +INK = HexColor("#17181c")
20 +INK2 = HexColor("#4c4f57")
21 +INK3 = HexColor("#8b8e96")
22 +ACCENT = HexColor("#ff5a2a")
23 +ACCENT_DEEP = HexColor("#cc3f16")
24 +PAPER = HexColor("#f4f2ec")
25 +SURFACE = HexColor("#ffffff")
26 +GOOD = HexColor("#1c7a4d")
27 +LINE = HexColor("#d8d5cd")
28 +
29 +W, H = letter
30 +MARGIN = 18 * mm
31 +
32 +
33 +def _fmt(n, suffix="") -> str:
34 + if n is None:
35 + return "—"
36 + return f"{round(n):,}".replace(",", " ") + suffix
37 +
38 +
39 +class _Doc:
40 + """Petit assistant de mise en page (curseur vertical + gabarits)."""
41 +
42 + def __init__(self, canvas: Canvas):
43 + self.c = canvas
44 + self.y = H - MARGIN
45 + self.page = 1
46 + self._chrome()
47 +
48 + # -- gabarit de page -------------------------------------------------------
49 + def _chrome(self):
50 + c = self.c
51 + c.setFillColor(PAPER)
52 + c.rect(0, 0, W, H, stroke=0, fill=1)
53 + # bandeau encre
54 + c.setFillColor(INK)
55 + c.rect(0, H - 12 * mm, W, 12 * mm, stroke=0, fill=1)
56 + c.setFillColor(ACCENT)
57 + c.setFont("Helvetica-Bold", 11)
58 + c.drawString(MARGIN, H - 8 * mm, "Auto·Ka")
59 + c.setFillColor(HexColor("#c9cbd1"))
60 + c.setFont("Helvetica", 8)
61 + c.drawString(MARGIN + 18 * mm, H - 8 * mm,
62 + "Le marché des voitures usagées au Québec — rapport d'ensemble")
63 + c.drawRightString(W - MARGIN, H - 8 * mm,
64 + datetime.date.today().strftime("%Y-%m-%d"))
65 + # pied
66 + c.setFillColor(INK3)
67 + c.setFont("Helvetica", 7)
68 + c.drawString(MARGIN, 10 * mm,
69 + "www.auto-ka.com — agrégateur indépendant, données lues "
70 + "directement sur les sites des concessionnaires")
71 + c.drawRightString(W - MARGIN, 10 * mm, f"page {self.page}")
72 + self.y = H - 22 * mm
73 +
74 + def new_page(self):
75 + self.c.showPage()
76 + self.page += 1
77 + self._chrome()
78 +
79 + def need(self, height: float):
80 + if self.y - height < 16 * mm:
81 + self.new_page()
82 +
83 + # -- blocs -----------------------------------------------------------------
84 + def title(self, text: str):
85 + self.need(14 * mm)
86 + self.c.setFillColor(ACCENT_DEEP)
87 + self.c.setFont("Helvetica-Bold", 8)
88 + self.c.drawString(MARGIN, self.y, "— " + text.upper())
89 + self.y -= 7 * mm
90 +
91 + def hero_row(self, items: list[tuple[str, str]]):
92 + """Rangée de tuiles héros (valeur + étiquette)."""
93 + n = len(items)
94 + gap = 4 * mm
95 + w = (W - 2 * MARGIN - gap * (n - 1)) / n
96 + h = 20 * mm
97 + self.need(h + 6 * mm)
98 + x = MARGIN
99 + for value, label in items:
100 + self.c.setFillColor(SURFACE)
101 + self.c.setStrokeColor(INK)
102 + self.c.setLineWidth(1.2)
103 + self.c.roundRect(x, self.y - h, w, h, 2 * mm, stroke=1, fill=1)
104 + self.c.setFillColor(INK)
105 + self.c.setFont("Helvetica-Bold", 15)
106 + self.c.drawString(x + 3.5 * mm, self.y - 9 * mm, value)
107 + self.c.setFillColor(INK3)
108 + self.c.setFont("Helvetica", 6.5)
109 + self.c.drawString(x + 3.5 * mm, self.y - h + 3.5 * mm, label.upper())
110 + x += w + gap
111 + self.y -= h + 8 * mm
112 +
113 + def bars(self, rows: list[dict], value_key="n", label_key="label",
114 + extra_key=None, height_per=6.2 * mm, color=ACCENT,
115 + value_fmt=lambda v: _fmt(v)):
116 + """Barres horizontales fines, étiquettes directes, coins arrondis."""
117 + if not rows:
118 + return
119 + total_h = height_per * len(rows)
120 + self.need(total_h + 4 * mm)
121 + max_v = max(r[value_key] or 0 for r in rows) or 1
122 + label_w = 42 * mm
123 + bar_max = W - 2 * MARGIN - label_w - 26 * mm
124 + for r in rows:
125 + v = r[value_key] or 0
126 + bw = max(1.2 * mm, bar_max * v / max_v)
127 + yb = self.y - 4.2 * mm
128 + self.c.setFillColor(INK2)
129 + self.c.setFont("Helvetica", 7.5)
130 + label = str(r[label_key])[:30]
131 + self.c.drawRightString(MARGIN + label_w - 2 * mm, yb + 0.6 * mm, label)
132 + self.c.setFillColor(color)
133 + self.c.roundRect(MARGIN + label_w, yb, bw, 3.4 * mm, 1 * mm,
134 + stroke=0, fill=1)
135 + self.c.setFillColor(INK)
136 + self.c.setFont("Helvetica-Bold", 7)
137 + txt = value_fmt(v)
138 + if extra_key and r.get(extra_key) is not None:
139 + txt += f" · moy. {_fmt(r[extra_key], ' $')}"
140 + self.c.setFont("Helvetica", 7)
141 + self.c.drawString(MARGIN + label_w + bw + 2 * mm, yb + 0.6 * mm, txt)
142 + self.y -= height_per
143 + self.y -= 5 * mm
144 +
145 + def histogram(self, rows: list[dict], height=32 * mm, color=ACCENT):
146 + """Histogramme vertical (classes de prix/km/années)."""
147 + if not rows:
148 + return
149 + self.need(height + 14 * mm)
150 + n = len(rows)
151 + gap = 1.6 * mm
152 + bw = (W - 2 * MARGIN - gap * (n - 1)) / n
153 + max_v = max(r["n"] for r in rows) or 1
154 + base = self.y - height
155 + x = MARGIN
156 + self.c.setFont("Helvetica", 6)
157 + for r in rows:
158 + bh = max(1 * mm, height * r["n"] / max_v)
159 + self.c.setFillColor(color)
160 + self.c.roundRect(x, base, bw, bh, 1 * mm, stroke=0, fill=1)
161 + self.c.setFillColor(INK)
162 + self.c.setFont("Helvetica-Bold", 6)
163 + self.c.drawCentredString(x + bw / 2, base + bh + 1.4 * mm, _fmt(r["n"]))
164 + self.c.setFillColor(INK3)
165 + self.c.setFont("Helvetica", 5.8)
166 + self.c.drawCentredString(x + bw / 2, base - 3.2 * mm, r["label"])
167 + x += bw + gap
168 + self.y = base - 9 * mm
169 +
170 + def table(self, headers: list[str], rows: list[list[str]],
171 + widths: list[float]):
172 + self.need(6 * mm * (len(rows) + 1))
173 + x = MARGIN
174 + self.c.setFont("Helvetica-Bold", 7)
175 + self.c.setFillColor(INK3)
176 + for htxt, w in zip(headers, widths):
177 + self.c.drawString(x, self.y, htxt.upper())
178 + x += w
179 + self.y -= 1.6 * mm
180 + self.c.setStrokeColor(INK)
181 + self.c.setLineWidth(0.8)
182 + self.c.line(MARGIN, self.y, W - MARGIN, self.y)
183 + self.y -= 4.4 * mm
184 + for row in rows:
185 + self.need(5.4 * mm)
186 + x = MARGIN
187 + self.c.setFont("Helvetica", 7.5)
188 + self.c.setFillColor(INK)
189 + for cell, w in zip(row, widths):
190 + self.c.drawString(x, self.y, str(cell)[:42])
191 + x += w
192 + self.c.setStrokeColor(LINE)
193 + self.c.setLineWidth(0.4)
194 + self.c.line(MARGIN, self.y - 1.6 * mm, W - MARGIN, self.y - 1.6 * mm)
195 + self.y -= 5.4 * mm
196 + self.y -= 4 * mm
197 +
198 +
199 +def rapport_pdf() -> bytes:
200 + """Rapport PDF multi-pages : vue d'ensemble du marché de l'occasion."""
201 + s = marketstats.compute()
202 + buf = io.BytesIO()
203 + c = Canvas(buf, pagesize=letter)
204 + c.setTitle("Auto-Ka — Rapport du marché de l'occasion")
205 + c.setAuthor("Simon-Pierre Boucher — contact@spboucher.ai")
206 + doc = _Doc(c)
207 +
208 + # -- entête / héros ---------------------------------------------------------
209 + doc.c.setFillColor(INK)
210 + doc.c.setFont("Helvetica-Bold", 22)
211 + doc.c.drawString(MARGIN, doc.y, "Le marché de l'occasion, en un coup d'œil")
212 + doc.y -= 6 * mm
213 + doc.c.setFillColor(INK2)
214 + doc.c.setFont("Helvetica", 9)
215 + doc.c.drawString(
216 + MARGIN, doc.y,
217 + f"Inventaire actif agrégé de {s['sources']} concessionnaires dans "
218 + f"{s['regions']} régions du Québec.")
219 + doc.y -= 10 * mm
220 +
221 + doc.hero_row([
222 + (_fmt(s["total"]), "véhicules en vente"),
223 + (_fmt(s["avg_price"], " $"), "prix moyen"),
224 + (_fmt(s["median_price"], " $"), "prix médian"),
225 + (_fmt(s["avg_km"], " km"), "km moyen"),
226 + ])
227 + doc.hero_row([
228 + (f"{s['avg_year']:.0f}" if s["avg_year"] else "—", "année moyenne"),
229 + (_fmt(s["new_7d"]), "arrivages (7 jours)"),
230 + (f"{s['electrified_pct']} %", "électrifiés (VÉ + hybrides)"),
231 + (_fmt(s["sources"]), "concessionnaires"),
232 + ])
233 +
234 + # -- distributions ----------------------------------------------------------
235 + doc.title("Distribution des prix")
236 + doc.histogram(s["price_hist"])
237 + doc.title("Distribution du kilométrage (milliers de km)")
238 + doc.histogram(s["km_hist"], color=HexColor("#1f6fb5"))
239 +
240 + # -- marques / régions ------------------------------------------------------
241 + doc.new_page()
242 + doc.title("Top marques (inventaire et prix moyen)")
243 + doc.bars(s["by_make"], extra_key="avg_price")
244 + doc.title("Par région (inventaire et prix moyen)")
245 + doc.bars(s["by_region"], extra_key="avg_price",
246 + color=HexColor("#1f6fb5"))
247 +
248 + # -- modèles ----------------------------------------------------------------
249 + doc.new_page()
250 + doc.title("Modèles les plus offerts")
251 + doc.table(
252 + ["Modèle", "En vente", "Prix moyen", "KM moyen"],
253 + [[r["label"], _fmt(r["n"]), _fmt(r["avg_price"], " $"),
254 + _fmt(r["avg_km"], " km")] for r in s["top_models"]],
255 + [70 * mm, 30 * mm, 38 * mm, 38 * mm])
256 +
257 + doc.title("Carrosseries")
258 + doc.bars(s["by_body"], color=HexColor("#1c7a4d"))
259 + doc.title("Carburants")
260 + doc.bars(s["by_fuel"], color=HexColor("#b58500"))
261 +
262 + # -- aubaines ---------------------------------------------------------------
263 + if s["price_drops"]:
264 + doc.new_page()
265 + doc.title("Baisses de prix récentes (aubaines détectées)")
266 + doc.table(
267 + ["Véhicule", "Avant", "Maintenant", "Économie", "Concessionnaire"],
268 + [[f"{r['title'][:34]}", _fmt(r["prev_price"], " $"),
269 + _fmt(r["price"], " $"),
270 + "-" + _fmt(r["prev_price"] - r["price"], " $"),
271 + f"{r['dealer_name'][:22]} ({r['city']})"]
272 + for r in s["price_drops"]],
273 + [62 * mm, 24 * mm, 26 * mm, 24 * mm, 44 * mm])
274 +
275 + doc.title("Plus grands inventaires")
276 + doc.bars(s["top_dealers"], extra_key="avg_price",
277 + color=HexColor("#7d4fc9"))
278 +
279 + c.save()
280 + return buf.getvalue()
modified autoka/web.py +18 −0
@@ -247,6 +247,24 @@ def stats():
247 247 "by_body": by_body, "price_drops": drops, "recent_syncs": log}
248 248
249 249
250 +@app.get("/api/stats/detailed")
251 +def stats_detailed():
252 + """Agrégats complets du marché (source unique : autoka/marketstats.py)."""
253 + from . import marketstats
254 + return marketstats.compute()
255 +
256 +
257 +@app.get("/api/stats/rapport.pdf")
258 +def rapport_pdf():
259 + """Rapport PDF « Le marché de l'occasion » — vue d'ensemble."""
260 + from fastapi.responses import Response
261 + from . import pdfgen
262 + return Response(
263 + content=pdfgen.rapport_pdf(), media_type="application/pdf",
264 + headers={"Content-Disposition":
265 + 'attachment; filename="auto-ka-rapport-marche.pdf"'})
266 +
267 +
250 268 @app.post("/api/sync")
251 269 def trigger_sync(background: BackgroundTasks, source: str | None = None):
252 270 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
modified data/autoka.db-shm +0 −0

Binary file not shown.

modified data/autoka.db-wal +0 −0

Binary file not shown.

modified data/sources.json +276 −1
@@ -1100,6 +1100,281 @@
1100 1100 "platform": "WordPress Elementor + JetEngine (sitemaps vehicles-sitemapN.xml + JSON-LD fute-custom-schema-vehicles)",
1101 1101 "connector": "autodurocher",
1102 1102 "status": "actif"
1103 + },
1104 + {
1105 + "id": "dupontautomobile",
1106 + "name": "Dupont Automobile",
1107 + "url": "https://www.dupontautomobile.com",
1108 + "listing_url": "https://www.dupontautomobile.com/occasion/recherche.html",
1109 + "city": "Alma",
1110 + "region": "Saguenay–Lac-Saint-Jean",
1111 + "platform": "D2C Media",
1112 + "connector": "d2c_dealers",
1113 + "status": "actif"
1114 + },
1115 + {
1116 + "id": "almahonda",
1117 + "name": "Alma Honda",
1118 + "url": "https://www.almahonda.com",
1119 + "listing_url": "https://www.almahonda.com/occasion/recherche.html",
1120 + "city": "Alma",
1121 + "region": "Saguenay–Lac-Saint-Jean",
1122 + "platform": "D2C Media",
1123 + "connector": "d2c_dealers",
1124 + "status": "actif"
1125 + },
1126 + {
1127 + "id": "hondanewrichmond",
1128 + "name": "Honda New Richmond",
1129 + "url": "https://www.hondanewrichmond.com",
1130 + "listing_url": "https://www.hondanewrichmond.com/occasion/recherche.html",
1131 + "city": "New Richmond",
1132 + "region": "Gaspésie–Îles-de-la-Madeleine",
1133 + "platform": "D2C Media",
1134 + "connector": "d2c_dealers",
1135 + "status": "actif"
1136 + },
1137 + {
1138 + "id": "olivierhyundaibc",
1139 + "name": "Olivier Hyundai Baie-Comeau",
1140 + "url": "https://www.olivierhyundaibaiecomeau.com",
1141 + "listing_url": "https://www.olivierhyundaibaiecomeau.com/occasion/recherche.html",
1142 + "city": "Baie-Comeau",
1143 + "region": "Côte-Nord",
1144 + "platform": "D2C Media",
1145 + "connector": "d2c_dealers",
1146 + "status": "actif"
1147 + },
1148 + {
1149 + "id": "olivieroccasionseptiles",
1150 + "name": "Olivier Occasion Sept-Îles",
1151 + "url": "https://www.olivieroccasionseptiles.com",
1152 + "listing_url": "https://www.olivieroccasionseptiles.com/occasion/recherche.html",
1153 + "city": "Sept-Îles",
1154 + "region": "Côte-Nord",
1155 + "platform": "D2C Media (inventaire commun aux 4 sites Olivier de Sept-Îles : Ford/Nissan/Chrysler/Occasion — un seul connecté pour éviter les doublons)",
1156 + "connector": "d2c_dealers",
1157 + "status": "actif"
1158 + },
1159 + {
1160 + "id": "charlesbourgtoyota",
1161 + "name": "Charlesbourg Toyota",
1162 + "url": "https://www.charlesbourgtoyota.com",
1163 + "listing_url": "https://www.charlesbourgtoyota.com/occasion/recherche.html",
1164 + "city": "Québec",
1165 + "region": "Capitale-Nationale",
1166 + "platform": "D2C Media",
1167 + "connector": "d2c_dealers",
1168 + "status": "actif"
1169 + },
1170 + {
1171 + "id": "hondacharlesbourg",
1172 + "name": "Honda Charlesbourg",
1173 + "url": "https://www.hondacharlesbourg.com",
1174 + "listing_url": "https://www.hondacharlesbourg.com/occasion/recherche.html",
1175 + "city": "Québec",
1176 + "region": "Capitale-Nationale",
1177 + "platform": "D2C Media",
1178 + "connector": "d2c_dealers",
1179 + "status": "actif"
1180 + },
1181 + {
1182 + "id": "kiabeauport",
1183 + "name": "Kia Beauport",
1184 + "url": "https://www.kiabeauport.com",
1185 + "listing_url": "https://www.kiabeauport.com/occasion/recherche.html",
1186 + "city": "Québec",
1187 + "region": "Capitale-Nationale",
1188 + "platform": "D2C Media",
1189 + "connector": "d2c_dealers",
1190 + "status": "actif"
1191 + },
1192 + {
1193 + "id": "hondacharlevoix",
1194 + "name": "Honda Charlevoix",
1195 + "url": "https://www.hondacharlevoix.com",
1196 + "listing_url": "https://www.hondacharlevoix.com/occasion/recherche.html",
1197 + "city": "La Malbaie",
1198 + "region": "Capitale-Nationale",
1199 + "platform": "D2C Media",
1200 + "connector": "d2c_dealers",
1201 + "status": "actif"
1202 + },
1203 + {
1204 + "id": "hyundaicharlevoix",
1205 + "name": "Hyundai Charlevoix",
1206 + "url": "https://www.hyundaicharlevoix.com",
1207 + "listing_url": "https://www.hyundaicharlevoix.com/occasion/recherche.html",
1208 + "city": "Baie-Saint-Paul",
1209 + "region": "Capitale-Nationale",
1210 + "platform": "D2C Media",
1211 + "connector": "d2c_dealers",
1212 + "status": "actif"
1213 + },
1214 + {
1215 + "id": "charlevoixtoyota",
1216 + "name": "Charlevoix Toyota",
1217 + "url": "https://www.charlevoixtoyota.com",
1218 + "listing_url": "https://www.charlevoixtoyota.com/occasion/recherche.html",
1219 + "city": "La Malbaie",
1220 + "region": "Capitale-Nationale",
1221 + "platform": "D2C Media",
1222 + "connector": "d2c_dealers",
1223 + "status": "actif"
1224 + },
1225 + {
1226 + "id": "autovilleneuve",
1227 + "name": "Automobile Villeneuve Amqui",
1228 + "url": "https://www.autovilleneuve.com",
1229 + "listing_url": "https://www.autovilleneuve.com/occasion/recherche.html",
1230 + "city": "Amqui",
1231 + "region": "Bas-Saint-Laurent",
1232 + "platform": "D2C Media (petit inventaire ~5-10 véhicules)",
1233 + "connector": "d2c_dealers",
1234 + "status": "actif"
1235 + },
1236 + {
1237 + "id": "septileshonda",
1238 + "name": "Sept-Îles Honda",
1239 + "url": "https://www.septileshonda.com",
1240 + "listing_url": "https://www.septileshonda.com/fr/inventaire-occasion",
1241 + "city": "Sept-Îles",
1242 + "region": "Côte-Nord",
1243 + "platform": "sm360",
1244 + "connector": "sm360_dealers",
1245 + "status": "actif"
1246 + },
1247 + {
1248 + "id": "septilesgm",
1249 + "name": "Sept-Îles Chevrolet Buick GMC",
1250 + "url": "https://www.sept-ilesgm.com",
1251 + "listing_url": "https://www.sept-ilesgm.com/fr/inventaire-occasion",
1252 + "city": "Sept-Îles",
1253 + "region": "Côte-Nord",
1254 + "platform": "sm360",
1255 + "connector": "sm360_dealers",
1256 + "status": "actif"
1257 + },
1258 + {
1259 + "id": "baiecomeaugm",
1260 + "name": "Baie-Comeau Chevrolet Buick GMC",
1261 + "url": "https://www.baiecomeaugm.com",
1262 + "listing_url": "https://www.baiecomeaugm.com/fr/inventaire-occasion",
1263 + "city": "Baie-Comeau",
1264 + "region": "Côte-Nord",
1265 + "platform": "sm360",
1266 + "connector": "sm360_dealers",
1267 + "status": "actif"
1268 + },
1269 + {
1270 + "id": "volvosaintleonard",
1271 + "name": "Volvo Cars Saint-Léonard",
1272 + "url": "https://www.volvocarssaintleonard.com",
1273 + "listing_url": "https://www.volvocarssaintleonard.com/fr/inventaire-occasion",
1274 + "city": "Montréal",
1275 + "region": "Montréal",
1276 + "platform": "sm360",
1277 + "connector": "sm360_dealers",
1278 + "status": "actif"
1279 + },
1280 + {
1281 + "id": "kiamatane",
1282 + "name": "Kia Matane",
1283 + "url": "https://www.kiamatane.com",
1284 + "listing_url": "https://www.kiamatane.com/fr/inventaire-occasion",
1285 + "city": "Matane",
1286 + "region": "Bas-Saint-Laurent",
1287 + "platform": "sm360",
1288 + "connector": "sm360_dealers",
1289 + "status": "actif"
1290 + },
1291 + {
1292 + "id": "marquisautomobiles",
1293 + "name": "Marquis Automobiles",
1294 + "url": "https://www.marquisautomobiles.com",
1295 + "listing_url": "https://www.marquisautomobiles.com/fr/inventaire-occasion",
1296 + "city": "Matane",
1297 + "region": "Bas-Saint-Laurent",
1298 + "platform": "sm360",
1299 + "connector": "sm360_dealers",
1300 + "status": "actif"
1301 + },
1302 + {
1303 + "id": "villeneuveford",
1304 + "name": "Villeneuve Ford",
1305 + "url": "https://www.villeneuveford.com",
1306 + "listing_url": "https://www.villeneuveford.com/fr/inventaire-occasion",
1307 + "city": "Matane",
1308 + "region": "Bas-Saint-Laurent",
1309 + "platform": "sm360",
1310 + "connector": "sm360_dealers",
1311 + "status": "actif"
1312 + },
1313 + {
1314 + "id": "autobsl",
1315 + "name": "Auto BSL",
1316 + "url": "https://www.autobsl.com",
1317 + "listing_url": "https://www.autobsl.com/inventaire/",
1318 + "city": "Rimouski",
1319 + "region": "Bas-Saint-Laurent",
1320 + "platform": "AMVOQ Focus 360 (theme_central)",
1321 + "connector": "central_dealers",
1322 + "status": "actif"
1323 + },
1324 + {
1325 + "id": "autoxtreme",
1326 + "name": "Auto Xtrême",
1327 + "url": "https://www.autoxtreme.ca",
1328 + "listing_url": "https://www.autoxtreme.ca/inventaire/",
1329 + "city": "Montréal",
1330 + "region": "Montréal",
1331 + "platform": "AMVOQ Focus 360 (theme_central)",
1332 + "connector": "central_dealers",
1333 + "status": "actif"
1334 + },
1335 + {
1336 + "id": "dastousauto",
1337 + "name": "D'Astous Auto",
1338 + "url": "https://www.dastousauto.com",
1339 + "listing_url": "https://www.dastousauto.com/inventaire/",
1340 + "city": "Montréal",
1341 + "region": "Montréal",
1342 + "platform": "AMVOQ Focus 360 (theme_central)",
1343 + "connector": "central_dealers",
1344 + "status": "actif"
1345 + },
1346 + {
1347 + "id": "montjolichrysler",
1348 + "name": "Chrysler Mont-Joli",
1349 + "url": "https://www.montjolichrysler.com",
1350 + "listing_url": "https://www.montjolichrysler.com/auto-usage/",
1351 + "city": "Mont-Joli",
1352 + "region": "Bas-Saint-Laurent",
1353 + "platform": "Next.js RSC autoroot.io / EvalAuto (pattern Occasion Beaucage)",
1354 + "connector": "montjolichrysler",
1355 + "status": "actif"
1356 + },
1357 + {
1358 + "id": "classeauto",
1359 + "name": "Classe Auto",
1360 + "url": "https://www.classeauto.ca",
1361 + "listing_url": "https://www.classeauto.ca/en/cars",
1362 + "city": "Montréal",
1363 + "region": "Montréal",
1364 + "platform": "WordPress Astra + DriveGood (export JSON statique car_single_page_data/cars_formatted.json — 1 requête pour tout l'inventaire)",
1365 + "connector": "classeauto",
1366 + "status": "actif"
1367 + },
1368 + {
1369 + "id": "occasioncharlevoix",
1370 + "name": "Occasion Charlevoix",
1371 + "url": "https://www.occasioncharlevoix.com",
1372 + "listing_url": "https://www.occasioncharlevoix.com/fr/inventaire/occasion",
1373 + "city": "Baie-Saint-Paul",
1374 + "region": "Capitale-Nationale",
1375 + "platform": "OctoberCMS thème nerd2 / NerdAuto (cartes liste rendues serveur + bloc Autoverify data-av-* ; pages détail pour specs/carfax)",
1376 + "connector": "occasioncharlevoix",
1377 + "status": "actif"
1103 1378 }
1104 1379 ]
1105 }
\ No newline at end of file
1380 +}
added frontend/src/components/Charts.tsx +193 −0
@@ -0,0 +1,193 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Charts.tsx : graphiques SVG maison — histogramme, barres horizontales, donut.
5 +// Marques fines, bouts arrondis, étiquettes directes sélectives, infobulle
6 +// au survol, vue tableau repliable (accessibilité). Palette catégorielle
7 +// validée (contraste + daltonisme) : #d94f1e #1f6fb5 #1c7a4d #b58500
8 +// #7d4fc9 #a05a2c.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useState } from "react";
11 +
12 +export const CAT_COLORS = ["#d94f1e", "#1f6fb5", "#1c7a4d", "#b58500", "#7d4fc9", "#a05a2c"];
13 +
14 +export interface Datum {
15 + label: string;
16 + n: number;
17 + extra?: string;
18 +}
19 +
20 +const fmt = (n: number) => Math.round(n).toLocaleString("fr-CA");
21 +
22 +function DataTable({ rows, valueLabel }: { rows: Datum[]; valueLabel: string }) {
23 + return (
24 + <details className="chart-data">
25 + <summary>Voir les données</summary>
26 + <table>
27 + <thead>
28 + <tr><th>Catégorie</th><th>{valueLabel}</th></tr>
29 + </thead>
30 + <tbody>
31 + {rows.map((r) => (
32 + <tr key={r.label}>
33 + <td>{r.label}</td>
34 + <td>{fmt(r.n)}{r.extra ? ` · ${r.extra}` : ""}</td>
35 + </tr>
36 + ))}
37 + </tbody>
38 + </table>
39 + </details>
40 + );
41 +}
42 +
43 +function Tip({ x, y, children }: { x: number; y: number; children: ReactNode }) {
44 + return (
45 + <div className="chart-tip" style={{ left: x, top: y }}>
46 + {children}
47 + </div>
48 + );
49 +}
50 +
51 +/** Histogramme vertical — magnitude d'une distribution, une seule teinte. */
52 +export function VBars({ data, color = "#d94f1e", valueLabel = "véhicules", height = 190 }:
53 + { data: Datum[]; color?: string; valueLabel?: string; height?: number }) {
54 + const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null);
55 + if (!data.length) return null;
56 + const max = Math.max(...data.map((d) => d.n), 1);
57 + const W = 720, PAD = 6, LBL = 28;
58 + const bw = (W - PAD * (data.length - 1)) / data.length;
59 + const plotH = height - LBL - 18;
60 + return (
61 + <div className="chart-wrap" onMouseLeave={() => setTip(null)}>
62 + <svg viewBox={`0 0 ${W} ${height}`} className="chart vbars" role="img"
63 + aria-label={`Histogramme : ${valueLabel} par classe`}>
64 + {data.map((d, i) => {
65 + const h = Math.max(3, (d.n / max) * plotH);
66 + const x = i * (bw + PAD);
67 + const y = 18 + (plotH - h);
68 + const show = d.n === max || i === 0 || i === data.length - 1;
69 + return (
70 + <g key={d.label}>
71 + <rect
72 + x={x} y={y} width={bw} height={h} rx={4} fill={color}
73 + className="mark"
74 + onMouseEnter={(e) => {
75 + const r = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect();
76 + setTip({ x: e.clientX - r.left, y: e.clientY - r.top - 10, d });
77 + }}
78 + />
79 + {show && (
80 + <text x={x + bw / 2} y={y - 5} textAnchor="middle" className="c-val">
81 + {fmt(d.n)}
82 + </text>
83 + )}
84 + <text x={x + bw / 2} y={height - 4} textAnchor="middle" className="c-lbl">
85 + {d.label}
86 + </text>
87 + </g>
88 + );
89 + })}
90 + </svg>
91 + {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}</Tip>}
92 + <DataTable rows={data} valueLabel={valueLabel} />
93 + </div>
94 + );
95 +}
96 +
97 +/** Barres horizontales — classement d'une même mesure, une seule teinte. */
98 +export function HBars({ data, color = "#d94f1e", valueLabel = "véhicules" }:
99 + { data: Datum[]; color?: string; valueLabel?: string }) {
100 + const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null);
101 + if (!data.length) return null;
102 + const max = Math.max(...data.map((d) => d.n), 1);
103 + const W = 720, ROW = 26, LBL = 150;
104 + const H = data.length * ROW;
105 + return (
106 + <div className="chart-wrap" onMouseLeave={() => setTip(null)}>
107 + <svg viewBox={`0 0 ${W} ${H}`} className="chart hbars" role="img"
108 + aria-label={`Barres : ${valueLabel} par catégorie`}>
109 + {data.map((d, i) => {
110 + const w = Math.max(4, (d.n / max) * (W - LBL - 120));
111 + const y = i * ROW + 4;
112 + return (
113 + <g key={d.label}>
114 + <text x={LBL - 8} y={y + 13} textAnchor="end" className="c-cat">
115 + {d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label}
116 + </text>
117 + <rect
118 + x={LBL} y={y} width={w} height={ROW - 10} rx={4} fill={color}
119 + className="mark"
120 + onMouseEnter={(e) => {
121 + const r = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect();
122 + setTip({ x: e.clientX - r.left, y: e.clientY - r.top - 10, d });
123 + }}
124 + />
125 + <text x={LBL + w + 7} y={y + 12} className="c-val">
126 + {fmt(d.n)}{d.extra ? ` · ${d.extra}` : ""}
127 + </text>
128 + </g>
129 + );
130 + })}
131 + </svg>
132 + {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}</Tip>}
133 + <DataTable rows={data} valueLabel={valueLabel} />
134 + </div>
135 + );
136 +}
137 +
138 +/** Donut — parts d'un tout (≤ 6 catégories + « Autres »), palette validée. */
139 +export function Donut({ data, valueLabel = "véhicules" }:
140 + { data: Datum[]; valueLabel?: string }) {
141 + const [tip, setTip] = useState<{ x: number; y: number; d: Datum; pct: number } | null>(null);
142 + if (!data.length) return null;
143 + const top = data.slice(0, 5);
144 + const rest = data.slice(5).reduce((s, d) => s + d.n, 0);
145 + const parts: Datum[] = rest > 0 ? [...top, { label: "Autres", n: rest }] : top;
146 + const total = parts.reduce((s, d) => s + d.n, 0) || 1;
147 + const R = 80, r = 46, CX = 110, CY = 100;
148 + let angle = -Math.PI / 2;
149 + const arcs = parts.map((d, i) => {
150 + const span = (d.n / total) * Math.PI * 2;
151 + const a0 = angle, a1 = angle + span;
152 + angle = a1;
153 + const large = span > Math.PI ? 1 : 0;
154 + const p = (a: number, rad: number) =>
155 + `${CX + rad * Math.cos(a)},${CY + rad * Math.sin(a)}`;
156 + return {
157 + d,
158 + pct: (d.n / total) * 100,
159 + color: CAT_COLORS[i % CAT_COLORS.length],
160 + path: `M ${p(a0, R)} A ${R} ${R} 0 ${large} 1 ${p(a1, R)} L ${p(a1, r)} A ${r} ${r} 0 ${large} 0 ${p(a0, r)} Z`,
161 + };
162 + });
163 + return (
164 + <div className="chart-wrap donut-wrap" onMouseLeave={() => setTip(null)}>
165 + <svg viewBox="0 0 220 200" className="chart donut" role="img"
166 + aria-label={`Répartition : ${valueLabel} par catégorie`}>
167 + {arcs.map((a) => (
168 + <path
169 + key={a.d.label} d={a.path} fill={a.color} stroke="var(--surface)"
170 + strokeWidth={2} className="mark"
171 + onMouseEnter={(e) => {
172 + const rr = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect();
173 + setTip({ x: e.clientX - rr.left, y: e.clientY - rr.top - 10, d: a.d, pct: a.pct });
174 + }}
175 + />
176 + ))}
177 + <text x={CX} y={CY - 2} textAnchor="middle" className="donut-total">{fmt(total)}</text>
178 + <text x={CX} y={CY + 14} textAnchor="middle" className="c-lbl">{valueLabel}</text>
179 + </svg>
180 + <div className="donut-legend">
181 + {arcs.map((a) => (
182 + <div key={a.d.label} className="dl-row">
183 + <span className="dl-swatch" style={{ background: a.color }} />
184 + <span className="dl-name">{a.d.label}</span>
185 + <span className="dl-val">{a.pct.toFixed(1)} %</span>
186 + </div>
187 + ))}
188 + </div>
189 + {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel} ({tip.pct.toFixed(1)} %)</Tip>}
190 + <DataTable rows={parts} valueLabel={valueLabel} />
191 + </div>
192 + );
193 +}
modified frontend/src/pages/Stats.tsx +129 −56
@@ -1,74 +1,164 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 // Stats.tsx : le marché de l'occasion en un coup d'œil + baisses de prix
4 +// Stats.tsx : tableau de bord du marché — héros, distributions, classements,
5 +// parts de marché, aubaines + rapport PDF téléchargeable.
5 6 // -----------------------------------------------------------------------------
6 7 import { useEffect, useState } from "react";
7 8 import { Link } from "react-router-dom";
8 import { Stats, fetchStats, fmtPrice } from "../api";
9 +import { fmtPrice } from "../api";
10 +import { Datum, Donut, HBars, VBars } from "../components/Charts";
9 11
10 function Bars({ rows }: { rows: { label: string; n: number; extra?: string }[] }) {
11 const max = Math.max(1, ...rows.map((r) => r.n));
12 return (
13 <div>
14 {rows.map((r) => (
15 <div key={r.label} className="bar-row">
16 <span className="lbl">{r.label}</span>
17 <div className="bar" style={{ width: `${(r.n / max) * 55}%` }} />
18 <span className="val">{r.n.toLocaleString("fr-CA")}{r.extra ? ` · ${r.extra}` : ""}</span>
19 </div>
20 ))}
21 </div>
22 );
12 +interface Detailed {
13 + total: number;
14 + sources: number;
15 + regions: number;
16 + avg_price: number | null;
17 + median_price: number | null;
18 + avg_km: number | null;
19 + median_km: number | null;
20 + avg_year: number | null;
21 + new_7d: number;
22 + electrified_pct: number;
23 + price_hist: Datum[];
24 + km_hist: Datum[];
25 + year_hist: Datum[];
26 + by_make: { label: string; n: number; avg_price: number | null }[];
27 + by_region: { label: string; n: number; avg_price: number | null }[];
28 + by_body: Datum[];
29 + by_fuel: Datum[];
30 + top_models: { label: string; n: number; avg_price: number | null; avg_km: number | null }[];
31 + top_dealers: { label: string; n: number; avg_price: number | null }[];
32 + price_drops: {
33 + uid: string; title: string; year: number | null; price: number;
34 + prev_price: number; dealer_name: string; city: string;
35 + }[];
23 36 }
24 37
38 +const fmtK = (n: number | null) =>
39 + n == null ? "—" : `${Math.round(n / 1000)}k km`;
40 +
25 41 export default function StatsPage() {
26 const [stats, setStats] = useState<Stats | null>(null);
42 + const [s, setS] = useState<Detailed | null>(null);
27 43
28 44 useEffect(() => {
29 fetchStats().then(setStats).catch(() => {});
45 + fetch("/api/stats/detailed")
46 + .then((r) => r.json())
47 + .then(setS)
48 + .catch(() => {});
30 49 }, []);
31 50
32 if (!stats)
51 + if (!s)
33 52 return (
34 53 <div className="container page">
35 <div className="skeleton" style={{ height: 300 }} />
54 + <div className="skeleton" style={{ height: 120, marginBottom: 20 }} />
55 + <div className="skeleton" style={{ height: 340 }} />
36 56 </div>
37 57 );
38 58
59 + const withPrice = (rows: { label: string; n: number; avg_price: number | null }[]): Datum[] =>
60 + rows.map((r) => ({
61 + label: r.label, n: r.n,
62 + extra: r.avg_price ? `moy. ${fmtPrice(r.avg_price)}` : undefined,
63 + }));
64 +
39 65 return (
40 66 <div className="container page">
41 <span className="kicker">Marché</span>
42 <h1>Le marché de l'occasion, en direct</h1>
43 <p className="lead">
44 Statistiques calculées sur l'inventaire actif de tous les
45 concessionnaires connectés.
46 </p>
67 + <div className="stats-head">
68 + <div>
69 + <span className="kicker">Marché</span>
70 + <h1>Le marché de l'occasion, en direct</h1>
71 + <p className="lead" style={{ marginBottom: 0 }}>
72 + Calculé en temps réel sur l'inventaire actif de {s.sources} concessionnaires
73 + dans {s.regions} régions du Québec.
74 + </p>
75 + </div>
76 + <a className="btn pdf-btn" href="/api/stats/rapport.pdf">
77 + ⬇ Télécharger le rapport PDF
78 + </a>
79 + </div>
47 80
48 <div className="stat-grid">
49 <div className="stat-tile">
50 <b>{stats.total.toLocaleString("fr-CA")}</b>
81 + <div className="stat-grid" style={{ marginTop: 26 }}>
82 + <div className="stat-tile hero-tile">
83 + <b>{s.total.toLocaleString("fr-CA")}</b>
51 84 <span>véhicules en vente</span>
52 85 </div>
53 86 <div className="stat-tile">
54 <b>{stats.avg_price ? fmtPrice(stats.avg_price) : "—"}</b>
87 + <b>{fmtPrice(s.avg_price)}</b>
55 88 <span>prix moyen</span>
56 89 </div>
57 90 <div className="stat-tile">
58 <b>{stats.avg_km ? `${Math.round(stats.avg_km / 1000)}k km` : "—"}</b>
59 <span>kilométrage moyen</span>
91 + <b>{fmtPrice(s.median_price)}</b>
92 + <span>prix médian</span>
93 + </div>
94 + <div className="stat-tile">
95 + <b>{fmtK(s.avg_km)}</b>
96 + <span>km moyen</span>
97 + </div>
98 + <div className="stat-tile">
99 + <b>{s.new_7d.toLocaleString("fr-CA")}</b>
100 + <span>arrivages · 7 jours</span>
60 101 </div>
61 102 <div className="stat-tile">
62 <b>{stats.avg_year ? Math.round(stats.avg_year) : "—"}</b>
63 <span>année moyenne</span>
103 + <b>{s.electrified_pct} %</b>
104 + <span>électrifiés (VÉ + hybrides)</span>
105 + </div>
106 + </div>
107 +
108 + <div className="panel">
109 + <h3>💰 Distribution des prix</h3>
110 + <VBars data={s.price_hist} color="#d94f1e" />
111 + </div>
112 +
113 + <div className="vd-cols">
114 + <div className="panel">
115 + <h3>🛣 Kilométrage (milliers de km)</h3>
116 + <VBars data={s.km_hist} color="#1f6fb5" height={170} />
117 + </div>
118 + <div className="panel">
119 + <h3>📅 Années-modèles</h3>
120 + <VBars data={s.year_hist} color="#b58500" height={170} />
64 121 </div>
65 122 </div>
66 123
67 {stats.price_drops.length > 0 && (
68 <section style={{ marginBottom: 34 }}>
124 + <div className="vd-cols">
125 + <div className="panel">
126 + <h3>🏷 Top marques</h3>
127 + <HBars data={withPrice(s.by_make)} color="#d94f1e" />
128 + </div>
129 + <div className="panel">
130 + <h3>📍 Par région</h3>
131 + <HBars data={withPrice(s.by_region)} color="#1f6fb5" />
132 + </div>
133 + </div>
134 +
135 + <div className="vd-cols">
136 + <div className="panel">
137 + <h3>🚙 Carrosseries</h3>
138 + <Donut data={s.by_body} />
139 + </div>
140 + <div className="panel">
141 + <h3>⛽ Carburants</h3>
142 + <Donut data={s.by_fuel} />
143 + </div>
144 + </div>
145 +
146 + <div className="panel">
147 + <h3>🚗 Modèles les plus offerts</h3>
148 + <HBars
149 + data={s.top_models.map((m) => ({
150 + label: m.label, n: m.n,
151 + extra: `moy. ${fmtPrice(m.avg_price)} · ${fmtK(m.avg_km)}`,
152 + }))}
153 + color="#1c7a4d"
154 + />
155 + </div>
156 +
157 + {s.price_drops.length > 0 && (
158 + <section style={{ margin: "30px 0" }}>
69 159 <h2 style={{ marginBottom: 14 }}>📉 Baisses de prix récentes</h2>
70 160 <div className="src-grid">
71 {stats.price_drops.map((d) => (
161 + {s.price_drops.map((d) => (
72 162 <Link key={d.uid} to={`/vehicule/${encodeURIComponent(d.uid)}`} className="src-card">
73 163 <h3>{d.title}</h3>
74 164 <div className="meta">{d.dealer_name} · {d.city}</div>
@@ -78,7 +168,7 @@ export default function StatsPage() {
78 168 {fmtPrice(d.prev_price)}
79 169 </span>
80 170 </div>
81 <div className="meta" style={{ color: "var(--good)" }}>
171 + <div className="meta" style={{ color: "var(--good)", fontWeight: 700 }}>
82 172 −{fmtPrice(d.prev_price - d.price)}
83 173 </div>
84 174 </Link>
@@ -87,26 +177,9 @@ export default function StatsPage() {
87 177 </section>
88 178 )}
89 179
90 <div className="vd-cols">
91 <div className="panel">
92 <h3>Par région</h3>
93 <Bars rows={stats.by_region.map((r) => ({
94 label: r.region, n: r.n,
95 extra: r.avg_price ? `moy. ${fmtPrice(r.avg_price)}` : undefined,
96 }))} />
97 </div>
98 <div className="panel">
99 <h3>Par marque (top 20)</h3>
100 <Bars rows={stats.by_make.map((m) => ({
101 label: m.make, n: m.n,
102 extra: m.avg_price ? `moy. ${fmtPrice(m.avg_price)}` : undefined,
103 }))} />
104 </div>
105 </div>
106
107 180 <div className="panel">
108 <h3>Par carrosserie</h3>
109 <Bars rows={stats.by_body.map((b) => ({ label: b.body_type, n: b.n }))} />
181 + <h3>🏢 Plus grands inventaires</h3>
182 + <HBars data={withPrice(s.top_dealers)} color="#7d4fc9" />
110 183 </div>
111 184 </div>
112 185 );
modified frontend/src/pages/Vehicle.tsx +23 −0
@@ -52,6 +52,7 @@ export default function VehiclePage() {
52 52 ["Carburant", v.fuel || "—"],
53 53 ["Motricité", v.drivetrain || "—"],
54 54 ["Carrosserie", v.body_type || "—"],
55 + ["Moteur", v.engine || "—"],
55 56 ["Couleur ext.", v.exterior_color || "—"],
56 57 ["Couleur int.", v.interior_color || "—"],
57 58 ["Portes", v.doors ? String(v.doors) : "—"],
@@ -130,11 +131,33 @@ export default function VehiclePage() {
130 131 <a className="cta-source" href={v.url} target="_blank" rel="noreferrer">
131 132 Voir chez {v.dealer_name || "le concessionnaire"} →
132 133 </a>
134 + {v.carfax_url && (
135 + <a
136 + className="cta-source"
137 + style={{ background: "var(--surface)", color: "var(--ink)", marginTop: 10 }}
138 + href={v.carfax_url}
139 + target="_blank"
140 + rel="noreferrer"
141 + >
142 + 📋 Rapport Carfax
143 + </a>
144 + )}
133 145 <div className="cta-note">
134 146 annonce originale — prix et disponibilité confirmés à la source
135 147 </div>
136 148 </div>
137 149
150 + {v.features.length > 0 && (
151 + <div className="panel">
152 + <h3>✨ Équipements ({v.features.length})</h3>
153 + <div className="feat-list">
154 + {v.features.map((f) => (
155 + <span key={f} className="spec-chip">{f}</span>
156 + ))}
157 + </div>
158 + </div>
159 + )}
160 +
138 161 {v.price_history.length > 1 && (
139 162 <div className="panel">
140 163 <h3>📉 Historique de prix</h3>
modified frontend/src/styles.css +106 −0
@@ -289,6 +289,9 @@ img { display: block; }
289 289 .cta-note { font-size: 11.5px; color: var(--ink-3); text-align: center; margin-top: 8px; font-family: var(--font-mono); }
290 290
291 291 .desc { white-space: pre-line; color: var(--ink-2); font-size: 14.5px; }
292 +.feat-list {
293 + display: flex; flex-wrap: wrap; gap: 6px; max-height: 340px; overflow-y: auto;
294 +}
292 295 .price-history { font-size: 13px; }
293 296 .price-history .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--line); }
294 297 .price-history .down { color: var(--good); font-weight: 700; }
@@ -323,6 +326,109 @@ img { display: block; }
323 326 .bar-row .bar { height: 16px; background: var(--accent); border: 1.5px solid var(--ink); border-radius: 3px; min-width: 2px; }
324 327 .bar-row .val { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-2); }
325 328
329 +/* ================= Graphiques (Charts.tsx) ================= */
330 +.stats-head { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; }
331 +.pdf-btn { margin-left: auto; background: var(--accent); border-color: var(--ink); font-size: 15px; }
332 +.pdf-btn:hover { box-shadow: 4px 4px 0 var(--ink); }
333 +.stat-tile.hero-tile b { color: var(--accent-deep); }
334 +
335 +.chart-wrap { position: relative; }
336 +.chart { width: 100%; height: auto; display: block; }
337 +.chart .mark { transition: opacity 0.12s ease; cursor: default; }
338 +.chart .mark:hover { opacity: 0.75; }
339 +.c-val { font-family: var(--font-mono); font-size: 11px; font-weight: 600; fill: var(--ink); }
340 +.c-lbl { font-family: var(--font-mono); font-size: 10px; fill: var(--ink-3); }
341 +.c-cat { font-size: 12px; font-weight: 600; fill: var(--ink-2); }
342 +.donut-total { font-family: var(--font-display); font-size: 22px; font-weight: 700; fill: var(--ink); }
343 +
344 +.chart-tip {
345 + position: absolute; transform: translate(-50%, -100%); pointer-events: none;
346 + background: var(--ink); color: #fff; font-size: 12.5px; line-height: 1.35;
347 + padding: 6px 10px; border-radius: 6px; white-space: nowrap; z-index: 5;
348 + box-shadow: 0 4px 14px rgba(23,24,28,0.25);
349 +}
350 +.chart-tip b { color: var(--accent); }
351 +
352 +.donut-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 14px; align-items: center; }
353 +.donut-wrap .chart-data { grid-column: 1 / -1; }
354 +@media (max-width: 560px) { .donut-wrap { grid-template-columns: 1fr; } }
355 +.donut-legend { display: flex; flex-direction: column; gap: 6px; }
356 +.dl-row { display: flex; align-items: center; gap: 8px; font-size: 13.5px; }
357 +.dl-swatch { width: 13px; height: 13px; border-radius: 3px; border: 1px solid rgba(23,24,28,0.3); flex: 0 0 auto; }
358 +.dl-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
359 +.dl-val { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); }
360 +
361 +.chart-data { margin-top: 10px; font-size: 12.5px; }
362 +.chart-data summary {
363 + cursor: pointer; font-family: var(--font-mono); font-size: 11px;
364 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3);
365 +}
366 +.chart-data table { width: 100%; border-collapse: collapse; margin-top: 8px; }
367 +.chart-data th, .chart-data td {
368 + text-align: left; padding: 4px 8px; border-bottom: 1px dashed var(--line);
369 +}
370 +.chart-data th { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; color: var(--ink-3); }
371 +
372 +/* ================= Cartes véhicules — raffinements ================= */
373 +.vcard .photo img { transition: transform 0.35s ease; }
374 +.vcard:hover .photo img { transform: scale(1.04); }
375 +
376 +/* ================= Adaptatif téléphone ================= */
377 +@media (max-width: 640px) {
378 + .hero { padding: 30px 0 4px; }
379 + .hero p.sub { font-size: 15px; }
380 + .hero-stats { gap: 18px; margin-top: 16px; }
381 + .hstat b { font-size: 21px; }
382 +
383 + /* filtres : 2 colonnes compactes, recherche et actions pleine largeur */
384 + .filters {
385 + grid-template-columns: repeat(2, minmax(0, 1fr));
386 + padding: 12px; gap: 8px;
387 + box-shadow: 4px 4px 0 var(--ink);
388 + }
389 + .f-actions { grid-column: 1 / -1; }
390 + .f-actions .btn { width: 100%; }
391 + .f-field select, .f-field input { font-size: 16px; } /* évite le zoom iOS */
392 +
393 + .result-bar { gap: 8px; margin: 16px 0 12px; }
394 + .result-bar h2 { font-size: 17px; }
395 + .sort-box { margin-left: 0; width: 100%; }
396 + .sort-box select { flex: 1; }
397 +
398 + .vgrid { gap: 14px; grid-template-columns: 1fr; }
399 + .vcard:hover { transform: none; box-shadow: var(--shadow-flat); }
400 +
401 + /* fiche véhicule : entête et prix empilés */
402 + .vdetail { padding: 20px 0 40px; }
403 + .vd-head { flex-direction: column; gap: 8px; }
404 + .vd-price { margin-left: 0; text-align: left; }
405 + .vd-price .p { font-size: 28px; }
406 + .gallery .thumbs img { width: 68px; height: 50px; }
407 + .panel { padding: 14px 14px; }
408 +
409 + /* stats : bouton PDF pleine largeur, tuiles 2 colonnes */
410 + .stats-head { flex-direction: column; align-items: stretch; }
411 + .pdf-btn { margin-left: 0; text-align: center; }
412 + .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
413 + .stat-tile { padding: 13px; }
414 + .stat-tile b { font-size: 22px; }
415 +
416 + /* graphiques : défilement horizontal plutôt que texte illisible */
417 + .chart-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
418 + .chart.vbars { min-width: 540px; }
419 + .chart.hbars { min-width: 460px; }
420 + .donut-wrap { overflow-x: visible; }
421 +
422 + .src-grid { grid-template-columns: 1fr; }
423 + .page { padding: 26px 0 50px; }
424 +}
425 +
426 +@media (max-width: 400px) {
427 + .brand { font-size: 22px; }
428 + .nav a { padding: 7px 9px; font-size: 12.5px; }
429 + .header-inner { gap: 8px; }
430 +}
431 +
326 432 .notice { text-align: center; padding: 90px 20px; }
327 433 .notice .big { font-size: 52px; margin-bottom: 12px; }
328 434 .notice p { color: var(--ink-2); }
modified requirements.txt +2 −0
@@ -4,3 +4,5 @@ fastapi>=0.110
4 4 uvicorn>=0.29
5 5 requests>=2.31
6 6 beautifulsoup4>=4.12
7 +reportlab>=4.0
8 +pillow>=10.0
7 9