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>
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(?:é|é)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 | |