SPB Git

spb/auto-ka Public

Python 82.3% TypeScript 12% CSS 5.4%

Auto-Ka — agrégateur de voitures usagées du Québec

13 713 véhicules · 99 concessionnaires · 18 régions.
Backend FastAPI + SQLite (diff engine, historique de prix), frontend
React 18/Vite, 95+ connecteurs à la source groupés par plateforme
(D2C Media, SM360, AMVOQ, EvalAuto, Convertus, ADWS, customs),
Firecrawl pour les sites derrière Cloudflare. Déployé sur M4M64b
via PM2 + ngrok → www.auto-ka.com.

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

Showing 54 changed files with +10,877 and −0

added .gitignore +10 −0
@@ -0,0 +1,10 @@
1 +.env
2 +.venv/
3 +__pycache__/
4 +*.pyc
5 +data/*.db
6 +data/*.db-journal
7 +frontend/node_modules/
8 +frontend/dist/
9 +frontend/tsconfig.tsbuildinfo
10 +.DS_Store
added README.md +200 −0
@@ -0,0 +1,200 @@
1 +<div align="center">
2 +
3 +# Auto·Ka
4 +
5 +### Toutes les voitures usagées à vendre au Québec. Un seul endroit.
6 +
7 +**[www.auto-ka.com](https://www.auto-ka.com)**
8 +
9 +![Python](https://img.shields.io/badge/Python-3.14-17181c?style=for-the-badge&logo=python&logoColor=ff5a2a)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API-17181c?style=for-the-badge&logo=fastapi&logoColor=ff5a2a)
11 +![React](https://img.shields.io/badge/React_18-Vite_+_TS-17181c?style=for-the-badge&logo=react&logoColor=ff5a2a)
12 +![SQLite](https://img.shields.io/badge/SQLite-storage-17181c?style=for-the-badge&logo=sqlite&logoColor=ff5a2a)
13 +![PM2](https://img.shields.io/badge/PM2-production-17181c?style=for-the-badge&logoColor=ff5a2a)
14 +
15 +![Véhicules](https://img.shields.io/badge/v%C3%A9hicules_en_vente-13_713-cc3f16?style=flat-square)
16 +![Concessionnaires](https://img.shields.io/badge/concessionnaires_connect%C3%A9s-99-cc3f16?style=flat-square)
17 +![Régions](https://img.shields.io/badge/r%C3%A9gions_couvertes-18-cc3f16?style=flat-square)
18 +![Prix](https://img.shields.io/badge/annonces_avec_prix-100%25-cc3f16?style=flat-square)
19 +![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-95%2B-cc3f16?style=flat-square)
20 +
21 +*Agrégateur indépendant de voitures usagées — chaque annonce avec ses photos,
22 +ses caractéristiques standardisées (année, km, transmission, carburant, VIN…)
23 +et un lien direct vers l'annonce originale du concessionnaire. Toujours à
24 +jour, automatiquement. Suivi des baisses de prix intégré.*
25 +
26 +<img src="docs/screenshot-home.png" alt="Auto-Ka — recherche de véhicules" width="920" />
27 +
28 +</div>
29 +
30 +---
31 +
32 +## Pourquoi Auto-Ka ?
33 +
34 +Chercher une auto usagée au Québec, c'est ouvrir des dizaines de sites de
35 +concessionnaires — chacun avec sa navigation, ses filtres, son format.
36 +**Auto-Ka retourne le problème** : un connecteur dédié par commerce visite
37 +chaque site **à la source** (aucune plateforme d'annonces revendue), normalise
38 +chaque véhicule vers un schéma unique et détecte les changements en continu.
39 +
40 +> Les sites de concessionnaires n'offrent pas de webhooks. Auto-Ka reproduit
41 +> l'équivalent : **synchronisation périodique + hash de contenu** → arrivages,
42 +> baisses de prix et ventes détectés automatiquement. Un véhicule qui disparaît
43 +> du site source est marqué vendu dans Auto-Ka.
44 +
45 +## Les chiffres
46 +
47 +| Métrique | Valeur |
48 +|---|---|
49 +| Véhicules en vente | **13 713** |
50 +| Concessionnaires connectés | **99** (95+ connecteurs actifs) |
51 +| Régions administratives couvertes | **18 / 17** (+ Outaouais frontalier) |
52 +| Annonces avec prix affiché | **100 %** (jamais de prix inventé) |
53 +| Annonces avec kilométrage | **99,9 %** |
54 +| Annonces avec photos | **~99 %** |
55 +| Prix moyen du marché | **28 181 $** |
56 +| Cycle de rafraîchissement | **2 h** (PM2 watcher) |
57 +
58 +## L'architecture en 30 secondes
59 +
60 +```mermaid
61 +flowchart LR
62 + subgraph Sources["99 concessionnaires"]
63 + S1["D2C Media ×50 · SM360 ×8<br/>AMVOQ ×6 · EvalAuto ×11<br/>Convertus ×4 · customs<br/>HGrégoire · AED · Beaucage…"]
64 + end
65 + subgraph AutoKa["Auto-Ka"]
66 + C["Connecteurs<br/><i>1 adaptateur / plateforme</i>"] --> N["Normalisation<br/><i>schéma Vehicle unique</i>"]
67 + N --> D[("SQLite<br/>hash + diff + prix")]
68 + D --> A["API FastAPI<br/>/api/vehicles · /api/facets"]
69 + A --> F["React 18 + Vite<br/>filtres auto · fiches · stats"]
70 + end
71 + W["⏱ Watcher 2 h<br/>(PM2)"] -.-> C
72 + S1 --> C
73 + F --> U["🔑 Acheteur"]
74 +```
75 +
76 +| Couche | Rôle | Fichiers |
77 +|---|---|---|
78 +| **Connecteurs** | 1 module par plateforme de site (D2C Media, SM360, AMVOQ Focus 360, EvalAuto Gatsby/Next.js, Convertus VMS, ADWS, EDealer…) avec 1 sous-classe par concessionnaire ; JSON-LD schema.org, API JSON internes, sitemaps ; Firecrawl pour les sites derrière Cloudflare | `autoka/connectors/*.py` |
79 +| **Schéma** | `Vehicle` standardisé : marque, modèle, année, prix, km, transmission, carburant, motricité, carrosserie, couleurs, VIN, photos, région | `autoka/schema.py` |
80 +| **Normalisation** | Prix/km/année depuis le texte, marques canoniques, ville → région administrative, inférence motricité/carburant depuis les badges du titre (AWD, PHEV…) | `autoka/normalize.py` |
81 +| **Diff engine** | Upsert par hash de contenu — nouveau / modifié / vendu (délai de grâce 2 syncs), **historique de prix**, détection de dérive | `autoka/db.py` |
82 +| **API** | Filtres marque / modèle / année / prix / km / carburant / motricité / région / concessionnaire, facettes, stats marché, baisses de prix récentes | `autoka/web.py` |
83 +| **Frontend** | Design « éditorial sharp », accent orange racing : filtres auto complets, fiches avec galerie photos, historique de prix et véhicules similaires | `frontend/` |
84 +
85 +## Le marché en direct
86 +
87 +<div align="center">
88 +<img src="docs/screenshot-stats.png" alt="Auto-Ka — statistiques du marché et baisses de prix" width="920" />
89 +</div>
90 +
91 +## Démarrage rapide
92 +
93 +```bash
94 +git clone https://git.spboucher.ai/auto-ka.git && cd auto-ka
95 +
96 +# Backend
97 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
98 +
99 +# Frontend
100 +cd frontend && npm install && npm run build && cd ..
101 +
102 +# Sites derrière Cloudflare (optionnel)
103 +echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
104 +
105 +# Ingestion puis service
106 +.venv/bin/python run.py sync # toutes les sources (ou: run.py sync forceoccasion)
107 +.venv/bin/python run.py serve 8080 # → http://localhost:8080
108 +.venv/bin/python run.py watch 120 # resynchronisation en boucle (minutes)
109 +```
110 +
111 +## Ajouter un concessionnaire (≈ 6 lignes)
112 +
113 +L'enregistrement est **auto-découvrant** : déposez un module dans
114 +`autoka/connectors/` contenant une sous-classe de `BaseConnector` avec un
115 +`source_id` — c'est tout. Pour un site D2C Media (le fournisseur le plus
116 +répandu au Québec) :
117 +
118 +```python
119 +# dans autoka/connectors/d2c_dealers.py
120 +class MonConcessionnaire(D2CConnector):
121 + source_id = "monconcessionnaire"
122 + base_url = "https://www.monconcessionnaire.ca"
123 + dealer_name = "Mon Concessionnaire"
124 + city = "Sherbrooke"
125 +```
126 +
127 +Puis : `.venv/bin/python run.py sync monconcessionnaire` — et l'inventaire
128 +apparaît sur le site avec fiches, galeries et liens sources. Ajoutez l'entrée
129 +correspondante dans `data/sources.json` pour la page **Sources**.
130 +
131 +## API
132 +
133 +| Endpoint | Description |
134 +|---|---|
135 +| `GET /api/vehicles?make=&model=&region=&price_max=&km_max=&fuel=&sort=` | Recherche filtrée et triée |
136 +| `GET /api/vehicles/{uid}` | Fiche complète (photos, historique de prix, similaires) |
137 +| `GET /api/facets?make=` | Valeurs distinctes pour construire les filtres |
138 +| `GET /api/sources` | Registre des concessionnaires + compteurs + dernière sync |
139 +| `GET /api/stats` | Totaux, moyennes, top marques/régions, baisses de prix récentes |
140 +| `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
141 +
142 +## Couverture
143 +
144 +**Grands réseaux** — HGrégoire (mégacentres partout au Québec), Automobile En
145 +Direct, Occasion Beaucage (7 concessions), Le Prix du Gros, Méga Centre de
146 +liquidation, St-Jérôme Auto Dépôt, Ste-Foy Chrysler, Desmeules, Entrepôt Auto
147 +Durocher…
148 +
149 +**Toutes les régions** — Montérégie, Capitale-Nationale, Laurentides, Mauricie,
150 +Laval, Estrie, Outaouais, Centre-du-Québec, Chaudière-Appalaches, Lanaudière,
151 +Montréal, Saguenay–Lac-Saint-Jean, Bas-Saint-Laurent, Abitibi-Témiscamingue,
152 +Côte-Nord, Gaspésie…
153 +
154 +Chaque source non connectable est **documentée avec sa raison** dans
155 +`data/sources.json` (ex. : doublon d'inventaire d'un autre site du même
156 +groupe, vérifié par VIN).
157 +
158 +## Production
159 +
160 +Déployé sous **PM2** (3 processus) derrière **ngrok** :
161 +
162 +```
163 +auto-ka-web .venv/bin/python run.py serve 8095 # API + frontend
164 +auto-ka-sync .venv/bin/python run.py watch 120 # resync 2 h
165 +auto-ka-ngrok ngrok http --url=www.auto-ka.com 8095 # tunnel
166 +```
167 +
168 +Philosophie d'exploitation : **on ne pousse que le code — le serveur maintient
169 +ses données lui-même.**
170 +
171 +## Principes
172 +
173 +1. **À la source** — les annonces viennent des sites des concessionnaires
174 + eux-mêmes, jamais des plateformes d'agrégation existantes.
175 +2. **Politesse** — délai ≥ 1 s entre requêtes, cache des pages détail
176 + (revalidation hebdomadaire), backoff sur rate-limit, User-Agent identifié.
177 +3. **Fidélité** — aucun prix inventé : si la source n'affiche pas de prix,
178 + `price = null` (« Prix sur demande »).
179 +4. **Traçabilité** — chaque fiche renvoie vers l'annonce originale.
180 +5. **Robustesse** — un connecteur qui casse n'affecte jamais les autres ;
181 + un 404 (véhicule vendu) ne bloque jamais une source ; détection de dérive
182 + (chute de volume → retraits suspendus + alerte).
183 +
184 +---
185 +
186 +<div align="center">
187 +
188 +## Auteur
189 +
190 +**Simon-Pierre Boucher**
191 +
192 +[![Email](https://img.shields.io/badge/contact@spboucher.ai-17181c?style=for-the-badge&logo=minutemailer&logoColor=ff5a2a)](mailto:contact@spboucher.ai)
193 +[![Git](https://img.shields.io/badge/git.spboucher.ai-17181c?style=for-the-badge&logo=git&logoColor=ff5a2a)](https://git.spboucher.ai)
194 +
195 +*Conçu, construit et déployé en une journée — du sondage de plateformes
196 +(60+ sites classifiés) aux 99 connecteurs en production.*
197 +
198 +© 2026 Simon-Pierre Boucher — tous droits réservés.
199 +
200 +</div>
added autoka/__init__.py +5 −0
@@ -0,0 +1,5 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# -----------------------------------------------------------------------------
5 +__version__ = "1.0.0"
added autoka/connectors/__init__.py +30 −0
@@ -0,0 +1,30 @@
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/__init__.py : registre AUTO-DÉCOUVRANT des connecteurs
5 +# Tout module de ce paquet contenant une sous-classe de BaseConnector avec un
6 +# source_id non vide est enregistré automatiquement — aucun fichier partagé
7 +# à modifier pour ajouter un connecteur.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import importlib
12 +import pkgutil
13 +import sys
14 +
15 +from .base import BaseConnector
16 +
17 +CONNECTORS: dict[str, type[BaseConnector]] = {}
18 +
19 +for _mod in pkgutil.iter_modules(__path__):
20 + if _mod.name in ("base", "__init__") or _mod.name.startswith("_"):
21 + continue
22 + try:
23 + module = importlib.import_module(f"{__name__}.{_mod.name}")
24 + except Exception as exc: # un connecteur cassé ne bloque pas les autres
25 + print(f"[auto-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr)
26 + continue
27 + for obj in vars(module).values():
28 + if (isinstance(obj, type) and issubclass(obj, BaseConnector)
29 + and obj is not BaseConnector and getattr(obj, "source_id", "")):
30 + CONNECTORS[obj.source_id] = obj
added autoka/connectors/albioccasion.py +287 −0
@@ -0,0 +1,287 @@
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/albioccasion.py : connecteur ALBI Occasion (albioccasion.com),
5 +# la division occasion d'ALBI le Géant — très grosse chaîne multi-
6 +# succursales (Mascouche, Repentigny, Blainville, Laval, Rive-Nord…),
7 +# ~1 100+ véhicules d'occasion.
8 +#
9 +# Plateforme : ASP.NET WebForms maison. La liste /fr/inventaire/?page=N
10 +# (12 véhicules/page, « Page X de Y » dans le HTML) référence chaque page
11 +# détail /fr/vehicule/<id>/<slug>/. La page détail expose un microdata
12 +# schema.org complet (itemprop) : prix, km, VIN, année, version,
13 +# transmission, portes, places, couleurs, carrosserie + des lignes de
14 +# spécifications (SpecLine : entraînement, catégorie…), la galerie photos
15 +# (S3 enzo-production …/vehicules_medias/…) et la succursale réelle du
16 +# véhicule (lien Google Maps « ALBI Occasion <Ville> »).
17 +#
18 +# Pages détail en cache BD (clé hebdomadaire — le prix est revalidé chaque
19 +# semaine). AUTOKA_MAX_PAGES limite la pagination liste et
20 +# AUTOKA_MAX_DETAILS plafonne les vraies requêtes détail par sync.
21 +# -----------------------------------------------------------------------------
22 +from __future__ import annotations
23 +
24 +import datetime
25 +import html as htmllib
26 +import os
27 +import re
28 +import urllib.parse
29 +
30 +from ..schema import Vehicle
31 +from .base import BaseConnector
32 +
33 +_LINK_RE = re.compile(r'href="/fr/vehicule/(\d+)/([^"/]+)/?"')
34 +_TOTAL_PAGES_RE = re.compile(r'TotalPagesLabel">\s*(\d+)')
35 +
36 +_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
37 +_ITEMPROP_CONTENT_RE = r'itemprop="{name}"[^>]*content="([^"]*)"'
38 +_ITEMPROP_TEXT_RE = r'itemprop="{name}"[^>]*>\s*([^<]+)'
39 +_SPEC_RE = re.compile(r'class="SpecLine">\s*([^:<]+):\s*(?:<span[^>]*>)?'
40 + r"\s*([^<]*)", re.S)
41 +_STOCK_RE = re.compile(r'data\.stock\s*=\s*"([^"]+)"')
42 +_BRANCH_RE = re.compile(r"maps/dir//([^,\"]+),")
43 +_IMG_RE = re.compile(r'https://[^"\'\s\\]+/vehicules_medias/[^"\'\s\\]+'
44 + r"\.(?:jpe?g|png|webp)", re.I)
45 +
46 +_EXCLUDE_RE = re.compile(
47 + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|"
48 + r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I)
49 +
50 +
51 +def _clean(text) -> str:
52 + if not text:
53 + return ""
54 + out = htmllib.unescape(str(text))
55 + out = re.sub(r"<[^>]+>", " ", out)
56 + return re.sub(r"\s{2,}", " ", out).strip()
57 +
58 +
59 +def _prop(html: str, name: str) -> str:
60 + m = re.search(_ITEMPROP_CONTENT_RE.format(name=name), html)
61 + if m and m.group(1).strip():
62 + return _clean(m.group(1))
63 + m = re.search(_ITEMPROP_TEXT_RE.format(name=name), html)
64 + return _clean(m.group(1)) if m else ""
65 +
66 +
67 +def _to_float(text) -> float | None:
68 + if text in (None, ""):
69 + return None
70 + t = re.sub(r"[^\d.]", "", str(text))
71 + try:
72 + return float(t) if t else None
73 + except ValueError:
74 + return None
75 +
76 +
77 +class AlbiOccasion(BaseConnector):
78 + """ALBI Occasion — liste ASP.NET paginée + pages détail (microdata)."""
79 +
80 + source_id = "albioccasion"
81 + base_url = "https://www.albioccasion.com"
82 + dealer_name = "ALBI Occasion"
83 + city = "Mascouche"
84 + request_delay = 2.5 # le site retourne 429 sous ~1 s/requête
85 + max_pages = 150 # garde-fou dur (~95 pages réelles)
86 + max_details = 400 # plafond de vraies requêtes par sync
87 +
88 + def get(self, url, **kw):
89 + """GET avec backoff sur 429 (rate-limit ALBI)."""
90 + import time as _t
91 +
92 + import requests as _rq
93 + for attempt in range(4):
94 + try:
95 + return super().get(url, **kw)
96 + except _rq.exceptions.HTTPError as exc:
97 + if exc.response is not None and exc.response.status_code == 429 \
98 + and attempt < 3:
99 + _t.sleep(20 * (attempt + 1)) # 20 s, 40 s, 60 s
100 + continue
101 + raise
102 +
103 + # -- pages liste -> [(id, slug)] --------------------------------------------
104 + def _vehicle_refs(self) -> list[tuple[str, str]]:
105 + cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
106 + refs: list[tuple[str, str]] = []
107 + seen: set[str] = set()
108 +
109 + page, last_page = 1, 1
110 + while page <= min(last_page, cap, self.max_pages):
111 + url = f"{self.base_url}/fr/inventaire/"
112 + if page > 1:
113 + url += f"?page={page}"
114 + html = self.get(url).text
115 + if page == 1:
116 + m = _TOTAL_PAGES_RE.search(html)
117 + if m:
118 + last_page = int(m.group(1))
119 + new = 0
120 + for veh_id, slug in _LINK_RE.findall(html):
121 + if veh_id not in seen:
122 + seen.add(veh_id)
123 + refs.append((veh_id, slug))
124 + new += 1
125 + if new == 0: # page recyclée / fin atteinte
126 + break
127 + page += 1
128 + return refs
129 +
130 + # -- page détail -> payload ---------------------------------------------------
131 + def _fetch_detail(self, url: str) -> dict:
132 + html = self.get(url).text
133 + payload: dict = {}
134 +
135 + m = _H1_RE.search(html)
136 + if m:
137 + payload["title"] = _clean(m.group(1))
138 +
139 + for name in ("price", "vehicleModelDate", "vehicleConfiguration",
140 + "vehicleIdentificationNumber", "mileageFromOdometer",
141 + "vehicleTransmission", "color", "vehicleInteriorColor",
142 + "bodyType", "numberOfDoors", "vehicleSeatingCapacity",
143 + "description", "name"):
144 + value = _prop(html, name)
145 + if value:
146 + payload[name] = value
147 +
148 + specs: dict[str, str] = {}
149 + for label, value in _SPEC_RE.findall(html):
150 + label, value = _clean(label), _clean(value)
151 + if label and value:
152 + specs.setdefault(label, value)
153 + payload["specs"] = specs
154 +
155 + m = _STOCK_RE.search(html)
156 + if m:
157 + payload["stock"] = m.group(1).strip()
158 + m = re.search(r'data\.make\s*=\s*"([^"]*)"', html)
159 + if m:
160 + payload["make"] = m.group(1).strip()
161 + m = re.search(r'data\.model\s*=\s*"([^"]*)"', html)
162 + if m:
163 + payload["model"] = m.group(1).strip()
164 +
165 + m = _BRANCH_RE.search(html)
166 + if m:
167 + payload["branch"] = urllib.parse.unquote_plus(m.group(1)).strip()
168 +
169 + images, seen = [], set()
170 + for u in _IMG_RE.findall(html):
171 + if u not in seen:
172 + seen.add(u)
173 + images.append(u)
174 + payload["images"] = images[:20]
175 + return payload
176 +
177 + # -- contrat ---------------------------------------------------------------
178 + def fetch(self) -> list[Vehicle]:
179 + refs = self._vehicle_refs()
180 + week = datetime.date.today().isocalendar()
181 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
182 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
183 +
184 + vehicles: list[Vehicle] = []
185 + real_fetches = 0
186 + for veh_id, slug in refs:
187 + url = f"{self.base_url}/fr/vehicule/{veh_id}/{slug}/"
188 +
189 + def _fetch(u=url):
190 + return self._fetch_detail(u)
191 +
192 + if real_fetches >= cap: # plafond : cache seulement
193 + from .. import db
194 + if self._detail_con is None:
195 + self._detail_con = db.connect()
196 + data = db.get_cached_detail(self._detail_con, self.source_id,
197 + veh_id, cache_key)
198 + if data is None:
199 + continue
200 + else:
201 + before = self._last_request
202 + try:
203 + data = self.detail(veh_id, cache_key, _fetch)
204 + except Exception:
205 + # page disparue (vendu) ou erreur ponctuelle : continuer
206 + if self._last_request != before:
207 + real_fetches += 1
208 + continue
209 + if self._last_request != before:
210 + real_fetches += 1
211 + if not data:
212 + continue
213 + veh = self._to_vehicle(veh_id, url, data)
214 + if veh is not None:
215 + vehicles.append(veh)
216 + return vehicles
217 +
218 + # -- payload -> Vehicle ------------------------------------------------------
219 + def _to_vehicle(self, veh_id: str, url: str, d: dict) -> Vehicle | None:
220 + specs = d.get("specs") or {}
221 + make = _clean(d.get("make"))
222 + model = _clean(d.get("model"))
223 + title = _clean(d.get("name") or d.get("title"))
224 + if not title and not (make or model):
225 + return None
226 + body = _clean(d.get("bodyType") or specs.get("Catégorie"))
227 + if _EXCLUDE_RE.search(f"{make} {model} {body} {title}"):
228 + return None
229 +
230 + year = None
231 + m = re.search(r"(19|20)\d{2}", str(d.get("vehicleModelDate") or ""))
232 + if m:
233 + year = int(m.group(0))
234 +
235 + branch = _clean(d.get("branch"))
236 + city = self.city
237 + if branch:
238 + # « ALBI Occasion Mascouche » -> ville ; les succursales de
239 + # marque (« ALBI Le Géant Mazda »…) restent sur la ville de repli
240 + m = re.match(r"ALBI\s+Occasion\s+(.+)$", branch, re.I)
241 + if m:
242 + city = m.group(1).strip()
243 +
244 + details: dict = {}
245 + if branch:
246 + details["branch"] = branch
247 + if specs.get("Type"):
248 + details["size_class"] = specs["Type"]
249 + if specs.get("Garantie manufacturier"):
250 + details["manufacturer_warranty"] = specs["Garantie manufacturier"]
251 +
252 + price = _to_float(d.get("price"))
253 + km = _to_float(d.get("mileageFromOdometer"))
254 +
255 + return Vehicle(
256 + source=self.source_id,
257 + external_id=veh_id,
258 + url=url,
259 + title=title or " ".join(x for x in (make, model, year or "") if x),
260 + make=make,
261 + model=model,
262 + trim=_clean(d.get("vehicleConfiguration")),
263 + year=year,
264 + price=price,
265 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
266 + mileage_km=km,
267 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
268 + transmission=_clean(d.get("vehicleTransmission")
269 + or specs.get("Transmission")),
270 + drivetrain=_clean(specs.get("Entraînement")),
271 + body_type=body,
272 + exterior_color=_clean(d.get("color")
273 + or specs.get("Couleur extérieure")),
274 + interior_color=_clean(d.get("vehicleInteriorColor")
275 + or specs.get("Couleur intérieure")),
276 + doors=int(_to_float(d.get("numberOfDoors")
277 + or specs.get("Portes")) or 0) or None,
278 + seats=int(_to_float(d.get("vehicleSeatingCapacity")
279 + or specs.get("Places Assises")) or 0) or None,
280 + vin=_clean(d.get("vehicleIdentificationNumber")),
281 + stock_number=_clean(d.get("stock")),
282 + dealer_name=branch or self.dealer_name,
283 + city=city,
284 + description=_clean(d.get("description"))[:4000],
285 + details=details,
286 + images=d.get("images") or [],
287 + )
added autoka/connectors/autodurocher.py +237 −0
@@ -0,0 +1,237 @@
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/autodurocher.py : connecteur Entrepôt Auto Durocher
5 +# (autodurocher.com), gros marchand indépendant de Mirabel (~200 véhicules,
6 +# occasion seulement).
7 +#
8 +# Plateforme : WordPress + Elementor + JetEngine (CPT « vehicles »).
9 +# Le sitemap /fr/sitemap_index.xml expose des vehicles-sitemapN.xml qui ne
10 +# recensent QUE l'inventaire courant (/fr/vehicles/<slug>/). Chaque page
11 +# détail embarque un JSON-LD (script class="fute-custom-schema-vehicles",
12 +# Organization -> makesOffer -> itemOffered @type Car) : prix, km,
13 +# transmission, couleur, carrosserie, rouage, année, marque, modèle — et la
14 +# galerie photos (CDN AutoTrader 1s-photomanager). Le no de stock est le
15 +# dernier segment du slug. Le JSON contient des sauts de ligne bruts ->
16 +# json.loads(strict=False).
17 +#
18 +# Pages détail en cache BD (clé hebdomadaire) ; AUTOKA_MAX_DETAILS (env)
19 +# plafonne les vraies requêtes détail par synchronisation.
20 +# -----------------------------------------------------------------------------
21 +from __future__ import annotations
22 +
23 +import datetime
24 +import html as htmllib
25 +import json
26 +import os
27 +import re
28 +
29 +from ..schema import Vehicle
30 +from .base import BaseConnector
31 +
32 +_SITEMAP_RE = re.compile(r"vehicles-sitemap\d+\.xml$")
33 +_VEH_URL_RE = re.compile(r"/fr/vehicles/([a-z0-9-]+)/?$")
34 +_STOCK_RE = re.compile(r"-(?:19|20)\d{2}-([a-z0-9]+)$")
35 +
36 +_SCHEMA_RE = re.compile(
37 + r'<script type=["\']application/ld\+json["\'][^>]*'
38 + r'class="fute-custom-schema-vehicles"[^>]*>(.*?)</script>', re.S)
39 +_LD_RE = re.compile(
40 + r'<script type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.S)
41 +_IMG_RE = re.compile(r'https://1s-photomanager[^"\'\s\\<>]+', re.I)
42 +
43 +_FUEL_HINT_RE = re.compile(
44 + r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|électrique|"
45 + r"electrique|electric|\bev\b|diesel|tdi)", re.I)
46 +
47 +_EXCLUDE_RE = re.compile(
48 + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|"
49 + r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I)
50 +
51 +
52 +def _clean(text) -> str:
53 + if not text:
54 + return ""
55 + out = htmllib.unescape(str(text))
56 + out = re.sub(r"<[^>]+>", " ", out)
57 + return re.sub(r"\s+", " ", out).strip()
58 +
59 +
60 +def _num(value):
61 + try:
62 + v = float(str(value).replace(",", "").replace(" ", ""))
63 + except (TypeError, ValueError):
64 + return None
65 + return v if v else None
66 +
67 +
68 +class AutoDurocher(BaseConnector):
69 + """Entrepôt Auto Durocher — sitemaps vehicles + JSON-LD des pages détail."""
70 +
71 + source_id = "autodurocher"
72 + base_url = "https://www.autodurocher.com"
73 + dealer_name = "Entrepôt Auto Durocher"
74 + city = "Mirabel"
75 + request_delay = 1.0
76 + max_details = 300 # plafond de vraies requêtes par sync
77 +
78 + # -- sitemaps ----------------------------------------------------------------
79 + def _vehicle_slugs(self) -> list[str]:
80 + index = self.get(f"{self.base_url}/fr/sitemap_index.xml").text
81 + maps = [loc.strip() for loc in re.findall(r"<loc>([^<]+)</loc>", index)
82 + if _SITEMAP_RE.search(loc.strip())]
83 + slugs: list[str] = []
84 + for sitemap_url in maps:
85 + try:
86 + xml = self.get(sitemap_url).text
87 + except Exception:
88 + continue # un sitemap HS ne bloque pas
89 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
90 + m = _VEH_URL_RE.search(loc.strip())
91 + if m:
92 + slugs.append(m.group(1))
93 + return sorted(set(slugs))
94 +
95 + # -- page détail -> payload ---------------------------------------------------
96 + def _fetch_detail(self, url: str) -> dict:
97 + html = self.get(url).text
98 + payload: dict = {}
99 +
100 + blocks = _SCHEMA_RE.findall(html) or _LD_RE.findall(html)
101 + for block in blocks:
102 + try:
103 + data = json.loads(block.strip(), strict=False)
104 + except ValueError:
105 + continue
106 + offer = data.get("makesOffer") or {}
107 + car = offer.get("itemOffered") or {}
108 + if not isinstance(car, dict) or car.get("@type") != "Car":
109 + continue
110 + spec = offer.get("priceSpecification") or {}
111 + payload["price"] = spec.get("price")
112 + payload["car"] = {k: v for k, v in car.items()
113 + if not str(k).startswith("@")}
114 + break
115 +
116 + images, seen = [], set()
117 + for u in _IMG_RE.findall(html):
118 + if u not in seen:
119 + seen.add(u)
120 + images.append(u)
121 + payload["images"] = images[:20]
122 + return payload
123 +
124 + # -- contrat ---------------------------------------------------------------
125 + def fetch(self) -> list[Vehicle]:
126 + slugs = self._vehicle_slugs()
127 + week = datetime.date.today().isocalendar()
128 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
129 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
130 +
131 + vehicles: list[Vehicle] = []
132 + real_fetches = 0
133 + for slug in slugs:
134 + url = f"{self.base_url}/fr/vehicles/{slug}/"
135 +
136 + def _fetch(u=url):
137 + return self._fetch_detail(u)
138 +
139 + if real_fetches >= cap: # plafond : cache seulement
140 + from .. import db
141 + if self._detail_con is None:
142 + self._detail_con = db.connect()
143 + data = db.get_cached_detail(self._detail_con, self.source_id,
144 + slug, cache_key)
145 + if data is None:
146 + continue
147 + else:
148 + before = self._last_request
149 + try:
150 + data = self.detail(slug, cache_key, _fetch)
151 + except Exception:
152 + # page disparue (vendu) ou erreur ponctuelle : continuer
153 + if self._last_request != before:
154 + real_fetches += 1
155 + continue
156 + if self._last_request != before:
157 + real_fetches += 1
158 + if not data or not data.get("car"):
159 + continue
160 + veh = self._to_vehicle(slug, url, data)
161 + if veh is not None:
162 + vehicles.append(veh)
163 + return vehicles
164 +
165 + # -- payload -> Vehicle ------------------------------------------------------
166 + def _to_vehicle(self, slug: str, url: str, d: dict) -> Vehicle | None:
167 + car = d.get("car") or {}
168 + make = _clean(car.get("manufacturer") or car.get("brand"))
169 + model = _clean(car.get("model"))
170 + name = _clean(str(car.get("name") or "").split("\n")[0])
171 + if not (make or model or name):
172 + return None
173 + body = _clean(car.get("bodyType"))
174 + if _EXCLUDE_RE.search(f"{make} {model} {body} {name}"):
175 + return None
176 +
177 + year = None
178 + m = re.search(r"(19|20)\d{2}", str(car.get("vehicleModelDate")
179 + or car.get("modelDate") or ""))
180 + if m:
181 + year = int(m.group(0))
182 +
183 + # « 2018 - Audi A3 » -> titre standard
184 + title = re.sub(r"^(?:19|20)\d{2}\s*-\s*", "", name)
185 + title = " ".join(x for x in (title or f"{make} {model}",
186 + str(year or "")) if x)
187 +
188 + price = _num(d.get("price"))
189 + km = _num(car.get("mileageFromOdometer"))
190 +
191 + stock = ""
192 + m = _STOCK_RE.search(slug)
193 + if m:
194 + stock = m.group(1).upper()
195 +
196 + engine = ""
197 + eng = car.get("vehicleEngine")
198 + if isinstance(eng, dict):
199 + engine = _clean(eng.get("name"))
200 +
201 + description = _clean(car.get("description"))[:4000]
202 + # indice carburant : le NOM seulement (la description parle de
203 + # « sièges électriques » etc. -> faux positifs)
204 + fuel = ""
205 + m = _FUEL_HINT_RE.search(name)
206 + if m:
207 + fuel = m.group(1)
208 +
209 + images = d.get("images") or []
210 + ld_img = car.get("image")
211 + if not images and isinstance(ld_img, str):
212 + images = [ld_img]
213 +
214 + return Vehicle(
215 + source=self.source_id,
216 + external_id=slug,
217 + url=url,
218 + title=title,
219 + make=make,
220 + model=model,
221 + year=year,
222 + price=price,
223 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
224 + mileage_km=km,
225 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
226 + transmission=_clean(car.get("vehicleTransmission")),
227 + fuel=fuel,
228 + drivetrain=_clean(car.get("driveWheelConfiguration")),
229 + body_type=body,
230 + exterior_color=_clean(car.get("color")),
231 + engine=engine,
232 + stock_number=stock,
233 + dealer_name=self.dealer_name,
234 + city=self.city,
235 + description=description,
236 + images=images,
237 + )
added autoka/connectors/automobileendirect.py +222 −0
@@ -0,0 +1,222 @@
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/automobileendirect.py : connecteur Automobile En Direct
5 +# (automobileendirect.com), grande chaîne d'occasion — Saint-Constant,
6 +# Laval, Saint-Hubert, Île-Perrot, Québec, Gatineau.
7 +#
8 +# Stratégie : le site est une SPA React alimentée par une API REST
9 +# publique non authentifiée : https://api.automobileendirect.com
10 +# GET /cars?page=N (en-tête accept-language: fr)
11 +# -> {"totalResults": ~1725, "results": [22 véhicules]}
12 +# chaque résultat : vin, slug, année/marque/modèle/version, prix
13 +# courant + régulier (CAD), km, transmission, carburant, carrosserie,
14 +# couleur, no de stock, photos, succursale (store.title.fr = ville).
15 +# GET /cars/detailed/<vin> (optionnel, mise en cache BD, clé statique :
16 +# contenu quasi immuable, le prix/km frais viennent de la liste)
17 +# -> traction, portes, passagers, moteur, couleurs, description,
18 +# options, anciens propriétaires.
19 +# ~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 +#
22 +# URL publique d'une fiche : /auto-usage/<slug>/<vin> (route SPA fr).
23 +# AUTOKA_MAX_PAGES (env) limite le nombre de pages de liste (tests).
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import os
28 +import re
29 +
30 +from ..schema import Vehicle
31 +from .base import BaseConnector
32 +
33 +API = "https://api.automobileendirect.com"
34 +SITE = "https://www.automobileendirect.com"
35 +
36 +_PER_PAGE = 22 # taille fixe côté serveur
37 +_AWD_RE = re.compile(r"\b(awd|4wd|4x4|quattro|xdrive|4matic|4motion)\b", re.I)
38 +
39 +
40 +def _fr(value) -> str:
41 + """Champ bilingue {'fr':…,'en':…} ou {'id':…,'title':{…}} -> texte fr."""
42 + if isinstance(value, dict):
43 + if "title" in value:
44 + return _fr(value["title"])
45 + return str(value.get("fr") or value.get("en") or "").strip()
46 + return str(value or "").strip()
47 +
48 +
49 +class AutomobileEnDirect(BaseConnector):
50 + """Automobile En Direct — inventaire complet via l'API REST de la SPA."""
51 +
52 + source_id = "automobileendirect"
53 + dealer_name = "Automobile En Direct"
54 + request_delay = 1.0
55 + max_pages = 200 # garde-fou dur (~80 pages réelles)
56 + max_details = 400 # plafond de vraies requêtes détail / sync
57 +
58 + def __init__(self) -> None:
59 + super().__init__()
60 + self.session.headers["accept-language"] = "fr"
61 +
62 + # -- API ----------------------------------------------------------------
63 + def _list_page(self, page: int) -> dict:
64 + return self.get(f"{API}/cars", params={"page": page}).json()
65 +
66 + def _fetch_detail(self, vin: str) -> dict:
67 + data = self.get(f"{API}/cars/detailed/{vin}").json()
68 + # ne garder que les champs utiles (cache BD compact)
69 + keep = ("traction", "doors", "passengers", "engineDescription",
70 + "engineSize", "cylinders", "colors", "description",
71 + "options", "previousOwners", "fuelConsumption")
72 + return {k: data[k] for k in keep if data.get(k) is not None}
73 +
74 + # -- contrat --------------------------------------------------------------
75 + def fetch(self) -> list[Vehicle]:
76 + page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
77 + detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
78 +
79 + rows: list[dict] = []
80 + seen: set[str] = set()
81 + page, total = 1, None
82 + while page <= min(page_cap, self.max_pages):
83 + data = self._list_page(page)
84 + results = data.get("results") or []
85 + if not results:
86 + break
87 + total = data.get("totalResults") or total
88 + for r in results:
89 + vin = str(r.get("vin") or "").strip()
90 + if vin and vin not in seen:
91 + seen.add(vin)
92 + rows.append(r)
93 + if total is not None and page * _PER_PAGE >= total:
94 + break
95 + page += 1
96 +
97 + vehicles: list[Vehicle] = []
98 + real_fetches = 0
99 + for r in rows:
100 + extra: dict = {}
101 + vin = str(r["vin"])
102 + if real_fetches < detail_cap:
103 + before = self._last_request
104 + # clé statique : le détail (traction, moteur, options…) ne
105 + # change pas ; prix/km frais viennent toujours de la liste
106 + try:
107 + extra = self.detail(vin, "v1",
108 + lambda v=vin: self._fetch_detail(v))
109 + except Exception:
110 + extra = {} # détail indisponible : données liste suffisent
111 + if self._last_request != before:
112 + real_fetches += 1
113 + veh = self._to_vehicle(r, extra)
114 + if veh is not None:
115 + vehicles.append(veh)
116 + return vehicles
117 +
118 + # -- payload API -> Vehicle -------------------------------------------------
119 + def _to_vehicle(self, r: dict, extra: dict) -> Vehicle | None:
120 + if r.get("sold") or r.get("hidden") or r.get("removedFromInventory"):
121 + return None
122 + vin = str(r.get("vin") or "").strip()
123 + slug = str(r.get("slug") or "").strip()
124 + if not vin or not slug:
125 + return None
126 +
127 + year = r.get("year")
128 + make = str(r.get("make") or "").strip()
129 + model = str(r.get("model") or "").strip()
130 + trim = str(r.get("trim") or "").strip()
131 +
132 + prices = r.get("prices") or {}
133 + price = prices.get("current") or prices.get("regular")
134 + try:
135 + price = float(price) if price else None
136 + except (TypeError, ValueError):
137 + price = None
138 +
139 + km = r.get("kilometers")
140 + try:
141 + km = float(km) if km not in (None, "") else None
142 + except (TypeError, ValueError):
143 + km = None
144 +
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"]]
149 +
150 + # traction : page détail si dispo, sinon indice AWD/4x4 dans la version
151 + drivetrain = _fr(extra.get("traction"))
152 + if not drivetrain and _AWD_RE.search(trim):
153 + drivetrain = "Intégrale / 4x4"
154 +
155 + colors = extra.get("colors") or {}
156 + interior = _fr(colors.get("interior")) if isinstance(colors, dict) else ""
157 +
158 + engine = str(extra.get("engineDescription") or "").strip()
159 + if not engine and extra.get("engineSize"):
160 + cyl = extra.get("cylinders")
161 + engine = f"{extra['engineSize']}L" + (f" {cyl} cyl." if cyl else "")
162 +
163 + description = _fr(extra.get("description")) or _fr(r.get("punchline"))
164 +
165 + features: list[str] = []
166 + for opt in extra.get("options") or []:
167 + label = _fr(opt.get("title") if isinstance(opt, dict) else opt) \
168 + if isinstance(opt, (dict, str)) else ""
169 + if label:
170 + features.append(label)
171 +
172 + details: dict = {}
173 + regular = prices.get("regular")
174 + if regular and price and regular != price:
175 + details["regular_price"] = regular
176 + if r.get("electricRange"):
177 + details["electric_range_km"] = r["electricRange"]
178 + if extra.get("previousOwners") is not None:
179 + details["previous_owners"] = extra["previousOwners"]
180 + if r.get("carfaxAmount") is not None:
181 + details["carfax_reports"] = r["carfaxAmount"]
182 +
183 + try:
184 + doors = int(extra["doors"]) if extra.get("doors") else None
185 + except (TypeError, ValueError):
186 + doors = None
187 + try:
188 + seats = int(extra["passengers"]) if extra.get("passengers") else None
189 + except (TypeError, ValueError):
190 + seats = None
191 +
192 + return Vehicle(
193 + source=self.source_id,
194 + external_id=vin,
195 + url=f"{SITE}/auto-usage/{slug}/{vin}",
196 + title=" ".join(str(x) for x in (year, make, model) if x),
197 + make=make,
198 + model=model,
199 + trim=trim,
200 + year=int(year) if year else None,
201 + price=price,
202 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
203 + mileage_km=km,
204 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
205 + transmission=_fr(r.get("transmission")),
206 + fuel=_fr(r.get("fuelType")),
207 + drivetrain=drivetrain,
208 + body_type=_fr(r.get("body")),
209 + exterior_color=_fr(r.get("exteriorColor")),
210 + interior_color=interior,
211 + engine=engine,
212 + doors=doors,
213 + seats=seats,
214 + vin=vin,
215 + stock_number=str(r.get("stockNumber") or ""),
216 + dealer_name=self.dealer_name,
217 + city=_fr(r.get("store")),
218 + description=description[:4000],
219 + features=features[:80],
220 + details=details,
221 + images=images[:20],
222 + )
added autoka/connectors/base.py +113 −0
@@ -0,0 +1,113 @@
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/base.py : classe de base des connecteurs + backends de fetch
5 +# (requests direct, ou Firecrawl pour JS/Cloudflare)
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import os
10 +import time
11 +
12 +import requests
13 +
14 +from ..schema import Vehicle
15 +
16 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
17 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
18 + "AutoKaBot/1.0 (+contact@spboucher.ai)")
19 +
20 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
21 +
22 +
23 +class BaseConnector:
24 + """Un connecteur = un adaptateur propre à un site de concessionnaire.
25 +
26 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
27 + la liste complète des véhicules actuellement affichés sur le site.
28 + Le pipeline (ingest.py) s'occupe du diff avec la base de données.
29 + """
30 +
31 + source_id: str = ""
32 + request_delay: float = 0.6 # politesse entre requêtes
33 + timeout: int = 30
34 + use_detail_cache: bool = True # cache BD des pages détail
35 +
36 + def __init__(self) -> None:
37 + self.session = requests.Session()
38 + self.session.headers["User-Agent"] = USER_AGENT
39 + self._last_request = 0.0
40 + self._detail_con = None
41 +
42 + # -- backends -------------------------------------------------------------
43 + def get(self, url: str, **kw) -> requests.Response:
44 + """GET direct avec throttling poli.
45 +
46 + Retry (2×) sur les connexions keep-alive fermées par le serveur —
47 + fréquent chez D2C Media / SM360 après une période d'inactivité.
48 + """
49 + wait = self.request_delay - (time.time() - self._last_request)
50 + if wait > 0:
51 + time.sleep(wait)
52 + last_exc: Exception | None = None
53 + for attempt in range(3):
54 + try:
55 + resp = self.session.get(url, timeout=self.timeout, **kw)
56 + self._last_request = time.time()
57 + resp.raise_for_status()
58 + return resp
59 + except (requests.exceptions.ConnectionError,
60 + requests.exceptions.ChunkedEncodingError) as exc:
61 + last_exc = exc
62 + self.session.close() # repartir sur une connexion neuve
63 + time.sleep(1.5 * (attempt + 1))
64 + self._last_request = time.time()
65 + raise last_exc # type: ignore[misc]
66 +
67 + def get_rendered(self, url: str, wait_ms: int = 0) -> str:
68 + """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
69 +
70 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
71 + À utiliser pour les sites SPA ou derrière Cloudflare.
72 + """
73 + key = os.environ.get("FIRECRAWL_API_KEY")
74 + if not key:
75 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
76 + payload: dict = {"url": url, "formats": ["html"]}
77 + if wait_ms:
78 + payload["waitFor"] = wait_ms
79 + resp = requests.post(
80 + FIRECRAWL_API,
81 + json=payload,
82 + headers={"Authorization": f"Bearer {key}"},
83 + timeout=120,
84 + )
85 + resp.raise_for_status()
86 + data = resp.json()
87 + return (data.get("data") or {}).get("html", "")
88 +
89 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
90 + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
91 + l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé.
92 +
93 + Permet d'extraire les champs riches (description, équipements, VIN…)
94 + sans revisiter chaque page détail à chaque synchronisation.
95 + `fetch_fn` doit retourner un dict JSON-sérialisable.
96 + """
97 + if not self.use_detail_cache:
98 + return fetch_fn() or {}
99 + from .. import db
100 + if self._detail_con is None:
101 + self._detail_con = db.connect()
102 + cached = db.get_cached_detail(self._detail_con, self.source_id,
103 + str(external_id), key)
104 + if cached is not None:
105 + return cached
106 + payload = fetch_fn() or {}
107 + db.put_cached_detail(self._detail_con, self.source_id,
108 + str(external_id), key, payload)
109 + return payload
110 +
111 + # -- contrat --------------------------------------------------------------
112 + def fetch(self) -> list[Vehicle]:
113 + raise NotImplementedError
added autoka/connectors/central_dealers.py +305 −0
@@ -0,0 +1,305 @@
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/central_dealers.py : connecteurs des concessionnaires sur la
5 +# plateforme AMVOQ « Focus 360 / Instavid360 » (thème WordPress
6 +# `theme_central`, inventaire Vue.js alimenté par un index Solr).
7 +#
8 +# Stratégie : le thème expose un endpoint JSON non authentifié
9 +# /wp-content/themes/theme_central/include/functions/RechercheVoitures.php
10 +# (exige seulement un en-tête Referer du même site) qui renvoie les
11 +# documents Solr complets de l'inventaire : gvo_id, année, marque, modèle,
12 +# sous-modèle, prix, km, transmission, carburant, traction, carrosserie,
13 +# couleurs, portes, VIN, no d'unité, municipalité/région et 1re photo.
14 +#
15 +# La page détail (/vehicule-usage/<slug>/<gvo_id>/, rendue côté serveur,
16 +# le slug est ignoré par le serveur) complète : galerie photos complète,
17 +# description, options/équipements, nb de passagers, cylindrée. Ces pages
18 +# sont mises en cache BD (clé statique v1 : le prix/km frais viennent
19 +# toujours de l'API liste, le contenu détail est quasi immuable).
20 +#
21 +# vehicule_type_i discrimine le type d'inventaire (1 = automobile,
22 +# 2 = moto, 3 = VR) ; on garde le type 1 + un garde-fou regex sur la
23 +# catégorie/carrosserie (motoneige, VTT, Classe A/B/C, récréatif…).
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import html as htmllib
28 +import os
29 +import re
30 +
31 +from ..schema import Vehicle, strip_accents
32 +from .base import BaseConnector
33 +
34 +IMG_HOST = "https://image.autousagee.ca"
35 +_SEARCH_PATH = ("/wp-content/themes/theme_central/include/functions/"
36 + "RechercheVoitures.php")
37 +
38 +# catégories / carrosseries qui ne sont PAS des automobiles usagées
39 +_EXCLUDE_RE = re.compile(
40 + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|\batv\b|scooter|spyder|"
41 + r"classe\s*[abc]\b|recreatif|roulotte|remorque|bateau|ponton|"
42 + r"tracteur|souffleuse|habitation|\bvr\b|campeur", re.I)
43 +
44 +
45 +def _slugify(text: str) -> str:
46 + """Slug d'URL à la façon du thème (le serveur ne le valide pas)."""
47 + t = strip_accents(text or "").lower()
48 + t = re.sub(r"[^a-z0-9]+", "-", t)
49 + return t.strip("-") or "vehicule"
50 +
51 +
52 +def _clean(text: str | None) -> str:
53 + if not text:
54 + return ""
55 + out = htmllib.unescape(str(text))
56 + return re.sub(r"\s+", " ", out).strip()
57 +
58 +
59 +def _num(value, cast=float):
60 + try:
61 + v = cast(value)
62 + except (TypeError, ValueError):
63 + return None
64 + return v if v else None
65 +
66 +
67 +class CentralConnector(BaseConnector):
68 + """Base commune aux sites `theme_central` (AMVOQ / Instavid360)."""
69 +
70 + base_url: str = ""
71 + dealer_name: str = ""
72 + city: str = ""
73 + request_delay: float = 1.0
74 + page_size: int = 200
75 +
76 + # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit
77 + # pour les tests) — au-delà, le véhicule est émis avec les données liste
78 + # seulement et sera enrichi aux syncs suivants.
79 + max_details = 400
80 +
81 + # -- API liste (Solr) -------------------------------------------------------
82 + def _api_get(self, params: dict) -> dict:
83 + url = self.base_url + _SEARCH_PATH
84 + headers = {
85 + "Referer": f"{self.base_url}/inventaire/",
86 + "Accept": "application/json, text/plain, */*",
87 + }
88 + return self.get(url, params=params, headers=headers).json()
89 +
90 + def _api_docs(self) -> list[dict]:
91 + docs: list[dict] = []
92 + page = 1
93 + while page <= 50:
94 + data = self._api_get({
95 + "order": "alphaASC",
96 + "type": 0,
97 + "nbCarsPerPage": self.page_size,
98 + "currentPage": page,
99 + })
100 + batch = data.get("data") or []
101 + docs.extend(batch)
102 + total = int((data.get("meta") or {}).get("numFound") or 0)
103 + if not batch or len(docs) >= total:
104 + break
105 + page += 1
106 + return docs
107 +
108 + # -- page détail ------------------------------------------------------------
109 + def _fetch_detail(self, url: str) -> dict:
110 + from bs4 import BeautifulSoup
111 + soup = BeautifulSoup(self.get(url).text, "html.parser")
112 + payload: dict = {}
113 +
114 + # galerie photos complète
115 + images = []
116 + for img in soup.select(".vdp__slider-image img[src]"):
117 + src = img["src"]
118 + if "autousagee.ca" in src and src not in images:
119 + images.append(src)
120 + payload["images"] = images[:30]
121 +
122 + # description libre
123 + desc = soup.select_one('div[name="description-content"]')
124 + if desc:
125 + text = _clean(desc.get_text(" "))
126 + text = re.sub(r"^DESCRIPTION DU V[ÉE]HICULE\s*", "", text, flags=re.I)
127 + payload["description"] = text[:4000]
128 +
129 + # spécifications (label -> valeur)
130 + specs: dict[str, str] = {}
131 + info = soup.select_one('div[name="informations-content"]')
132 + if info:
133 + for li in info.select("li"):
134 + spans = [_clean(s.get_text()) for s in li.find_all("span")]
135 + if len(spans) >= 2 and spans[0] and "--" not in spans[1]:
136 + specs[spans[0]] = spans[1]
137 + payload["specs"] = specs
138 +
139 + # options / équipements
140 + feats = []
141 + opts = soup.select_one('div[name="options-content"]')
142 + if opts:
143 + for li in opts.select("li"):
144 + t = _clean(li.get_text(" "))
145 + if t and t not in feats:
146 + feats.append(t)
147 + payload["features"] = feats[:80]
148 + return payload
149 +
150 + # -- doc Solr -> Vehicle ------------------------------------------------------
151 + def _to_vehicle(self, doc: dict) -> Vehicle | None:
152 + gvo_id = doc.get("vehicule_gvo_id_i")
153 + make = _clean(doc.get("vehicule_marque_nom_s"))
154 + model = _clean(doc.get("vehicule_modele_s"))
155 + if not gvo_id or not (make or model):
156 + return None
157 +
158 + year = _num(doc.get("vehicule_annee_i"), int)
159 + trim = _clean(doc.get("vehicule_sous_modele_valide_s"))
160 + slug = _slugify(f"{make} {model} {year or ''}")
161 + url = f"{self.base_url}/vehicule-usage/{slug}/{gvo_id}/"
162 +
163 + price = _num(doc.get("vehicule_prix_d"))
164 + km = _num(doc.get("vehicule_km_i"))
165 + city = _clean(doc.get("vehicule_municipalite_s")) or self.city
166 + body = _clean(doc.get("vehicule_carrosserie_type_s")).split(",")[0]
167 +
168 + photos = doc.get("vehicule_photo_t") or []
169 + if isinstance(photos, str):
170 + photos = [photos]
171 + images = [f"{IMG_HOST}/1024/{p}" for p in photos if isinstance(p, str)]
172 +
173 + details: dict = {}
174 + for src_key, out_key in (("vehicule_rabais_d", "rabais"),
175 + ("vehicule_categorie_s", "categorie"),
176 + ("vehicule_garantie_legal_texte_s", "garantie")):
177 + if doc.get(src_key):
178 + details[out_key] = doc[src_key]
179 +
180 + title = " ".join(x for x in (make, model, trim, str(year or "")) if x)
181 + return Vehicle(
182 + source=self.source_id,
183 + external_id=str(gvo_id),
184 + url=url,
185 + title=title,
186 + make=make,
187 + model=model,
188 + trim=trim,
189 + year=year,
190 + price=price,
191 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
192 + mileage_km=km,
193 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
194 + transmission=_clean(doc.get("vehicule_transmission_s")),
195 + fuel=_clean(doc.get("vehicule_carburant_nom_s")),
196 + drivetrain=_clean(doc.get("vehicule_traction_s")),
197 + body_type=body,
198 + exterior_color=_clean(doc.get("vehicule_couleur_exterieur_s")),
199 + interior_color=_clean(doc.get("vehicule_couleur_interieur_s")),
200 + engine=_clean(doc.get("vehicule_moteur_s")),
201 + doors=_num(doc.get("vehicule_porte_nom_s"), int),
202 + vin=_clean(doc.get("vehicule_numero_serie_s")),
203 + stock_number=_clean(doc.get("vehicule_numero_unite_s")),
204 + dealer_name=self.dealer_name,
205 + city=city.title() if city.isupper() else city,
206 + region=_clean(doc.get("vehicule_region_s")),
207 + details=details,
208 + images=images,
209 + )
210 +
211 + def _enrich(self, veh: Vehicle, payload: dict) -> None:
212 + if payload.get("images"):
213 + veh.images = payload["images"][:30]
214 + if payload.get("description"):
215 + veh.description = payload["description"]
216 + if payload.get("features"):
217 + veh.features = payload["features"]
218 + specs = payload.get("specs") or {}
219 + seats = specs.get("Nombre de passagers")
220 + if seats and veh.seats is None:
221 + veh.seats = _num(seats, int)
222 + litres = specs.get("Moteur")
223 + cyl = specs.get("Cylindre")
224 + if litres and re.fullmatch(r"\d+(\.\d+)?", litres):
225 + veh.engine = f"{litres}L" + (f" {cyl} cyl." if cyl else "")
226 +
227 + # -- contrat ------------------------------------------------------------------
228 + def fetch(self) -> list[Vehicle]:
229 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
230 + vehicles: list[Vehicle] = []
231 + real_fetches = 0
232 + for doc in self._api_docs():
233 + type_i = doc.get("vehicule_type_i")
234 + if type_i not in (None, 1): # 2 = moto, 3 = VR
235 + continue
236 + cat = f"{doc.get('vehicule_categorie_s') or ''} " \
237 + f"{doc.get('vehicule_carrosserie_type_s') or ''}"
238 + if _EXCLUDE_RE.search(strip_accents(cat)):
239 + continue
240 + veh = self._to_vehicle(doc)
241 + if veh is None:
242 + continue
243 +
244 + # enrichissement page détail (galerie, description, options) —
245 + # servi du cache BD sauf pour les nouveaux véhicules, dans la
246 + # limite du budget de vraies requêtes.
247 + if real_fetches < cap:
248 + def _fetch(u=veh.url):
249 + return self._fetch_detail(u)
250 + before = self._last_request
251 + try:
252 + payload = self.detail(veh.external_id, "v1", _fetch)
253 + except Exception:
254 + payload = {}
255 + if self._last_request != before:
256 + real_fetches += 1
257 + self._enrich(veh, payload)
258 + vehicles.append(veh)
259 + return vehicles
260 +
261 +
262 +# ---------------------------------------------------------------------------
263 +# Concessionnaires (1 sous-classe = 1 source)
264 +# ---------------------------------------------------------------------------
265 +
266 +class DenisAuto(CentralConnector):
267 + source_id = "denisauto"
268 + base_url = "https://www.denisauto.ca"
269 + dealer_name = "Denis Auto"
270 + city = "Gatineau"
271 +
272 +
273 +class JRAuto(CentralConnector):
274 + source_id = "jrautotr"
275 + base_url = "https://www.jrautotr.com"
276 + dealer_name = "J.R. Auto"
277 + city = "Trois-Rivières"
278 +
279 +
280 +class Autosag(CentralConnector):
281 + source_id = "autosag"
282 + base_url = "https://www.autosag.com"
283 + dealer_name = "Autosag"
284 + city = "Chicoutimi"
285 +
286 +
287 +class LeCentreAutoQuebec(CentralConnector):
288 + source_id = "lecentreautoquebec"
289 + base_url = "https://www.lecentreautoquebec.com"
290 + dealer_name = "Le Centre Auto Québec Plus"
291 + city = "Québec"
292 +
293 +
294 +class AutoCreditPlus(CentralConnector):
295 + source_id = "autocreditplus"
296 + base_url = "https://www.autocreditplus.ca"
297 + dealer_name = "Auto Crédit Plus"
298 + city = "Trois-Rivières"
299 +
300 +
301 +class FleuryAutoGroupe(CentralConnector):
302 + source_id = "fleuryautogroupe"
303 + base_url = "https://www.fleuryautogroupe.com"
304 + dealer_name = "Fleury Auto Groupe"
305 + city = "Gatineau"
added autoka/connectors/clubautozone.py +133 −0
@@ -0,0 +1,133 @@
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/clubautozone.py : Club Auto Zone (Longueuil) — WordPress sur la
5 +# plateforme Convertus (thème « achilles » + plugin convertus-vms).
6 +#
7 +# Stratégie : la page inventaire est une app Vue qui charge les véhicules
8 +# depuis l'API VMS (vms.prod.convertus.rocks) VIA le proxy PHP du site :
9 +# /wp-content/plugins/convertus-vms/include/php/ajax-vehicles.php
10 +# ?endpoint=<URL VMS encodée>&action=vms_data
11 +# (l'API VMS directe répond 403 ; le proxy du site est la voie officielle).
12 +# Réponse JSON riche : prix, km, VIN, moteur, couleurs, URL VDP… 30/page.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import urllib.parse
17 +
18 +from ..schema import Vehicle
19 +from .base import BaseConnector
20 +
21 +_VMS_API = "https://vms.prod.convertus.rocks/api/"
22 +_PROXY = "/wp-content/plugins/convertus-vms/include/php/ajax-vehicles.php"
23 +
24 +
25 +def _num(value) -> float | None:
26 + try:
27 + v = float(value)
28 + return v if v > 0 else None
29 + except (TypeError, ValueError):
30 + return None
31 +
32 +
33 +class ClubAutoZone(BaseConnector):
34 + source_id = "clubautozone"
35 + base_url = "https://www.clubautozone.ca"
36 + dealer_name = "Club Auto Zone"
37 + city = "Longueuil"
38 + inventory_id = 2292 # globalVars.inventoryId du site (cp=…)
39 + request_delay: float = 1.0
40 +
41 + def _page(self, pg: int) -> dict:
42 + endpoint = (f"{_VMS_API}filtering/?cp={self.inventory_id}"
43 + f"&ln=fr&sc=used&pg={pg}&pc=100")
44 + url = (f"{self.base_url}{_PROXY}"
45 + f"?endpoint={urllib.parse.quote(endpoint, safe='')}"
46 + f"&action=vms_data")
47 + return self.get(url).json()
48 +
49 + def fetch(self) -> list[Vehicle]:
50 + vehicles: list[Vehicle] = []
51 + seen: set[str] = set()
52 + pg = 1
53 + while pg <= 40:
54 + data = self._page(pg)
55 + results = data.get("results") or []
56 + new = 0
57 + for d in results:
58 + veh = self._to_vehicle(d)
59 + if veh is not None and veh.external_id not in seen:
60 + seen.add(veh.external_id)
61 + vehicles.append(veh)
62 + new += 1
63 + if not results or new == 0: # page vide ou déjà tout vu
64 + break
65 + pg += 1
66 + return vehicles
67 +
68 + def _to_vehicle(self, d: dict) -> Vehicle | None:
69 + ext_id = str(d.get("vehicle_id") or d.get("ad_id") or "")
70 + if not ext_id:
71 + return None
72 + sale_class = str(d.get("sale_class") or "")
73 + if sale_class and "occasion" not in sale_class.lower() \
74 + and "used" not in sale_class.lower():
75 + return None
76 +
77 + year = d.get("year") if isinstance(d.get("year"), int) else None
78 + title = " ".join(str(x) for x in
79 + (year, d.get("make"), d.get("model"),
80 + d.get("search_trim") or d.get("trim")) if x)
81 +
82 + price = (_num(d.get("final_price")) or _num(d.get("internet_price"))
83 + or _num(d.get("asking_price")))
84 + km = _num(d.get("odometer"))
85 +
86 + image = d.get("image") or {}
87 + images = []
88 + if isinstance(image, dict) and image.get("image_original") \
89 + and not d.get("placeholder_image"):
90 + images = [image["image_original"]]
91 +
92 + try:
93 + doors = int(d.get("doors")) or None
94 + except (TypeError, ValueError):
95 + doors = None
96 + try:
97 + seats = int(d.get("passenger")) or None
98 + except (TypeError, ValueError):
99 + seats = None
100 +
101 + details = {k: d[k] for k in ("days_on_lot", "certified", "cylinder",
102 + "engine_displacement", "vehicle_class")
103 + if d.get(k) not in (None, "", 0)}
104 +
105 + return Vehicle(
106 + source=self.source_id,
107 + external_id=ext_id,
108 + url=str(d.get("vdp_url") or f"{self.base_url}/vehicles/used/"),
109 + title=title,
110 + make=str(d.get("make") or ""),
111 + model=str(d.get("model") or ""),
112 + trim=str(d.get("search_trim") or d.get("trim") or ""),
113 + year=year,
114 + price=price,
115 + price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),
116 + mileage_km=km,
117 + mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),
118 + transmission=str(d.get("transmission") or ""),
119 + fuel=str(d.get("fuel_type") or ""),
120 + drivetrain=str(d.get("drive_train") or ""),
121 + body_type=str(d.get("body_style") or ""),
122 + exterior_color=str(d.get("exterior_color") or ""),
123 + interior_color=str(d.get("interior_color") or ""),
124 + engine=str(d.get("engine") or ""),
125 + doors=doors,
126 + seats=seats,
127 + vin=str(d.get("vin") or ""),
128 + stock_number=str(d.get("stock_number") or ""),
129 + dealer_name=self.dealer_name,
130 + city=self.city,
131 + details=details,
132 + images=images,
133 + )
added autoka/connectors/convertus_fc.py +84 −0
@@ -0,0 +1,84 @@
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/convertus_fc.py : concessionnaires WordPress « Convertus »
5 +# (thème achilles) protégés par Cloudflare — accès via Firecrawl.
6 +#
7 +# Même API VMS que clubautozone.py (proxy officiel du site
8 +# ajax-vehicles.php -> vms.prod.convertus.rocks), mais chaque appel passe
9 +# par Firecrawl pour franchir Cloudflare. Le JSON revient enveloppé dans
10 +# un <body> HTML — on l'extrait puis on réutilise le parseur VMS.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import json
15 +import re
16 +import urllib.parse
17 +
18 +from ..schema import Vehicle
19 +from .clubautozone import ClubAutoZone
20 +
21 +_VMS_API = "https://vms.prod.convertus.rocks/api/"
22 +_PROXY = "/wp-content/plugins/convertus-vms/include/php/ajax-vehicles.php"
23 +
24 +
25 +class ConvertusFirecrawlConnector(ClubAutoZone):
26 + """Hérite du parseur VMS de ClubAutoZone ; fetch via Firecrawl."""
27 +
28 + source_id = "" # base abstraite : non enregistrée
29 + inventory_id = 0
30 +
31 + def _page(self, pg: int) -> dict:
32 + endpoint = (f"{_VMS_API}filtering/?cp={self.inventory_id}"
33 + f"&ln=fr&sc=used&pg={pg}&pc=100")
34 + url = (f"{self.base_url}{_PROXY}"
35 + f"?endpoint={urllib.parse.quote(endpoint, safe='')}"
36 + f"&action=vms_data")
37 + html = self.get_rendered(url)
38 + # le JSON est enveloppé dans <body>…</body> par le rendu Firecrawl
39 + m = re.search(r"<body[^>]*>(.*)</body>", html, re.S)
40 + payload = (m.group(1) if m else html).strip()
41 + try:
42 + return json.loads(payload)
43 + except ValueError:
44 + return {}
45 +
46 + def _resolve_inventory_id(self) -> None:
47 + """Lit globalVars.inventoryId sur la page inventaire si inconnu."""
48 + if self.inventory_id:
49 + return
50 + html = self.get_rendered(f"{self.base_url}/vehicles/used/")
51 + m = re.search(r'inventoryId"\s*:\s*"?(\d+)', html)
52 + if not m:
53 + raise RuntimeError("inventoryId introuvable")
54 + self.inventory_id = int(m.group(1))
55 +
56 + def fetch(self) -> list[Vehicle]:
57 + if not self.source_id:
58 + return []
59 + self._resolve_inventory_id()
60 + return super().fetch()
61 +
62 +
63 +class BernieresAuto(ConvertusFirecrawlConnector):
64 + source_id = "bernieresauto"
65 + base_url = "https://www.bernieresauto.com"
66 + dealer_name = "Bernières Auto"
67 + city = "Saint-Nicolas"
68 + inventory_id = 1305
69 +
70 +
71 +class AutoFlash(ConvertusFirecrawlConnector):
72 + source_id = "autoflash"
73 + base_url = "https://www.autoflash.net"
74 + dealer_name = "AutoFlash"
75 + city = "Saint-Hubert"
76 + inventory_id = 1306
77 +
78 +
79 +class ComplexeAutoPlus(ConvertusFirecrawlConnector):
80 + source_id = "complexeautoplus"
81 + base_url = "https://www.complexeautoplus.com"
82 + dealer_name = "Complexe Auto Plus"
83 + city = "Saint-Jérôme"
84 + inventory_id = 855
added autoka/connectors/d2c_dealers.py +603 −0
@@ -0,0 +1,603 @@
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/d2c_dealers.py : connecteurs des concessionnaires sur plateforme
5 +# D2C Media (le fournisseur de sites le plus répandu chez les marchands
6 +# d'occasion au Québec).
7 +#
8 +# Stratégie : le sitemap (/fr/sitemap.xml) recense chaque page véhicule
9 +# (…-id123456.html). Chaque page détail expose un JSON-LD schema.org
10 +# complet (@type Vehicle/Car) : VIN, kilométrage, transmission, carburant,
11 +# carrosserie, couleurs, prix, photos. Les pages détail sont mises en cache
12 +# (clé hebdomadaire) : seuls les nouveaux véhicules — et une revalidation
13 +# par semaine pour les prix — génèrent de vraies requêtes.
14 +#
15 +# Le lastmod du sitemap est bumpé quotidiennement par D2C (inutilisable
16 +# comme clé de cache) — d'où la clé temporelle hebdomadaire.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import datetime
21 +import html as htmllib
22 +import json
23 +import os
24 +import re
25 +
26 +from ..schema import Vehicle
27 +from .base import BaseConnector
28 +
29 +# segments d'URL /occasion/<segment>/ qui ne sont PAS des automobiles
30 +_EXCLUDE_SEGMENTS = re.compile(
31 + r"motoneige|vtt|bateau|ponton|motomarine|moto(?:s)?\b|roulotte|remorque|"
32 + r"\bvr\b|campeur|tracteur|souffleuse|scooter|spyder|side|atv|snow|"
33 + r"powersport|nautique|habitation", re.I)
34 +
35 +_VEH_URL_RE = re.compile(r"-id(\d+)\.html?$")
36 +
37 +_DRIVE_MAP = {
38 + "FrontWheelDriveConfiguration": "Traction",
39 + "RearWheelDriveConfiguration": "Propulsion",
40 + "AllWheelDriveConfiguration": "Intégrale / 4x4",
41 + "FourWheelDriveConfiguration": "Intégrale / 4x4",
42 +}
43 +
44 +_LD_RE = re.compile(
45 + r'<script type=["\']application/ld\+json["\']>(.*?)</script>', re.S)
46 +
47 +
48 +def _clean(text: str | None) -> str:
49 + """Désencode les entités HTML (souvent doublement encodées chez D2C)."""
50 + if not text:
51 + return ""
52 + out = htmllib.unescape(htmllib.unescape(str(text)))
53 + out = re.sub(r"<[^>]+>", " ", out)
54 + return re.sub(r"\s{2,}", " ", out).strip()
55 +
56 +
57 +class D2CConnector(BaseConnector):
58 + """Base commune aux sites D2C Media. Sous-classes : base_url + ville."""
59 +
60 + base_url: str = ""
61 + dealer_name: str = ""
62 + city: str = ""
63 + request_delay: float = 1.0 # robots.txt D2C demande de la politesse
64 + use_firecrawl: bool = False # sites derrière Cloudflare
65 +
66 + # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit
67 + # pour les tests) — le reste sera complété aux syncs suivants
68 + max_details = 600
69 +
70 + def _get_text(self, url: str) -> str:
71 + if self.use_firecrawl:
72 + return self.get_rendered(url)
73 + return self.get(url).text
74 +
75 + # -- sitemap ---------------------------------------------------------------
76 + def _vehicle_urls(self) -> list[str]:
77 + xml = self._get_text(f"{self.base_url}/fr/sitemap.xml")
78 + urls = []
79 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
80 + loc = loc.strip()
81 + if "/occasion/" not in loc or not _VEH_URL_RE.search(loc):
82 + continue
83 + # catégorie intermédiaire ? (/occasion/motoneiges/…)
84 + path = loc.split("/occasion/", 1)[1]
85 + segments = path.split("/")[:-1]
86 + if any(_EXCLUDE_SEGMENTS.search(s) for s in segments):
87 + continue
88 + urls.append(loc)
89 + return urls
90 +
91 + # -- page détail -> payload JSON-LD ----------------------------------------
92 + def _fetch_detail(self, url: str) -> dict:
93 + html = self._get_text(url)
94 + for block in _LD_RE.findall(html):
95 + try:
96 + data = json.loads(block.strip())
97 + except ValueError:
98 + continue
99 + types = data.get("@type")
100 + types = types if isinstance(types, list) else [types]
101 + if "Vehicle" in types or "Car" in types:
102 + return data
103 + return {}
104 +
105 + # -- contrat ---------------------------------------------------------------
106 + def fetch(self) -> list[Vehicle]:
107 + urls = self._vehicle_urls()
108 + week = datetime.date.today().isocalendar()
109 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
110 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
111 +
112 + vehicles: list[Vehicle] = []
113 + real_fetches = 0
114 + for url in urls:
115 + ext_id = _VEH_URL_RE.search(url).group(1)
116 +
117 + def _fetch(u=url):
118 + return self._fetch_detail(u)
119 +
120 + # plafond atteint : ne servir que le cache (fetch réel interdit)
121 + if real_fetches >= cap:
122 + from .. import db
123 + if self._detail_con is None:
124 + self._detail_con = db.connect()
125 + data = db.get_cached_detail(self._detail_con, self.source_id,
126 + ext_id, cache_key)
127 + if data is None:
128 + continue
129 + else:
130 + before = self._last_request
131 + try:
132 + data = self.detail(ext_id, cache_key, _fetch)
133 + except Exception:
134 + # page détail disparue (vendu, sitemap pas encore purgé)
135 + # ou erreur ponctuelle : ne jamais bloquer la source
136 + if self._last_request != before:
137 + real_fetches += 1
138 + continue
139 + if self._last_request != before: # une vraie requête a eu lieu
140 + real_fetches += 1
141 + if not data:
142 + continue
143 + veh = self._to_vehicle(ext_id, url, data)
144 + if veh is not None:
145 + vehicles.append(veh)
146 + return vehicles
147 +
148 + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:
149 + offers = d.get("offers") or {}
150 + price = offers.get("price")
151 + try:
152 + price = float(price) if price not in (None, "", 0, "0") else None
153 + except (TypeError, ValueError):
154 + price = None
155 +
156 + odo = d.get("mileageFromOdometer") or {}
157 + km = odo.get("value") if isinstance(odo, dict) else odo
158 + try:
159 + km = float(km) if km not in (None, "") else None
160 + except (TypeError, ValueError):
161 + km = None
162 +
163 + brand = d.get("brand") or {}
164 + make = brand.get("name") if isinstance(brand, dict) else str(brand)
165 +
166 + drive_raw = str(d.get("driveWheelConfiguration") or "")
167 + drivetrain = ""
168 + for key, canon in _DRIVE_MAP.items():
169 + if key in drive_raw:
170 + drivetrain = canon
171 + break
172 +
173 + year = None
174 + if d.get("vehicleModelDate"):
175 + try:
176 + year = int(str(d["vehicleModelDate"])[:4])
177 + except ValueError:
178 + pass
179 +
180 + images = d.get("image") or []
181 + if isinstance(images, str):
182 + images = [images]
183 +
184 + title = _clean(d.get("name"))
185 + if not title:
186 + return None
187 +
188 + return Vehicle(
189 + source=self.source_id,
190 + external_id=ext_id,
191 + url=url,
192 + title=title,
193 + make=_clean(make),
194 + model=_clean(d.get("model")),
195 + trim=_clean(d.get("vehicleConfiguration")),
196 + year=year,
197 + price=price,
198 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
199 + mileage_km=km,
200 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
201 + transmission=_clean(d.get("vehicleTransmission")),
202 + fuel=_clean(d.get("fuelType")),
203 + drivetrain=drivetrain,
204 + body_type=_clean(d.get("bodyType")),
205 + exterior_color=_clean(d.get("color")),
206 + interior_color=_clean(d.get("vehicleInteriorColor")),
207 + doors=d.get("numberOfDoors"),
208 + seats=d.get("vehicleSeatingCapacity"),
209 + vin=_clean(d.get("vehicleIdentificationNumber")),
210 + stock_number=str(d.get("sku") or ""),
211 + dealer_name=self.dealer_name,
212 + city=self.city,
213 + description=_clean(d.get("description"))[:4000],
214 + images=[u for u in images if isinstance(u, str)][:20],
215 + )
216 +
217 +
218 +# ---------------------------------------------------------------------------
219 +# Concessionnaires (1 sous-classe = 1 source)
220 +# ---------------------------------------------------------------------------
221 +
222 +class GirardAutos(D2CConnector):
223 + source_id = "girardautos"
224 + base_url = "https://www.girardautos.ca"
225 + dealer_name = "Girard Autos"
226 + city = "Trois-Rivières"
227 +
228 +
229 +class ForceOccasion(D2CConnector):
230 + source_id = "forceoccasion"
231 + base_url = "https://www.forceoccasion.ca"
232 + dealer_name = "Force Occasion"
233 + city = "Québec"
234 +
235 +
236 +class GervaisAuto(D2CConnector):
237 + source_id = "gervaisauto"
238 + base_url = "https://www.gervaisauto.com"
239 + dealer_name = "Gervais Auto"
240 + city = "Shawinigan"
241 +
242 +
243 +class EquipeFormule(D2CConnector):
244 + source_id = "equipeformule"
245 + base_url = "https://www.equipeformule.com"
246 + dealer_name = "Équipe Formule"
247 + city = "Rimouski"
248 +
249 +
250 +class AutosEvasion(D2CConnector):
251 + source_id = "autosevasion"
252 + base_url = "https://www.autosevasion.com"
253 + dealer_name = "Autos Évasion"
254 + city = "Sherbrooke"
255 +
256 +
257 +class MFerlandAuto(D2CConnector):
258 + source_id = "mferlandauto"
259 + base_url = "https://www.mferlandauto.com"
260 + dealer_name = "M. Ferland Auto"
261 + city = "Sherbrooke"
262 +
263 +
264 +class AutosOccasionLD(D2CConnector):
265 + source_id = "autosoccasionld"
266 + base_url = "https://www.autosoccasions.ca"
267 + dealer_name = "Autos Occasion LD"
268 + city = "Lévis"
269 +
270 +
271 +class LevisAutos(D2CConnector):
272 + source_id = "levisautos"
273 + base_url = "https://www.levisautos.com"
274 + dealer_name = "Lévis Autos"
275 + city = "Lévis"
276 +
277 +
278 +class AutoQuebecLevis(D2CConnector):
279 + source_id = "autoquebeclevis"
280 + base_url = "https://www.autoquebecoccasionlevis.com"
281 + dealer_name = "AutoQuébec Occasion Lévis"
282 + city = "Lévis"
283 +
284 +
285 +class AutoQuebecThetford(D2CConnector):
286 + source_id = "autoquebectm"
287 + base_url = "https://www.autoquebectm.com"
288 + dealer_name = "AutoQuébec Occasion Thetford Mines"
289 + city = "Thetford Mines"
290 +
291 +
292 +class AbitibiAutoCredit(D2CConnector):
293 + source_id = "abitibiautocredit"
294 + base_url = "https://www.abitibiautocredit.ca"
295 + dealer_name = "Abitibi Auto Crédit"
296 + city = "Rouyn-Noranda"
297 +
298 +
299 +class NicolOccasion(D2CConnector):
300 + source_id = "nicoloccasion"
301 + base_url = "https://www.nicoloccasion.com"
302 + dealer_name = "Nicol Occasion"
303 + city = "La Sarre"
304 +
305 +
306 +class PoirierOccasion(D2CConnector):
307 + source_id = "poirieroccasion"
308 + base_url = "https://www.poirieroccasion.com"
309 + dealer_name = "Poirier Occasion"
310 + city = "Val-d'Or"
311 +
312 +
313 +class AutosStHubert(D2CConnector):
314 + source_id = "autosthubert"
315 + base_url = "https://www.autosthubert.com"
316 + dealer_name = "Auto St-Hubert"
317 + city = "Saint-Hubert"
318 +
319 +
320 +class SherbrookeAutoOccasion(D2CConnector):
321 + source_id = "sherbrookeautooccasion"
322 + base_url = "https://www.sherbrookeautooccasion.com"
323 + dealer_name = "Sherbrooke Auto Occasion"
324 + city = "Sherbrooke"
325 +
326 +
327 +# ---------------------------------------------------------------------------
328 +# Expansion 2 — concessionnaires de marque et groupes sur D2C Media
329 +# (inventaire OCCASION seulement : le sitemap /fr/sitemap.xml ne référence
330 +# que les pages /occasion/…-idNNN.html ; le neuf vit dans un sitemap séparé)
331 +# ---------------------------------------------------------------------------
332 +
333 +class BernierCrepeau(D2CConnector):
334 + source_id = "berniercrepeau"
335 + base_url = "https://www.berniercrepeau.com"
336 + dealer_name = "Bernier Crépeau"
337 + city = "Trois-Rivières"
338 +
339 +
340 +class ChomedeyToyota(D2CConnector):
341 + source_id = "chomedeytoyota"
342 + base_url = "https://www.chomedeytoyotalaval.ca"
343 + dealer_name = "Chomedey Toyota Laval"
344 + city = "Laval"
345 +
346 +
347 +class DrummondvilleFord(D2CConnector):
348 + source_id = "drummondvilleford"
349 + base_url = "https://www.drummondvilleford.com"
350 + dealer_name = "Drummondville Ford"
351 + city = "Drummondville"
352 +
353 +
354 +class DrummondvilleVW(D2CConnector):
355 + source_id = "drummondvillevw"
356 + base_url = "https://www.drummondvillevolkswagen.ca"
357 + dealer_name = "Drummondville Volkswagen"
358 + city = "Drummondville"
359 +
360 +
361 +class DubeChrysler(D2CConnector):
362 + source_id = "dubechrysler"
363 + base_url = "https://www.dubechrysler.com"
364 + dealer_name = "Dubé Chrysler"
365 + city = "Rivière-du-Loup"
366 +
367 +
368 +class DuclosLongueuil(D2CConnector):
369 + source_id = "ducloslongueuil"
370 + base_url = "https://www.ducloschryslerlongueuil.com"
371 + dealer_name = "Duclos Longueuil Chrysler"
372 + city = "Longueuil"
373 +
374 +
375 +class DynastieHyundai(D2CConnector):
376 + source_id = "dynastiehyundai"
377 + base_url = "https://www.dynastiehyundai.com"
378 + dealer_name = "Dynastie Hyundai"
379 + city = "Rouyn-Noranda"
380 +
381 +
382 +class FichaultKia(D2CConnector):
383 + source_id = "fichaultkia"
384 + base_url = "https://www.fichaultkia.com"
385 + dealer_name = "Fichault Kia"
386 + city = "Châteauguay"
387 +
388 +
389 +class FordThetford(D2CConnector):
390 + source_id = "fordthetford"
391 + base_url = "https://www.fordthetford.com"
392 + dealer_name = "Ford Thetford"
393 + city = "Thetford Mines"
394 +
395 +
396 +class GaspeToyota(D2CConnector):
397 + source_id = "gaspetoyota"
398 + base_url = "https://www.gaspe-toyota.com"
399 + dealer_name = "Gaspé Toyota"
400 + city = "Gaspé"
401 +
402 +
403 +class GaspesieHyundai(D2CConnector):
404 + source_id = "gaspesiehyundai"
405 + base_url = "https://www.gaspesiehyundai.com"
406 + dealer_name = "Gaspésie Hyundai"
407 + city = "Bonaventure"
408 +
409 +
410 +class GranbyToyota(D2CConnector):
411 + source_id = "granbytoyota"
412 + base_url = "https://www.granbytoyota.ca"
413 + dealer_name = "Granby Toyota"
414 + city = "Granby"
415 +
416 +
417 +class GrandPortage(D2CConnector):
418 + source_id = "grandportage"
419 + base_url = "https://www.grandportageauto.com"
420 + dealer_name = "Groupe Grand-Portage"
421 + city = "Rivière-du-Loup"
422 +
423 +
424 +class GroupeMotion(D2CConnector):
425 + source_id = "groupemotion"
426 + base_url = "https://www.groupemotion.com"
427 + dealer_name = "Groupe Motion"
428 + city = "Longueuil"
429 +
430 +
431 +class GroupePaquin(D2CConnector):
432 + source_id = "groupepaquin"
433 + base_url = "https://www.groupepaquin.com"
434 + dealer_name = "Groupe Paquin"
435 + city = "Rouyn-Noranda"
436 +
437 +
438 +class HullHyundai(D2CConnector):
439 + source_id = "hullhyundai"
440 + base_url = "https://www.hullhyundai.com"
441 + dealer_name = "Hull Hyundai"
442 + city = "Gatineau"
443 +
444 +
445 +class HyundaiBeauce(D2CConnector):
446 + source_id = "hyundaibeauce"
447 + base_url = "https://www.hyundaibeauce.com"
448 + dealer_name = "Hyundai Beauce"
449 + city = "Saint-Georges"
450 +
451 +
452 +class HyundaiDrummondville(D2CConnector):
453 + source_id = "hyundaidrummondville"
454 + base_url = "https://www.hyundaidrummondville.com"
455 + dealer_name = "Hyundai Drummondville"
456 + city = "Drummondville"
457 +
458 +
459 +class HyundaiLanaudiere(D2CConnector):
460 + source_id = "hyundailanaudiere"
461 + base_url = "https://www.hyundailanaudiere.com"
462 + dealer_name = "Hyundai Lanaudière"
463 + city = "Joliette"
464 +
465 +
466 +class HyundaiLongueuil(D2CConnector):
467 + source_id = "hyundailongueuil"
468 + base_url = "https://www.hyundailongueuil.com"
469 + dealer_name = "Hyundai Longueuil"
470 + city = "Longueuil"
471 +
472 +
473 +class HyundaiStJerome(D2CConnector):
474 + source_id = "hyundaistjerome"
475 + base_url = "https://www.hyundaistjerome.com"
476 + dealer_name = "Hyundai St-Jérôme"
477 + city = "Mirabel"
478 +
479 +
480 +class JeanDumas(D2CConnector):
481 + source_id = "jeandumas"
482 + base_url = "https://www.jeandumas.com"
483 + dealer_name = "Jean Dumas Multiconcessionnaire"
484 + city = "Alma"
485 +
486 +
487 +class JeanDumasFordBC(D2CConnector):
488 + source_id = "jeandumasfordbc"
489 + base_url = "https://www.jeandumasfordbaiecomeau.com"
490 + dealer_name = "Jean Dumas Ford Baie-Comeau"
491 + city = "Baie-Comeau"
492 +
493 +
494 +class JeanDumasHondaBC(D2CConnector):
495 + source_id = "jeandumashondabc"
496 + base_url = "https://www.jeandumashondabaiecomeau.com"
497 + dealer_name = "Jean Dumas Honda Baie-Comeau"
498 + city = "Baie-Comeau"
499 +
500 +
501 +class JolietteMitsubishi(D2CConnector):
502 + source_id = "joliettemitsubishi"
503 + base_url = "https://www.joliettemitsubishi.com"
504 + dealer_name = "Joliette Mitsubishi"
505 + city = "Joliette"
506 +
507 +
508 +class KiaJoliette(D2CConnector):
509 + source_id = "kiajoliette"
510 + base_url = "https://www.kiajoliette.com"
511 + dealer_name = "Kia Joliette"
512 + city = "Joliette"
513 +
514 +
515 +class LAmiHonda(D2CConnector):
516 + source_id = "lamihonda"
517 + base_url = "https://www.lamihonda.com"
518 + dealer_name = "L'Ami Honda"
519 + city = "Rouyn-Noranda"
520 +
521 +
522 +class LeoAutomobile(D2CConnector):
523 + source_id = "leoautomobile"
524 + base_url = "https://www.leoautomobile.com"
525 + dealer_name = "Léo Automobile"
526 + city = "Chicoutimi"
527 +
528 +
529 +class LevisToyota(D2CConnector):
530 + source_id = "levistoyota"
531 + base_url = "https://www.levistoyota.ca"
532 + dealer_name = "Lévis Toyota"
533 + city = "Lévis"
534 +
535 +
536 +class LongueuilHonda(D2CConnector):
537 + source_id = "longueuilhonda"
538 + base_url = "https://www.longueuilhonda.com"
539 + dealer_name = "Longueuil Honda"
540 + city = "Longueuil"
541 +
542 +
543 +class OlivierChryslerBC(D2CConnector):
544 + source_id = "olivierchryslerbc"
545 + base_url = "https://www.olivierchryslerbaiecomeau.com"
546 + dealer_name = "Olivier Chrysler Baie-Comeau"
547 + city = "Baie-Comeau"
548 +
549 +
550 +class StGeorgesGM(D2CConnector):
551 + source_id = "stgeorgesgm"
552 + base_url = "https://www.stgeorgesgm.ca"
553 + dealer_name = "St-Georges Chevrolet Buick GMC"
554 + city = "Saint-Georges"
555 +
556 +
557 +class StJeromeMitsubishi(D2CConnector):
558 + source_id = "stjeromemitsubishi"
559 + base_url = "https://www.stjeromemitsubishi.ca"
560 + dealer_name = "St-Jérôme Mitsubishi"
561 + city = "Saint-Jérôme"
562 +
563 +
564 +class SubaruVictoriaville(D2CConnector):
565 + source_id = "subaruvictoriaville"
566 + base_url = "https://www.subaruvictoriaville.ca"
567 + dealer_name = "Subaru Victoriaville"
568 + city = "Victoriaville"
569 +
570 +
571 +class ToyotaBaieDesChaleurs(D2CConnector):
572 + source_id = "toyotabdc"
573 + base_url = "https://www.toyotabaiedeschaleurs.com"
574 + dealer_name = "Toyota Baie des Chaleurs"
575 + city = "Caplan"
576 +
577 +
578 +class TroisRivieresHonda(D2CConnector):
579 + source_id = "troisriviereshonda"
580 + base_url = "https://www.troisriviereshonda.com"
581 + dealer_name = "Trois-Rivières Honda"
582 + city = "Trois-Rivières"
583 +
584 +
585 +class TRToyota(D2CConnector):
586 + source_id = "trtoyota"
587 + base_url = "https://www.trtoyota.ca"
588 + dealer_name = "Trois-Rivières Toyota"
589 + city = "Trois-Rivières"
590 +
591 +
592 +class VaudreuilVW(D2CConnector):
593 + source_id = "vaudreuilvw"
594 + base_url = "https://www.vaudreuilvolkswagen.com"
595 + dealer_name = "Vaudreuil Volkswagen"
596 + city = "Vaudreuil-Dorion"
597 +
598 +
599 +class VWSherbrooke(D2CConnector):
600 + source_id = "vwsherbrooke"
601 + base_url = "https://www.vwsherbrooke.com"
602 + dealer_name = "VW Sherbrooke"
603 + city = "Sherbrooke"
added autoka/connectors/desmeules.py +200 −0
@@ -0,0 +1,200 @@
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/desmeules.py : connecteur Desmeules Chrysler (desmeules.ca),
5 +# gros concessionnaire Chrysler/Dodge/Jeep/RAM de Laval — ~220 véhicules
6 +# d'occasion.
7 +#
8 +# Plateforme : ColdFusion maison + inventaire AutoJini (photos
9 +# images.autojini.net). La page occasion expose un flux RSS complet :
10 +# /fr/vehicules-doccasion/?fuseaction=inventory.feedRSS&ajaxCall=1
11 +# -> un <item> par véhicule : titre (année marque modèle + prix), lien
12 +# détail (…-v<id>.html), no de stock, VIN, photo, longue description.
13 +#
14 +# La page détail complète : kilométrage (detailsMileageData), moteur,
15 +# transmission/entraînement (microdata) et galerie photos. 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.
19 +# -----------------------------------------------------------------------------
20 +from __future__ import annotations
21 +
22 +import html as htmllib
23 +import os
24 +import re
25 +
26 +from ..schema import Vehicle
27 +from .base import BaseConnector
28 +
29 +_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S)
30 +_TITLE_RE = re.compile(r"<title>\s*<!\[CDATA\[(.*?)\]\]>", re.S)
31 +_LINK_RE = re.compile(r'href="(https?://[^"]+-v(\d+)\.html)"')
32 +_STOCK_RE = re.compile(r"Stock #:\s*([^<]+)</div>")
33 +_VIN_RE = re.compile(r"VIN #:\s*([^<]+)</div>")
34 +_IMG_RE = re.compile(r'img[^>]*src="([^"]+)"')
35 +_PRICE_RE = re.compile(r"-\s*\$([\d,]+)\s*$")
36 +
37 +# page détail (la transmission/couleur vient de la meta description :
38 +# « … - Engine: V8 4.0L Cylinder Engine - Color : . - Transmission: Automatique »)
39 +_KM_RE = re.compile(r'detailsMileageData">\s*([\d,\s]+)\s*km', re.I)
40 +_ENGINE_RE = re.compile(r"Moteur:\s*</div>\s*<div[^>]*>\s*([^<]+)", re.S)
41 +_TRANS_RE = re.compile(r"Transmission:\s*([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]*)")
42 +_DRIVE_RE = re.compile(r"Entra[îi]nement\s*:\s*<span>\s*([^<]+)", re.I)
43 +_COLOR_RE = re.compile(r"Color\s*:\s*([A-Za-zÀ-ÿ][^.<\"]*)[.<\"]")
44 +_GALLERY_RE = re.compile(
45 + r'https?://images\.autojini\.[a-z]+/[^"\s\\<>]+?\.jpg(?!/)', re.I)
46 +
47 +_EXCLUDE_RE = re.compile(
48 + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|"
49 + r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I)
50 +
51 +
52 +def _clean(text) -> str:
53 + if not text:
54 + return ""
55 + out = htmllib.unescape(str(text))
56 + out = re.sub(r"<[^>]+>", " ", out)
57 + return re.sub(r"\s+", " ", out).strip()
58 +
59 +
60 +# clé de cache détail stable (bump si le parsing change)
61 +_DETAIL_KEY = "v2"
62 +
63 +
64 +class DesmeulesChrysler(BaseConnector):
65 + """Desmeules Chrysler — flux RSS inventaire + pages détail (km, specs)."""
66 +
67 + source_id = "desmeules"
68 + base_url = "https://www.desmeules.ca"
69 + dealer_name = "Desmeules Chrysler"
70 + city = "Laval"
71 + request_delay = 1.0
72 + max_details = 300 # plafond de vraies requêtes par sync
73 +
74 + # -- flux RSS ----------------------------------------------------------------
75 + def _rss_items(self) -> list[dict]:
76 + xml = self.get(
77 + f"{self.base_url}/fr/vehicules-doccasion/"
78 + "?fuseaction=inventory.feedRSS&ajaxCall=1").text
79 + items: list[dict] = []
80 + for chunk in _ITEM_RE.findall(xml):
81 + link = _LINK_RE.search(chunk)
82 + if not link:
83 + continue
84 + entry: dict = {"url": link.group(1).replace("http://", "https://"),
85 + "id": link.group(2)}
86 + m = _TITLE_RE.search(chunk)
87 + title = _clean(m.group(1)) if m else ""
88 + m = _PRICE_RE.search(title)
89 + if m:
90 + entry["price"] = float(m.group(1).replace(",", ""))
91 + title = title[:m.start()].strip()
92 + title = re.sub(r"^For Sale\s+", "", title, flags=re.I)
93 + entry["title"] = title
94 + m = _STOCK_RE.search(chunk)
95 + if m:
96 + entry["stock"] = _clean(m.group(1))
97 + m = _VIN_RE.search(chunk)
98 + if m:
99 + entry["vin"] = _clean(m.group(1))
100 + m = _IMG_RE.search(chunk)
101 + if m:
102 + entry["photo"] = m.group(1).split("/Resize")[0]
103 + # description longue : le 2e bloc CDATA de la description
104 + blocks = re.findall(r"<!\[CDATA\[(.*?)\]\]>", chunk, re.S)
105 + if len(blocks) >= 3:
106 + entry["description"] = _clean(blocks[2])[:4000]
107 + items.append(entry)
108 + return items
109 +
110 + # -- page détail -> payload ---------------------------------------------------
111 + def _fetch_detail(self, url: str) -> dict:
112 + html = self.get(url).text
113 + payload: dict = {}
114 + m = _KM_RE.search(html)
115 + if m:
116 + payload["km"] = float(re.sub(r"[^\d]", "", m.group(1)))
117 + m = _ENGINE_RE.search(html)
118 + if m:
119 + payload["engine"] = _clean(m.group(1))
120 + m = _TRANS_RE.search(html)
121 + if m:
122 + payload["transmission"] = _clean(m.group(1))
123 + m = _DRIVE_RE.search(html)
124 + if m:
125 + payload["drivetrain"] = _clean(m.group(1))
126 + m = _COLOR_RE.search(html)
127 + if m:
128 + payload["color"] = _clean(m.group(1))
129 + images, seen = [], set()
130 + for u in _GALLERY_RE.findall(html):
131 + base = u.replace("_x.jpg", ".jpg")
132 + if base not in seen:
133 + seen.add(base)
134 + images.append(base)
135 + payload["images"] = images[:20]
136 + return payload
137 +
138 + # -- contrat ---------------------------------------------------------------
139 + def fetch(self) -> list[Vehicle]:
140 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
141 + vehicles: list[Vehicle] = []
142 + real_fetches = 0
143 + for item in self._rss_items():
144 + if _EXCLUDE_RE.search(item.get("title", "")):
145 + continue
146 + detail: dict = {}
147 + if real_fetches >= cap: # plafond : cache seulement
148 + from .. import db
149 + if self._detail_con is None:
150 + self._detail_con = db.connect()
151 + detail = db.get_cached_detail(self._detail_con, self.source_id,
152 + item["id"], _DETAIL_KEY) or {}
153 + else:
154 + def _fetch(u=item["url"]):
155 + return self._fetch_detail(u)
156 + before = self._last_request
157 + try:
158 + detail = self.detail(item["id"], _DETAIL_KEY, _fetch)
159 + except Exception:
160 + detail = {} # page disparue : données RSS
161 + if self._last_request != before:
162 + real_fetches += 1
163 + veh = self._to_vehicle(item, detail or {})
164 + if veh is not None:
165 + vehicles.append(veh)
166 + return vehicles
167 +
168 + # -- item RSS + détail -> Vehicle ----------------------------------------------
169 + def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None:
170 + title = item.get("title") or ""
171 + if not title:
172 + return None
173 +
174 + price = item.get("price")
175 + km = detail.get("km")
176 +
177 + images = list(detail.get("images") or [])
178 + if not images and item.get("photo"):
179 + images = [item["photo"]]
180 +
181 + return Vehicle(
182 + source=self.source_id,
183 + external_id=item["id"],
184 + url=item["url"],
185 + title=title, # « 2021 Aston Martin DBX … »
186 + price=price,
187 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
188 + mileage_km=km,
189 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
190 + transmission=detail.get("transmission", ""),
191 + drivetrain=detail.get("drivetrain", ""),
192 + exterior_color=detail.get("color", ""),
193 + engine=detail.get("engine", ""),
194 + vin=item.get("vin", ""),
195 + stock_number=item.get("stock", ""),
196 + dealer_name=self.dealer_name,
197 + city=self.city,
198 + description=item.get("description", ""),
199 + images=images,
200 + )
added autoka/connectors/dupontford.py +157 −0
@@ -0,0 +1,157 @@
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/dupontford.py : connecteur Dupont & Dupont Ford
5 +# (dupontdupontford.com), concessionnaire Ford de Gatineau.
6 +#
7 +# Plateforme : WordPress + thème Leadbox « lbx-egypt » (SPA Vue pour
8 +# l'inventaire). Le SPA charge un export JSON statique complet :
9 +# /wp-content/uploads/data/inventory.json -> {"vehicles": [...]} où chaque
10 +# véhicule est COMPLET : condition (New/Used), année, marque, modèle,
11 +# version, prix, km, VIN, stock, transmission, rouage, carburant,
12 +# carrosserie, moteur, couleur, photo, description. UNE seule requête pour
13 +# tout l'inventaire — on filtre condition == Used (voitures seulement).
14 +#
15 +# URL détail reconstruite comme dans le sitemap inventory.xml :
16 +# /vue/used-<année>-<marque>-<modèle>-<id>/.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import html as htmllib
21 +import re
22 +
23 +from ..schema import Vehicle, strip_accents
24 +from .base import BaseConnector
25 +
26 +_EXCLUDE_RE = re.compile(
27 + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|"
28 + r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur|trailer",
29 + re.I)
30 +
31 +
32 +def _slug(text: str) -> str:
33 + t = strip_accents(str(text or "")).lower()
34 + t = re.sub(r"[^a-z0-9]+", "-", t)
35 + return t.strip("-")
36 +
37 +
38 +def _clean(text) -> str:
39 + if not text:
40 + return ""
41 + out = htmllib.unescape(str(text))
42 + out = re.sub(r"<[^>]+>", " ", out)
43 + return re.sub(r"\s{2,}", " ", out).strip()
44 +
45 +
46 +def _num(value, cast=float):
47 + try:
48 + v = cast(value)
49 + except (TypeError, ValueError):
50 + return None
51 + return v if v else None
52 +
53 +
54 +class DupontDupontFord(BaseConnector):
55 + """Dupont & Dupont Ford — export JSON Leadbox (inventory.json)."""
56 +
57 + source_id = "dupontdupontford"
58 + base_url = "https://dupontdupontford.com"
59 + dealer_name = "Dupont & Dupont Ford"
60 + city = "Gatineau"
61 + request_delay = 1.0
62 + use_detail_cache = False # une seule requête, pas de pages détail
63 +
64 + def fetch(self) -> list[Vehicle]:
65 + data = self.get(
66 + f"{self.base_url}/wp-content/uploads/data/inventory.json").json()
67 + vehicles: list[Vehicle] = []
68 + for d in data.get("vehicles") or []:
69 + veh = self._to_vehicle(d)
70 + if veh is not None:
71 + vehicles.append(veh)
72 + return vehicles
73 +
74 + def _to_vehicle(self, d: dict) -> Vehicle | None:
75 + if str(d.get("condition") or "").lower() != "used":
76 + return None # occasion seulement
77 + if d.get("nonvehicle") or d.get("onweb") is False:
78 + return None
79 + if d.get("salepending") or d.get("sold"):
80 + return None
81 + if str(d.get("kind") or "car").lower() != "car":
82 + return None
83 +
84 + ext_id = str(d.get("id") or "")
85 + make = _clean(d.get("make"))
86 + model = _clean(d.get("model"))
87 + if not ext_id or not (make or model):
88 + return None
89 + body = _clean(d.get("bodystyle") or d.get("type"))
90 + if _EXCLUDE_RE.search(f"{make} {model} {body}"):
91 + return None
92 +
93 + year = _num(d.get("year"), int)
94 + trim = _clean(d.get("trim"))
95 + title = " ".join(str(x) for x in (make, model, trim, year or "") if x)
96 +
97 + url = (f"{self.base_url}/vue/used-{year or ''}-"
98 + f"{_slug(make)}-{_slug(model)}-{ext_id}/")
99 +
100 + price = _num(d.get("specialprice")) or _num(d.get("price")) \
101 + or _num(d.get("saleprice"))
102 + km = _num(d.get("mileage"))
103 +
104 + images = []
105 + if d.get("picture"):
106 + images = [str(d["picture"])]
107 + media = d.get("media")
108 + if isinstance(media, list):
109 + for u in media:
110 + if isinstance(u, str) and u not in images:
111 + images.append(u)
112 + elif isinstance(u, dict):
113 + link = u.get("url") or u.get("src")
114 + if link and link not in images:
115 + images.append(str(link))
116 +
117 + details: dict = {}
118 + for key, out in (("certified", "certified"), ("demo", "demo"),
119 + ("asis", "as_is")):
120 + if d.get(key):
121 + details[out] = True
122 + if d.get("carprooflink"):
123 + carfax = str(d["carprooflink"])
124 + else:
125 + carfax = ""
126 + if d.get("rebatesamount"):
127 + details["rebates"] = d["rebatesamount"]
128 +
129 + return Vehicle(
130 + source=self.source_id,
131 + external_id=ext_id,
132 + url=url,
133 + title=title,
134 + make=make,
135 + model=model,
136 + trim=trim,
137 + year=year,
138 + price=price,
139 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
140 + mileage_km=km,
141 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
142 + transmission=_clean(d.get("transmission")),
143 + fuel=_clean(d.get("fueltype")),
144 + drivetrain=_clean(d.get("drivetrain")),
145 + body_type=body,
146 + exterior_color=_clean(d.get("exteriorcolor")),
147 + engine=_clean(d.get("engine")),
148 + seats=_num(d.get("seats"), int),
149 + vin=_clean(d.get("vin")),
150 + stock_number=_clean(d.get("stocknumber")),
151 + dealer_name=self.dealer_name,
152 + city=self.city,
153 + description=_clean(d.get("description"))[:4000],
154 + details=details,
155 + images=images[:20],
156 + carfax_url=carfax,
157 + )
added autoka/connectors/gatsby_dealers.py +180 −0
@@ -0,0 +1,180 @@
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/gatsby_dealers.py : connecteurs des concessionnaires de marque
5 +# sur la plateforme Gatsby « autoroot.io » (inventaire EvalAuto — Groupe
6 +# Beaucage : Kia Granby/Drummondville, Mazda Sherbrooke, Mitsubishi et
7 +# Nissan Drummondville, Nissan Victoriaville).
8 +#
9 +# Même plateforme que megacentredeliquidation (voir megacentre.py) : chaque
10 +# véhicule expose /page-data/auto-usage/<slug>/page-data.json -> `usedCar`
11 +# complet. MAIS contrairement au Méga Centre, le sitemap de ces sites ne
12 +# référence pas les pages véhicule individuelles — seulement les pages
13 +# catégorie. L'énumération passe donc par l'API AWS AppSync que le site
14 +# utilise lui-même côté client : le endpoint GraphQL et la clé x-api-key
15 +# sont publiés dans siteParameters du page-data de la page liste
16 +# (/page-data/auto-usage/page-data.json). La requête
17 +# getUsedCarSearchByRelevanceMultiTable, filtrée par nameDealer, renvoie
18 +# 30 items/page + nextFilter (curseur) -> tous les slugs.
19 +#
20 +# Le reste (cache hebdo des page-data détail, plafond AUTOKA_MAX_DETAILS,
21 +# usedCar -> Vehicle) est hérité de MegaCentreDeLiquidation.
22 +# AUTOKA_MAX_PAGES (env) limite la pagination AppSync pour les tests.
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import json
27 +import os
28 +import time
29 +
30 +from .megacentre import MegaCentreDeLiquidation
31 +
32 +_QUERY_NAME = "getUsedCarSearchByRelevanceMultiTable"
33 +
34 +# champs minimaux : on ne veut que les slugs (le détail vient du page-data)
35 +_ITEM_FIELDS = "{ nextFilter items { id slug nameDealer } }"
36 +
37 +
38 +class GatsbyEvalAutoConnector(MegaCentreDeLiquidation):
39 + """Base des sites Gatsby autoroot/EvalAuto sans sitemap véhicule."""
40 +
41 + source_id = "" # base non enregistrée par le registre
42 + base_url = ""
43 + dealer_name = ""
44 + city = ""
45 + request_delay = 1.0
46 + max_details = 300
47 + max_aws_pages = 60 # garde-fou dur (60 x 30 = 1 800 véh.)
48 +
49 + # -- POST GraphQL poli ----------------------------------------------------
50 + def _post_json(self, url: str, payload: dict, headers: dict) -> dict:
51 + wait = self.request_delay - (time.time() - self._last_request)
52 + if wait > 0:
53 + time.sleep(wait)
54 + resp = self.session.post(url, json=payload, headers=headers,
55 + timeout=self.timeout)
56 + self._last_request = time.time()
57 + resp.raise_for_status()
58 + return resp.json()
59 +
60 + @staticmethod
61 + def _gql(dealer: str, next_item: str | None) -> str:
62 + nxt = f', usedCarNextItem:"{next_item}"' if next_item else ""
63 + return f"""
64 + {{
65 + {_QUERY_NAME}(order: "DESC",
66 + searchValue: "",
67 + maxPrice: 1000000,
68 + minPrice: 0,
69 + maxYear: 2035,
70 + minYear: 1900,
71 + brand: [],
72 + model: [],
73 + nameDealer: {json.dumps([dealer])},
74 + extColor: [],
75 + drivetrain: [],
76 + passengers: [],
77 + engine: [],
78 + bodytype: [],
79 + transmission: [],
80 + certified: [true,false],
81 + discount: [true,false],
82 + prestige: [true,false]
83 + {nxt})
84 + {_ITEM_FIELDS}
85 + }}
86 + """
87 +
88 + # -- énumération des slugs via la page liste + AppSync ---------------------
89 + def _vehicle_slugs(self) -> list[str]:
90 + data = self.get(
91 + f"{self.base_url}/page-data/auto-usage/page-data.json").json()
92 + result = data.get("result") or {}
93 + params = (result.get("data") or {}).get("siteParameters") or {}
94 + context = result.get("pageContext") or {}
95 +
96 + # les 30 premiers véhicules sont pré-rendus dans le pageContext
97 + slugs = {str(c.get("slug")) for c in context.get("allUsedCar") or []
98 + if c.get("slug")}
99 +
100 + endpoint = str(params.get("awsEndpoint") or "")
101 + token = str(params.get("awsToken") or "")
102 + dealer = str(params.get("awsDealerNameFilter")
103 + or context.get("nameDealer") or self.dealer_name)
104 + if not endpoint or not token: # repli : pré-rendu seulement
105 + return sorted(slugs)
106 +
107 + cap = min(int(os.environ.get("AUTOKA_MAX_PAGES", self.max_aws_pages)),
108 + self.max_aws_pages)
109 + headers = {"x-api-key": token, "Content-Type": "application/json"}
110 + next_item: str | None = None
111 + for _ in range(cap):
112 + try:
113 + resp = self._post_json(endpoint,
114 + {"query": self._gql(dealer, next_item)},
115 + headers)
116 + except Exception:
117 + break # AppSync HS -> pré-rendu
118 + payload = (resp.get("data") or {}).get(_QUERY_NAME) or {}
119 + items = payload.get("items") or []
120 + for item in items:
121 + slug = str(item.get("slug") or "")
122 + if slug:
123 + slugs.add(slug)
124 + next_item = payload.get("nextFilter")
125 + if not items or not next_item:
126 + break
127 + return sorted(slugs)
128 +
129 + # -- usedCar -> Vehicle (ville de repli) -----------------------------------
130 + def _to_vehicle(self, slug, d):
131 + veh = super()._to_vehicle(slug, d)
132 + if veh is not None and not veh.city:
133 + veh.city = self.city
134 + return veh
135 +
136 +
137 +# ---------------------------------------------------------------------------
138 +# Concessionnaires (1 sous-classe = 1 source) — Groupe Beaucage
139 +# ---------------------------------------------------------------------------
140 +
141 +class KiaGranby(GatsbyEvalAutoConnector):
142 + source_id = "kiagranby"
143 + base_url = "https://www.kiagranby.com"
144 + dealer_name = "Kia de Granby"
145 + city = "Granby"
146 +
147 +
148 +class KiaDrummondville(GatsbyEvalAutoConnector):
149 + source_id = "kiadrummondville"
150 + base_url = "https://www.kiadrummondville.com"
151 + dealer_name = "Kia Drummondville"
152 + city = "Drummondville"
153 +
154 +
155 +class MazdaDeSherbrooke(GatsbyEvalAutoConnector):
156 + source_id = "mazdadesherbrooke"
157 + base_url = "https://www.mazdadesherbrooke.com"
158 + dealer_name = "Mazda de Sherbrooke"
159 + city = "Sherbrooke"
160 +
161 +
162 +class MitsubishiDrummondville(GatsbyEvalAutoConnector):
163 + source_id = "mitsubishidrummondville"
164 + base_url = "https://www.mitsubishidrummondville.com"
165 + dealer_name = "Mitsubishi Drummondville"
166 + city = "Drummondville"
167 +
168 +
169 +class NissanDrummondville(GatsbyEvalAutoConnector):
170 + source_id = "nissandrummondville"
171 + base_url = "https://www.nissandrummondville.com"
172 + dealer_name = "Nissan Drummondville"
173 + city = "Drummondville"
174 +
175 +
176 +class NissanVictoriaville(GatsbyEvalAutoConnector):
177 + source_id = "nissanvictoriaville"
178 + base_url = "https://www.nissanvictoriaville.com"
179 + dealer_name = "Nissan de Victoriaville"
180 + city = "Victoriaville"
added autoka/connectors/hgregoire.py +223 −0
@@ -0,0 +1,223 @@
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/hgregoire.py : connecteur HGrégoire (hgregoire.com), la plus
5 +# grosse chaîne de mégacentres d'occasion au Québec (St-Léonard, Laval,
6 +# St-Eustache, Saguenay/Chicoutimi, Carbonear/Gaspésie, etc.).
7 +#
8 +# Stratégie : la page de résultats /auto-usage?page=N est rendue côté
9 +# serveur (WordPress custom) et embarque, pour CHAQUE carte véhicule, un
10 +# <script type="application/json" data-class="Vehicle"> complet :
11 +# Id, année/marque/modèle/version, prix (CAD), odomètre, VIN, no de stock,
12 +# transmission, carburant, traction, carrosserie, couleurs, moteur,
13 +# consommation, photo principale, Permalink ET la succursale du véhicule
14 +# (FullLocation.city + province). 24 véhicules par page, ~74 pages —
15 +# AUCUNE page détail nécessaire : ~75 requêtes pour tout l'inventaire.
16 +#
17 +# Seules les succursales du QUÉBEC sont conservées (FullLocation.province
18 +# == "QC") — le groupe opère aussi aux États-Unis (hgreg.com) et un
19 +# véhicule hors-Québec serait exclu par ce garde-fou.
20 +#
21 +# robots.txt demande Crawl-delay: 10 → request_delay élevé (sync complet
22 +# ≈ 12 min, acceptable pour une synchronisation quotidienne).
23 +# AUTOKA_MAX_PAGES (env) limite le nombre de pages pour les tests.
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import json
28 +import os
29 +import re
30 +
31 +from ..normalize import MAKES
32 +from ..schema import Vehicle, strip_accents
33 +from .base import BaseConnector
34 +
35 +_MAKE_WORDS = {strip_accents(m).lower() for m in MAKES}
36 +
37 +_BLOB_RE = re.compile(
38 + r'<script type="application/json" data-class="Vehicle"[^>]*>'
39 + r'/\*<!\[CDATA\[\*/(.*?)/\*\]\]>\*/</script>', re.S)
40 +_PAGE_RE = re.compile(r'data-page="(\d+)"')
41 +_DOORS_RE = re.compile(r"(\d+)\s*portes?", re.I)
42 +# photo de carte 448x252 -> version plus grande servie par le même CDN
43 +_IMG_SIZE_RE = re.compile(r"/by-size/([^/]+)/\d+x\d+/")
44 +
45 +MILE_KM = 1.609344
46 +
47 +
48 +# bannières du groupe qui ne sont pas des noms de ville
49 +_BANNER_CITY = {
50 + "ineos grenadier": "Montréal", # INEOS Grenadier Montréal
51 + "le prix du gros": "Trois-Rivières", # Méga centre d'occasion Le Prix du Gros
52 +}
53 +
54 +
55 +def _clean_city(raw: str) -> str:
56 + """Succursale -> ville : « Kia Vaudreuil » -> « Vaudreuil »,
57 + « Nissan St-Eustache » -> « St-Eustache » (certaines succursales du
58 + groupe sont des bannières de marque ; on retire le préfixe de marque)."""
59 + banner = _BANNER_CITY.get(strip_accents(raw or "").lower().strip())
60 + if banner:
61 + return banner
62 + words = (raw or "").split()
63 + while len(words) > 1 and strip_accents(words[0]).lower() in _MAKE_WORDS:
64 + words = words[1:]
65 + return " ".join(words)
66 +
67 +
68 +class HGregoire(BaseConnector):
69 + """HGrégoire — inventaire usagé complet via les pages liste (SRP)."""
70 +
71 + source_id = "hgregoire"
72 + base_url = "https://www.hgregoire.com"
73 + dealer_name = "HGrégoire"
74 + request_delay = 10.0 # robots.txt : Crawl-delay: 10
75 + max_pages = 200 # garde-fou dur (~74 pages réelles)
76 +
77 + # -- pages liste ------------------------------------------------------
78 + def _list_html(self, page: int) -> str:
79 + url = f"{self.base_url}/auto-usage"
80 + if page > 1:
81 + url += f"?page={page}"
82 + return self.get(url).text
83 +
84 + def _parse_blobs(self, html: str) -> list[dict]:
85 + out = []
86 + for raw in _BLOB_RE.findall(html):
87 + try:
88 + out.append(json.loads(raw))
89 + except ValueError:
90 + continue
91 + return out
92 +
93 + # -- contrat ----------------------------------------------------------
94 + def fetch(self) -> list[Vehicle]:
95 + cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
96 + vehicles: list[Vehicle] = []
97 + seen: set[str] = set()
98 +
99 + html = self._list_html(1)
100 + pages = [int(p) for p in _PAGE_RE.findall(html)]
101 + last_page = min(max(pages) if pages else 1, cap, self.max_pages)
102 +
103 + page = 1
104 + while True:
105 + blobs = self._parse_blobs(html)
106 + if not blobs:
107 + break
108 + new = 0
109 + for d in blobs:
110 + veh = self._to_vehicle(d)
111 + if veh is not None and veh.external_id not in seen:
112 + seen.add(veh.external_id)
113 + vehicles.append(veh)
114 + new += 1
115 + page += 1
116 + if page > last_page or new == 0:
117 + break
118 + html = self._list_html(page)
119 + return vehicles
120 +
121 + # -- carte JSON -> Vehicle ----------------------------------------------
122 + def _to_vehicle(self, d: dict) -> Vehicle | None:
123 + if d.get("Sold") or d.get("Deleted") or d.get("IsForSale") is False:
124 + return None
125 + if str(d.get("Type") or "Used") != "Used": # usagés seulement
126 + return None
127 +
128 + loc = d.get("FullLocation") or {}
129 + province = (loc.get("province") or "").strip().upper()
130 + if province and province != "QC": # succursales US exclues
131 + return None
132 +
133 + ext_id = str(d.get("Id") or "").strip()
134 + url = d.get("Permalink") or ""
135 + if not ext_id or not url:
136 + return None
137 +
138 + year = d.get("Year")
139 + make = str(d.get("Make") or "").strip()
140 + model = str(d.get("Model") or "").strip()
141 + title = (d.get("ShortTitle")
142 + or " ".join(str(x) for x in (year, make, model) if x)).strip()
143 +
144 + price = d.get("Price")
145 + try:
146 + price = float(price) if price else None
147 + except (TypeError, ValueError):
148 + price = None
149 +
150 + km = d.get("Odometer")
151 + try:
152 + km = float(km) if km not in (None, "") else None
153 + except (TypeError, ValueError):
154 + km = None
155 + if km is not None and str(d.get("OdoUnitCode") or "").upper() == "SMI":
156 + km = round(km * MILE_KM) # milles -> km
157 +
158 + # portes / passagers / équipements depuis la liste CarDetails
159 + doors = seats = None
160 + features: list[str] = []
161 + for item in d.get("CarDetails") or []:
162 + name = str(item.get("name") or "").strip()
163 + value = str(item.get("value") or "").strip()
164 + if not value: # équipement pur
165 + m = _DOORS_RE.search(name)
166 + if m:
167 + doors = int(m.group(1))
168 + elif name and "Stock" not in name:
169 + features.append(name.rstrip(" :"))
170 + elif "passagers" in name.lower():
171 + try:
172 + seats = int(value)
173 + except ValueError:
174 + pass
175 +
176 + image = d.get("MainImage") or ""
177 + if image: # carte 448x252 -> grand
178 + image = _IMG_SIZE_RE.sub(r"/by-size/\1/1010x568/", image)
179 +
180 + post = d.get("post") or {}
181 + description = str(post.get("post_content") or "")[:4000]
182 +
183 + details: dict = {}
184 + if loc.get("city_slug"):
185 + details["branch_slug"] = loc["city_slug"]
186 + if d.get("Categories"):
187 + details["categories"] = d["Categories"]
188 + if d.get("HwyMPG"):
189 + details["consumption_hwy_l_100km"] = d["HwyMPG"]
190 + if d.get("OldPrice"):
191 + details["old_price"] = d["OldPrice"]
192 +
193 + return Vehicle(
194 + source=self.source_id,
195 + external_id=ext_id,
196 + url=url,
197 + title=title,
198 + make=make,
199 + model=model,
200 + trim=str(d.get("Trim") or "").strip(),
201 + year=int(year) if year else None,
202 + price=price,
203 + price_label=str(d.get("FormattedPrice") or ""),
204 + mileage_km=km,
205 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
206 + transmission=str(d.get("Transmission") or ""),
207 + fuel=str(d.get("Fuel") or ""),
208 + drivetrain=str(d.get("DrivetrainType") or ""),
209 + body_type=str(d.get("BodyStyle") or ""),
210 + exterior_color=str(d.get("ExteriorColor") or ""),
211 + interior_color=str(d.get("InteriorColor") or ""),
212 + engine=str(d.get("Engine") or ""),
213 + doors=doors,
214 + seats=seats,
215 + vin=str(d.get("VIN") or ""),
216 + stock_number=str(d.get("Stock") or ""),
217 + dealer_name=self.dealer_name,
218 + city=_clean_city(str(loc.get("city") or "").strip()),
219 + description=description,
220 + features=features,
221 + details=details,
222 + images=[image] if image else [],
223 + )
added autoka/connectors/jlkauto.py +217 −0
@@ -0,0 +1,217 @@
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/jlkauto.py : JLK Auto (Montréal) — plateforme EDealer
5 +# (edealer.ca ; WordPress + inventaire EDealer, CDN media.edealer.ca).
6 +#
7 +# Stratégie : le sitemap Yoast `/inventory-listing-sitemap.xml` recense les
8 +# pages véhicule (…-<token>vdp/) en FR et EN. Chaque page détail (VDP)
9 +# expose un JSON-LD schema.org @graph avec un nœud @type [Product, Car]
10 +# complet : prix, VIN, km, transmission, carrosserie, couleurs, moteur.
11 +# Pages détail mises en cache BD (clé hebdomadaire), comme les sites D2C.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import datetime
16 +import json
17 +import os
18 +import re
19 +
20 +from ..schema import Vehicle
21 +from .base import BaseConnector
22 +
23 +# …/inventaire/2005-chrysler-crossfire-limitee-jGKAbGVgRXus0SSkyI35kgvdp/
24 +# le token (id EDealer) est le dernier segment alphanumérique SANS tiret :
25 +# le même véhicule a un slug différent en FR et en EN, mais le même token
26 +_VDP_RE = re.compile(r"-([A-Za-z0-9]{12,})vdp/?$")
27 +
28 +_LD_RE = re.compile(
29 + r'<script type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.S)
30 +
31 +_DRIVE_MAP = {
32 + "FrontWheelDriveConfiguration": "Traction",
33 + "RearWheelDriveConfiguration": "Propulsion",
34 + "AllWheelDriveConfiguration": "Intégrale / 4x4",
35 + "FourWheelDriveConfiguration": "Intégrale / 4x4",
36 +}
37 +
38 +
39 +def _qty(node) -> float | None:
40 + """QuantitativeValue -> float ; accepte aussi un scalaire nu."""
41 + value = node.get("value") if isinstance(node, dict) else node
42 + try:
43 + v = float(value)
44 + return v if v > 0 else None
45 + except (TypeError, ValueError):
46 + return None
47 +
48 +
49 +class JLKAuto(BaseConnector):
50 + source_id = "jlkauto"
51 + base_url = "https://www.jlkauto.ca"
52 + dealer_name = "JLK Auto"
53 + city = "Montréal"
54 + request_delay: float = 1.0
55 + max_details = 400
56 +
57 + # -- sitemap ----------------------------------------------------------------
58 + def _vehicle_urls(self) -> list[str]:
59 + xml = self.get(f"{self.base_url}/inventory-listing-sitemap.xml").text
60 + by_token: dict[str, str] = {}
61 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
62 + loc = loc.strip()
63 + m = _VDP_RE.search(loc)
64 + if not m:
65 + continue # pages listes (…vlp) et autres
66 + token = m.group(1)
67 + # préférer la page FR (/inventaire/) à la page EN (/inventory/)
68 + if token not in by_token or "/inventaire/" in loc:
69 + by_token[token] = loc
70 + return [by_token[t] for t in sorted(by_token)]
71 +
72 + # -- page détail -> nœud JSON-LD [Product, Car] ------------------------------
73 + def _fetch_detail(self, url: str) -> dict:
74 + html = self.get(url).text
75 + for block in _LD_RE.findall(html):
76 + try:
77 + data = json.loads(block.strip())
78 + except ValueError:
79 + continue
80 + nodes = data.get("@graph") if isinstance(data, dict) else None
81 + if nodes is None:
82 + nodes = data if isinstance(data, list) else [data]
83 + for node in nodes:
84 + types = node.get("@type")
85 + types = types if isinstance(types, list) else [types]
86 + if "Car" in types or "Vehicle" in types:
87 + # galerie complète depuis le CDN EDealer (JSON-LD n'a
88 + # qu'une photo)
89 + imgs = re.findall(
90 + r'https://media\.edealer\.ca/[^"\'\\\s]+\.(?:webp|jpe?g|png)',
91 + html)
92 + seen: set[str] = set()
93 + node["_gallery"] = [u for u in imgs
94 + if not (u in seen or seen.add(u))][:20]
95 + return node
96 + return {}
97 +
98 + # -- contrat -----------------------------------------------------------------
99 + def fetch(self) -> list[Vehicle]:
100 + urls = self._vehicle_urls()
101 + week = datetime.date.today().isocalendar()
102 + cache_key = f"v1:{week.year}w{week.week}"
103 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
104 +
105 + vehicles: list[Vehicle] = []
106 + real_fetches = 0
107 + for url in urls:
108 + ext_id = _VDP_RE.search(url).group(1)
109 +
110 + def _fetch(u=url):
111 + return self._fetch_detail(u)
112 +
113 + if real_fetches >= cap: # plafond : cache seulement
114 + from .. import db
115 + if self._detail_con is None:
116 + self._detail_con = db.connect()
117 + data = db.get_cached_detail(self._detail_con, self.source_id,
118 + ext_id, cache_key)
119 + if data is None:
120 + continue
121 + else:
122 + before = self._last_request
123 + data = self.detail(ext_id, cache_key, _fetch)
124 + if self._last_request != before:
125 + real_fetches += 1
126 + if not data:
127 + continue
128 + veh = self._to_vehicle(ext_id, url, data)
129 + if veh is not None:
130 + vehicles.append(veh)
131 + return vehicles
132 +
133 + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:
134 + # véhicules usagés uniquement
135 + cond = str(d.get("itemCondition") or "")
136 + if cond and "Used" not in cond:
137 + return None
138 +
139 + title = " ".join(str(d.get("name") or "").split())
140 + if not title:
141 + return None
142 +
143 + offers = d.get("offers") or {}
144 + price = None
145 + try:
146 + p = float(offers.get("price"))
147 + price = p if p > 0 else None
148 + except (TypeError, ValueError):
149 + pass
150 +
151 + km = _qty(d.get("mileageFromOdometer"))
152 +
153 + brand = d.get("brand") or {}
154 + make = brand.get("name") if isinstance(brand, dict) else str(brand)
155 +
156 + drivetrain = ""
157 + drive_raw = str(d.get("driveWheelConfiguration") or "")
158 + for key, canon in _DRIVE_MAP.items():
159 + if key in drive_raw:
160 + drivetrain = canon
161 + break
162 +
163 + year = None
164 + for k in ("vehicleModelDate", "modelDate", "productionDate"):
165 + try:
166 + year = int(str(d.get(k))[:4])
167 + break
168 + except (TypeError, ValueError):
169 + continue
170 +
171 + engine = d.get("vehicleEngine") or {}
172 + engine_name = engine.get("name") if isinstance(engine, dict) else ""
173 +
174 + images = d.get("_gallery") or []
175 + if not images and isinstance(d.get("image"), str):
176 + images = [d["image"]]
177 +
178 + doors = d.get("numberOfDoors")
179 + doors = doors if isinstance(doors, int) else None
180 + try:
181 + seats = int(str(d.get("vehicleSeatingCapacity")))
182 + except (TypeError, ValueError):
183 + seats = None
184 +
185 + details = {k: d[k] for k in ("numberOfForwardGears", "wheelbase",
186 + "fuelConsumption") if d.get(k)}
187 +
188 + return Vehicle(
189 + source=self.source_id,
190 + external_id=ext_id,
191 + url=url,
192 + title=title,
193 + make=str(make or ""),
194 + model=str(d.get("model") or ""),
195 + trim=str(d.get("vehicleConfiguration") or ""),
196 + year=year,
197 + price=price,
198 + price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),
199 + mileage_km=km,
200 + mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),
201 + transmission=str(d.get("vehicleTransmission") or ""),
202 + fuel=str(d.get("fuelType") or ""),
203 + drivetrain=drivetrain,
204 + body_type=str(d.get("bodyType") or ""),
205 + exterior_color=str(d.get("color") or ""),
206 + interior_color=str(d.get("vehicleInteriorColor") or ""),
207 + engine=str(engine_name or ""),
208 + doors=doors,
209 + seats=seats,
210 + vin=str(d.get("vehicleIdentificationNumber") or ""),
211 + stock_number=str(d.get("sku") or ""),
212 + dealer_name=self.dealer_name,
213 + city=self.city,
214 + description=" ".join(str(d.get("description") or "").split())[:4000],
215 + details=details,
216 + images=images,
217 + )
added autoka/connectors/leprixdugros.py +253 −0
@@ -0,0 +1,253 @@
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/leprixdugros.py : connecteur Le Prix du Gros (leprixdugros.com),
5 +# groupe Mauricie / Centre-du-Québec (Kia, Nissan, Hyundai, Mazda + méga
6 +# centre d'occasion) — Trois-Rivières, Shawinigan, Donnacona, Lévis,
7 +# Québec, Laval, Sherbrooke, Joliette. INVENTAIRE OCCASION UNIQUEMENT.
8 +#
9 +# Plateforme : WordPress custom (thème Stereodev « lpdg-2022 »). La page
10 +# liste expose un endpoint JSON paginé non documenté (celui du filtre Vue) :
11 +# /inventaire/occasion/?output=json&pg=N -> {items(36/page), total, page}.
12 +# Chaque item : année/marque/modèle/version, km, VIN, prix, rabais, stock,
13 +# photo, permalien. ~37 pages pour ~1 300 véhicules d'occasion.
14 +#
15 +# La page détail (permalien) complète : moteur, rouage, transmission,
16 +# passagers, couleurs, photos ET la concession où se trouve le véhicule
17 +# (bloc « c-single-car-map-location » : nom + ville). Ces pages sont mises
18 +# en cache BD à vie (clé stable — les specs ne changent pas ; le prix et le
19 +# km frais viennent de la liste). AUTOKA_MAX_DETAILS (env) plafonne les
20 +# vraies requêtes détail par sync ; AUTOKA_MAX_PAGES limite la pagination.
21 +# -----------------------------------------------------------------------------
22 +from __future__ import annotations
23 +
24 +import html as htmllib
25 +import os
26 +import re
27 +
28 +from ..schema import Vehicle
29 +from .base import BaseConnector
30 +
31 +_SPEC_RE = re.compile(
32 + r'c-single-car-features__list__text">\s*<strong>([^<]+)</strong><br>\s*'
33 + r"<span>([^<]*)</span>", re.S)
34 +_LOC_NAME_RE = re.compile(
35 + r'c-single-car-map-location__name">\s*([^<]+?)\s*</span>', re.S)
36 +_LOC_ADDR_RE = re.compile(
37 + r'c-single-car-map-location__adresse">(.*?)</span>', re.S)
38 +_CITY_RE = re.compile(r"([A-Za-zÀ-ÿ'’ .-]+?)\s*,\s*(?:QC|Québec)\b")
39 +_IMG_RE = re.compile(r'https://images\.leprixdugros\.com/[^"\s\\<>]+')
40 +
41 +_FUEL_HINT_RE = re.compile(
42 + r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|electrique|"
43 + r"électrique|electric|\bev\b|diesel|tdi)", re.I)
44 +
45 +
46 +def _clean(text: str | None) -> str:
47 + if not text:
48 + return ""
49 + out = htmllib.unescape(str(text))
50 + out = re.sub(r"<[^>]+>", " ", out)
51 + return re.sub(r"\s{2,}", " ", out).strip()
52 +
53 +
54 +# clé de cache détail stable (bump si le parsing change)
55 +_DETAIL_KEY = "v2"
56 +
57 +
58 +class LePrixDuGros(BaseConnector):
59 + """Le Prix du Gros — JSON liste paginé + pages détail (specs, ville)."""
60 +
61 + source_id = "leprixdugros"
62 + base_url = "https://www.leprixdugros.com"
63 + dealer_name = "Le Prix du Gros"
64 + request_delay = 1.0
65 + max_pages = 80 # garde-fou dur (~37 pages réelles)
66 + max_details = 400 # plafond de vraies requêtes détail / sync
67 +
68 + # -- liste JSON ----------------------------------------------------------
69 + def _list_page(self, page: int) -> dict:
70 + url = (f"{self.base_url}/inventaire/occasion/"
71 + f"?output=json&pg={page}")
72 + try:
73 + return self.get(url).json()
74 + except ValueError:
75 + return {}
76 +
77 + # -- page détail -> payload (specs, concession, photos) -------------------
78 + def _fetch_detail(self, url: str) -> dict:
79 + html = self.get(url).text
80 + payload: dict = {"specs": {}, "images": []}
81 + for name, value in _SPEC_RE.findall(html):
82 + payload["specs"][_clean(name)] = _clean(value)
83 + m = _LOC_NAME_RE.search(html)
84 + if m:
85 + payload["dealer"] = _clean(m.group(1))
86 + m = _LOC_ADDR_RE.search(html)
87 + if m:
88 + # l'adresse est multi-lignes (<br>) : « 141 Rue Commerciale »,
89 + # « Donnacona, QC, G3M 1W2 », téléphone — la ville est le début
90 + # de LA ligne contenant « , QC »
91 + lines = [_clean(l) for l in re.split(r"<br\s*/?>", m.group(1))]
92 + lines = [l for l in lines if l]
93 + payload["address"] = ", ".join(lines[:2])
94 + for line in lines:
95 + c = _CITY_RE.match(line)
96 + if c:
97 + payload["city"] = c.group(1).strip()
98 + break
99 + seen: set[str] = set()
100 + for u in _IMG_RE.findall(html):
101 + if "_1000x750" in u and u not in seen:
102 + seen.add(u)
103 + payload["images"].append(u)
104 + return payload
105 +
106 + # -- contrat ---------------------------------------------------------------
107 + def fetch(self) -> list[Vehicle]:
108 + page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
109 + detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
110 +
111 + items: list[dict] = []
112 + seen_ids: set[str] = set()
113 + page, last_page = 1, 1
114 + while page <= min(last_page, page_cap, self.max_pages):
115 + data = self._list_page(page)
116 + batch = data.get("items") or []
117 + if not batch:
118 + break
119 + per_page = int(data.get("perPage") or len(batch) or 36)
120 + total = int(data.get("total") or 0)
121 + last_page = max(1, -(-total // max(per_page, 1))) # ceil
122 + new = 0
123 + for item in batch:
124 + ext_id = str(item.get("id") or item.get("stocknumber") or "")
125 + if ext_id and ext_id not in seen_ids:
126 + seen_ids.add(ext_id)
127 + items.append(item)
128 + new += 1
129 + if new == 0:
130 + break
131 + page += 1
132 +
133 + vehicles: list[Vehicle] = []
134 + real_fetches = 0
135 + for item in items:
136 + detail: dict = {}
137 + url = str(item.get("permalink") or "")
138 + ext_id = str(item.get("id") or item.get("stocknumber") or "")
139 + if url:
140 + def _fetch(u=url):
141 + return self._fetch_detail(u)
142 +
143 + # clé stable : specs figées ; prix/km frais via la liste
144 + if real_fetches >= detail_cap:
145 + from .. import db
146 + if self._detail_con is None:
147 + self._detail_con = db.connect()
148 + detail = db.get_cached_detail(self._detail_con,
149 + self.source_id,
150 + ext_id, _DETAIL_KEY) or {}
151 + else:
152 + before = self._last_request
153 + try:
154 + detail = self.detail(ext_id, _DETAIL_KEY, _fetch)
155 + except Exception:
156 + # page détail disparue ou timeout ponctuel : le
157 + # véhicule est quand même émis avec les données liste
158 + detail = {}
159 + if self._last_request != before:
160 + real_fetches += 1
161 + veh = self._to_vehicle(item, detail or {})
162 + if veh is not None:
163 + vehicles.append(veh)
164 + return vehicles
165 +
166 + # -- item liste + détail -> Vehicle ------------------------------------------
167 + def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None:
168 + inventory = str(item.get("inventory") or "used").lower()
169 + if inventory == "new": # occasion seulement
170 + return None
171 +
172 + ext_id = str(item.get("id") or item.get("stocknumber") or "")
173 + url = str(item.get("permalink") or "")
174 + if not ext_id or not url:
175 + return None
176 +
177 + make = str(item.get("make") or "").strip()
178 + model = str(item.get("model") or "").strip()
179 + trim = _clean(item.get("trim"))
180 + year = item.get("year")
181 + title = _clean(item.get("title"))
182 + title = re.sub(r"\s+d[’']occasion à vendre\s*$", "", title, flags=re.I) \
183 + or " ".join(str(x) for x in (make, model, trim, year or "") if x)
184 +
185 + price = item.get("price")
186 + try:
187 + price = float(price) if price else None
188 + except (TypeError, ValueError):
189 + price = None
190 +
191 + km = item.get("kms")
192 + try:
193 + km = float(km) if km not in (None, "") else None
194 + except (TypeError, ValueError):
195 + km = None
196 +
197 + specs = detail.get("specs") or {}
198 + engine = specs.get("Moteur", "")
199 + seats = None
200 + m = re.search(r"\d+", specs.get("Passagers", ""))
201 + if m:
202 + seats = int(m.group(0))
203 +
204 + # carburant : déduit du titre/version/moteur (non affiché ailleurs)
205 + fuel = ""
206 + m = _FUEL_HINT_RE.search(f"{title} {trim} {engine}")
207 + if m:
208 + fuel = m.group(1)
209 +
210 + images = list(detail.get("images") or [])
211 + if not images and item.get("photo"):
212 + images = [str(item["photo"])]
213 +
214 + details: dict = {"inventory_type": inventory}
215 + if item.get("rebate"):
216 + details["rebate"] = item["rebate"]
217 + if str(item.get("is_certified") or "0") not in ("0", "", "None"):
218 + details["certified"] = True
219 + if specs.get("Cabine"):
220 + details["cab"] = specs["Cabine"]
221 + if detail.get("address"):
222 + details["branch_address"] = detail["address"]
223 + if detail.get("dealer"):
224 + details["branch"] = detail["dealer"]
225 +
226 + return Vehicle(
227 + source=self.source_id,
228 + external_id=ext_id,
229 + url=url,
230 + title=title,
231 + make=make,
232 + model=model,
233 + trim=trim,
234 + year=int(year) if year else None,
235 + price=price,
236 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
237 + mileage_km=km,
238 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
239 + transmission=specs.get("Transmission", ""),
240 + fuel=fuel,
241 + drivetrain=specs.get("Rouage", ""),
242 + body_type="", # non exposé par la source
243 + exterior_color=specs.get("Couleur extérieure", ""),
244 + interior_color=specs.get("Couleur intérieure", ""),
245 + engine=engine,
246 + seats=seats,
247 + vin=str(item.get("vin") or ""),
248 + stock_number=str(item.get("stocknumber") or ""),
249 + dealer_name=detail.get("dealer") or self.dealer_name,
250 + city=detail.get("city", ""),
251 + details=details,
252 + images=images[:20],
253 + )
added autoka/connectors/magnetis_dealers.py +267 −0
@@ -0,0 +1,267 @@
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/magnetis_dealers.py : connecteurs des concessionnaires sur la
5 +# plateforme WordPress « magnetis-dealer » (agence Magnetis, inventaire
6 +# SyncAuto — photos syncauto-XX.s3.ca-central-1.amazonaws.com).
7 +#
8 +# Stratégie : le sitemap /inventory-sitemap.xml recense chaque page
9 +# véhicule /inventaire/occasion/<année>/<marque>/<modèle>/<id> (versions FR
10 +# et EN ; on ne garde que les FR, occasion seulement — le neuf vit sous
11 +# /inventaire/neuf/). Chaque page détail embarque :
12 +# - <script data-class="Vehicle"> : JSON complet (marque, modèle, année,
13 +# version, km, prix, transmission, rouage, carburant, carrosserie,
14 +# portes, passagers, couleur, no stock ET la concession réelle du
15 +# véhicule avec sa ville — certains sites du réseau affichent
16 +# l'inventaire de concessions sœurs) ;
17 +# - des JSON-LD schema.org @type Vehicle (le bon = productID == id) :
18 +# VIN, photos, description, vendeur.
19 +# Pages détail en cache BD (clé hebdomadaire) ; AUTOKA_MAX_DETAILS (env)
20 +# plafonne les vraies requêtes détail par synchronisation.
21 +# -----------------------------------------------------------------------------
22 +from __future__ import annotations
23 +
24 +import datetime
25 +import html as htmllib
26 +import json
27 +import os
28 +import re
29 +
30 +from ..schema import Vehicle
31 +from .base import BaseConnector
32 +
33 +_VEH_PATH_RE = re.compile(
34 + r"/inventaire/occasion/(?:19|20)\d{2}/[^/]+/[^/]+/(\d+)/?$")
35 +
36 +_DATA_CLASS_RE = re.compile(
37 + r'<script[^>]*data-class=["\']?Vehicle["\']?[^>]*>(.*?)</script>', re.S)
38 +_LD_RE = re.compile(
39 + r'<script type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.S)
40 +
41 +# garde-fou non-automobile (ces réseaux vendent parfois des VR/motoneiges)
42 +_EXCLUDE_RE = re.compile(
43 + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|\batv\b|scooter|spyder|ryker|"
44 + r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|motoris|campeur|"
45 + r"trailer|snowmobile", re.I)
46 +
47 +
48 +def _clean(text) -> str:
49 + if not text:
50 + return ""
51 + out = htmllib.unescape(str(text))
52 + out = re.sub(r"<[^>]+>", " ", out)
53 + return re.sub(r"\s{2,}", " ", out).strip()
54 +
55 +
56 +def _num(value, cast=float):
57 + try:
58 + v = cast(value)
59 + except (TypeError, ValueError):
60 + return None
61 + return v if v else None
62 +
63 +
64 +class MagnetisConnector(BaseConnector):
65 + """Base commune aux sites WordPress « magnetis-dealer » (SyncAuto)."""
66 +
67 + base_url: str = ""
68 + dealer_name: str = ""
69 + city: str = ""
70 + request_delay: float = 1.0
71 + max_details = 400 # plafond de vraies requêtes par sync
72 +
73 + # -- sitemap ---------------------------------------------------------------
74 + def _vehicle_urls(self) -> list[str]:
75 + xml = self.get(f"{self.base_url}/inventory-sitemap.xml").text
76 + urls: list[str] = []
77 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
78 + loc = loc.strip()
79 + if "/en/" in loc or not loc.startswith(self.base_url):
80 + continue
81 + if _VEH_PATH_RE.search(loc):
82 + urls.append(loc)
83 + return sorted(set(urls))
84 +
85 + # -- page détail -> payload ---------------------------------------------------
86 + def _fetch_detail(self, url: str, ext_id: str) -> dict:
87 + html = self.get(url).text
88 + payload: dict = {}
89 +
90 + m = _DATA_CLASS_RE.search(html)
91 + if m:
92 + try:
93 + payload["vehicle"] = json.loads(m.group(1).strip())
94 + except ValueError:
95 + pass
96 +
97 + # JSON-LD : la page inclut aussi des « véhicules similaires » — le
98 + # bon bloc est celui dont productID correspond à l'id de l'URL.
99 + for block in _LD_RE.findall(html):
100 + try:
101 + data = json.loads(block.strip(), strict=False)
102 + except ValueError:
103 + continue
104 + types = data.get("@type")
105 + types = types if isinstance(types, list) else [types]
106 + if "Vehicle" not in types and "Car" not in types:
107 + continue
108 + if str(data.get("productID") or "") == ext_id:
109 + payload["ld"] = data
110 + break
111 + return payload
112 +
113 + # -- contrat ---------------------------------------------------------------
114 + def fetch(self) -> list[Vehicle]:
115 + urls = self._vehicle_urls()
116 + week = datetime.date.today().isocalendar()
117 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
118 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
119 +
120 + vehicles: list[Vehicle] = []
121 + real_fetches = 0
122 + for url in urls:
123 + ext_id = _VEH_PATH_RE.search(url).group(1)
124 +
125 + def _fetch(u=url, e=ext_id):
126 + return self._fetch_detail(u, e)
127 +
128 + # plafond atteint : ne servir que le cache (fetch réel interdit)
129 + if real_fetches >= cap:
130 + from .. import db
131 + if self._detail_con is None:
132 + self._detail_con = db.connect()
133 + data = db.get_cached_detail(self._detail_con, self.source_id,
134 + ext_id, cache_key)
135 + if data is None:
136 + continue
137 + else:
138 + before = self._last_request
139 + try:
140 + data = self.detail(ext_id, cache_key, _fetch)
141 + except Exception:
142 + # page disparue (vendu) ou erreur ponctuelle : ne jamais
143 + # bloquer la source entière
144 + if self._last_request != before:
145 + real_fetches += 1
146 + continue
147 + if self._last_request != before:
148 + real_fetches += 1
149 + if not data or not data.get("vehicle"):
150 + continue
151 + veh = self._to_vehicle(ext_id, url, data)
152 + if veh is not None:
153 + vehicles.append(veh)
154 + return vehicles
155 +
156 + # -- payload -> Vehicle ------------------------------------------------------
157 + def _to_vehicle(self, ext_id: str, url: str, data: dict) -> Vehicle | None:
158 + v = data.get("vehicle") or {}
159 + ld = data.get("ld") or {}
160 +
161 + if str(v.get("status") or "used").lower() != "used":
162 + return None # occasion seulement
163 +
164 + make = _clean(v.get("make"))
165 + model = _clean(v.get("model"))
166 + body = _clean(v.get("body"))
167 + if not (make or model):
168 + return None
169 + if _EXCLUDE_RE.search(f"{make} {model} {body}"):
170 + return None
171 +
172 + year = _num(v.get("year"), int)
173 + trim = _clean(v.get("trim"))
174 + title = " ".join(str(x) for x in (make, model, trim, year or "") if x)
175 +
176 + price = _num(v.get("price"))
177 + km = _num(v.get("kms"))
178 +
179 + images = ld.get("image") or []
180 + if isinstance(images, str):
181 + images = [images]
182 + images = [u for u in images if isinstance(u, str)
183 + and "img-bkg" not in u] # bannière de repli du site
184 +
185 + # la concession réelle du véhicule (réseaux multi-concessions)
186 + dealer = _clean(v.get("dealer_name_fr") or v.get("dealer_name_en"))
187 + dealer = re.sub(r"\s+(?:used|occasion)$", "", dealer, flags=re.I)
188 + if dealer.lower() in ("", "sync"): # placeholder SyncAuto
189 + dealer = self.dealer_name
190 + city = _clean(v.get("dealer_city")).split(",")[0].strip() or self.city
191 +
192 + details: dict = {}
193 + if v.get("msrp") and _num(v["msrp"]) != price:
194 + details["msrp"] = v["msrp"]
195 + if v.get("create_date"):
196 + details["listed_date"] = str(v["create_date"])
197 +
198 + offers = ld.get("offers") or {}
199 + seller = offers.get("seller") or {}
200 + if isinstance(seller, dict) and seller.get("name"):
201 + details["branch"] = _clean(seller["name"])
202 +
203 + return Vehicle(
204 + source=self.source_id,
205 + external_id=ext_id,
206 + url=url,
207 + title=title,
208 + make=make,
209 + model=model,
210 + trim=trim,
211 + year=year,
212 + price=price,
213 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
214 + mileage_km=km,
215 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
216 + transmission=_clean(v.get("transmission")),
217 + fuel=_clean(v.get("fuel_type")),
218 + drivetrain=_clean(v.get("drive")),
219 + body_type=body,
220 + exterior_color=_clean(v.get("exterior_color")),
221 + engine=_clean(v.get("engine_size")),
222 + doors=_num(v.get("doors"), int),
223 + seats=_num(v.get("passengers"), int),
224 + vin=_clean(ld.get("vehicleIdentificationNumber")),
225 + stock_number=_clean(v.get("stock_number") or ld.get("sku")),
226 + dealer_name=dealer,
227 + city=city,
228 + description=_clean(ld.get("description"))[:4000],
229 + details=details,
230 + images=images[:20],
231 + )
232 +
233 +
234 +# ---------------------------------------------------------------------------
235 +# Concessionnaires (1 sous-classe = 1 source)
236 +# ---------------------------------------------------------------------------
237 +
238 +class BaieComeauToyota(MagnetisConnector):
239 + source_id = "baiecomeautoyota"
240 + base_url = "https://baiecomeautoyota.com"
241 + dealer_name = "Baie-Comeau Toyota"
242 + city = "Baie-Comeau"
243 +
244 +
245 +class SherbrookeToyota(MagnetisConnector):
246 + source_id = "sherbrooketoyota"
247 + base_url = "https://sherbrooketoyota.ca"
248 + dealer_name = "Sherbrooke Toyota"
249 + city = "Sherbrooke"
250 +
251 +
252 +class StGeorgesNissan(MagnetisConnector):
253 + source_id = "stgnissan"
254 + base_url = "https://www.stgnissan.com"
255 + dealer_name = "St-Georges Nissan"
256 + city = "Saint-Georges"
257 +
258 +
259 +class LussierChevrolet(MagnetisConnector):
260 + """Lussier Chevrolet Buick GMC — remplace Auto Bas Prix St-Hyacinthe
261 + (autobasprixst-hyacinthe.com redirige vers lussierchevrolet.com depuis
262 + la fusion des deux commerces)."""
263 +
264 + source_id = "lussierchevrolet"
265 + base_url = "https://www.lussierchevrolet.com"
266 + dealer_name = "Lussier Chevrolet Buick GMC"
267 + city = "Saint-Hyacinthe"
added autoka/connectors/megacentre.py +225 −0
@@ -0,0 +1,225 @@
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/megacentre.py : connecteur Méga Centre de liquidation
5 +# (megacentredeliquidation.com), place de marché d'occasion du Groupe
6 +# Laplante — Québec, Trois-Rivières, Rimouski, Matane, Mont-Joli, Amqui,
7 +# Rawdon (Lanaudière), etc.
8 +#
9 +# Plateforme : Gatsby (site statique sur Fastly), inventaire EvalAuto.
10 +# Le sitemap (/sitemap-index.xml -> /sitemap-0.xml) recense chaque page
11 +# véhicule /auto-usage/<marque-modele-annee-stock>/. Pour chaque slug, le
12 +# JSON Gatsby /page-data/auto-usage/<slug>/page-data.json expose l'objet
13 +# `usedCar` complet : prix, odomètre, NIV, stock, transmission, rouage,
14 +# carburant, carrosserie, couleurs, moteur, portes, places, options,
15 +# description, photos, CarProof ET la concession réelle du véhicule
16 +# (nameDealer + cityDealer — la ville par véhicule).
17 +#
18 +# Les page-data sont mises en cache BD (clé hebdomadaire, même logique que
19 +# les connecteurs D2C) : seuls les nouveaux véhicules et la revalidation
20 +# hebdomadaire génèrent de vraies requêtes. AUTOKA_MAX_DETAILS (env)
21 +# plafonne les vraies requêtes détail par synchronisation.
22 +# -----------------------------------------------------------------------------
23 +from __future__ import annotations
24 +
25 +import datetime
26 +import html as htmllib
27 +import os
28 +import re
29 +
30 +from ..schema import Vehicle
31 +from .base import BaseConnector
32 +
33 +# pages véhicule : /auto-usage/<slug>-<année>-<stock>/ (un seul segment)
34 +_VEH_URL_RE = re.compile(
35 + r"^https://www\.megacentredeliquidation\.com/auto-usage/"
36 + r"([a-z0-9-]+-(?:19|20)\d{2}-[a-z0-9-]+)/$")
37 +
38 +# le groupe liste aussi quelques non-automobiles (Can-Am Ryker, VR…)
39 +_EXCLUDE_RE = re.compile(
40 + r"can-?am|spyder|ryker|harley|ski-?doo|sea-?doo|polaris|kawasaki|"
41 + r"gulf ?stream|jayco|keystone|forest river|motoris|roulotte|remorque|"
42 + r"motorcycle|snowmobile|atv|\brv\b|trailer|motorhome", re.I)
43 +
44 +
45 +def _clean_html(text: str | None) -> str:
46 + """HTML avec entités (&eacute;…) -> texte brut compact."""
47 + if not text:
48 + return ""
49 + out = htmllib.unescape(str(text))
50 + out = re.sub(r"<[^>]+>", " ", out)
51 + return re.sub(r"\s{2,}", " ", out).strip()
52 +
53 +
54 +class MegaCentreDeLiquidation(BaseConnector):
55 + """Méga Centre de liquidation — sitemap + page-data Gatsby (usedCar)."""
56 +
57 + source_id = "megacentredeliquidation"
58 + base_url = "https://www.megacentredeliquidation.com"
59 + dealer_name = "Méga Centre de liquidation"
60 + request_delay = 1.0
61 + max_details = 600 # plafond de vraies requêtes par sync
62 +
63 + # -- sitemap -------------------------------------------------------------
64 + def _vehicle_slugs(self) -> list[str]:
65 + index = self.get(f"{self.base_url}/sitemap-index.xml").text
66 + maps = re.findall(r"<loc>([^<]+)</loc>", index) \
67 + or [f"{self.base_url}/sitemap-0.xml"]
68 + slugs: list[str] = []
69 + for sitemap_url in maps:
70 + xml = self.get(sitemap_url.strip()).text
71 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
72 + m = _VEH_URL_RE.match(loc.strip())
73 + if m:
74 + slugs.append(m.group(1))
75 + return sorted(set(slugs))
76 +
77 + # -- page-data Gatsby -> payload usedCar ----------------------------------
78 + def _fetch_detail(self, slug: str) -> dict:
79 + url = f"{self.base_url}/page-data/auto-usage/{slug}/page-data.json"
80 + try:
81 + data = self.get(url).json()
82 + except ValueError:
83 + return {}
84 + result = data.get("result") or {}
85 + used_car = (result.get("data") or {}).get("usedCar")
86 + if not isinstance(used_car, dict):
87 + return {}
88 + used_car.pop("coordinates", None) # gros bloc inutile
89 + used_car.pop("loanPaiement", None)
90 + return {"usedCar": used_car,
91 + "latestModification":
92 + (result.get("pageContext") or {}).get("latestModification")}
93 +
94 + # -- contrat ---------------------------------------------------------------
95 + def fetch(self) -> list[Vehicle]:
96 + slugs = self._vehicle_slugs()
97 + week = datetime.date.today().isocalendar()
98 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
99 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
100 +
101 + vehicles: list[Vehicle] = []
102 + real_fetches = 0
103 + for slug in slugs:
104 + def _fetch(s=slug):
105 + return self._fetch_detail(s)
106 +
107 + # plafond atteint : ne servir que le cache (fetch réel interdit)
108 + if real_fetches >= cap:
109 + from .. import db
110 + if self._detail_con is None:
111 + self._detail_con = db.connect()
112 + data = db.get_cached_detail(self._detail_con, self.source_id,
113 + slug, cache_key)
114 + if data is None:
115 + continue
116 + else:
117 + before = self._last_request
118 + try:
119 + data = self.detail(slug, cache_key, _fetch)
120 + except Exception:
121 + # page disparue (vendu) ou erreur ponctuelle : ne jamais
122 + # bloquer la source entière
123 + if self._last_request != before:
124 + real_fetches += 1
125 + continue
126 + if self._last_request != before: # une vraie requête a eu lieu
127 + real_fetches += 1
128 + if not data or not data.get("usedCar"):
129 + continue
130 + veh = self._to_vehicle(slug, data["usedCar"])
131 + if veh is not None:
132 + vehicles.append(veh)
133 + return vehicles
134 +
135 + # -- usedCar -> Vehicle ------------------------------------------------------
136 + def _to_vehicle(self, slug: str, d: dict) -> Vehicle | None:
137 + make = str(d.get("brand") or "").strip()
138 + model = str(d.get("labelModel_fr") or d.get("model") or "").strip()
139 + body = str(d.get("labelBodytype_fr") or d.get("bodytype") or "").strip()
140 + if _EXCLUDE_RE.search(f"{make} {model} {body}"): # non-automobile
141 + return None
142 +
143 + year = d.get("year")
144 + trim = str(d.get("trim") or "").strip()
145 + # certains labels de modèle incluent déjà la version (X1 XDRIVE28I)
146 + title_trim = "" if trim.lower() in model.lower() else trim
147 + title = " ".join(str(x)
148 + for x in (make, model, title_trim, year or "") if x)
149 + if not title:
150 + return None
151 +
152 + price = d.get("price")
153 + try:
154 + price = float(price) if price else None
155 + except (TypeError, ValueError):
156 + price = None
157 +
158 + km = d.get("odometer")
159 + try:
160 + km = float(km) if km not in (None, "") else None
161 + except (TypeError, ValueError):
162 + km = None
163 + unit = str(d.get("odometer_Type") or "km").lower()
164 + if km is not None and unit.startswith("mi"):
165 + km = round(km * 1.609344)
166 +
167 + images = d.get("images") or []
168 + if isinstance(images, str):
169 + images = [images]
170 + images = [u for u in images if isinstance(u, str)]
171 + if not images and d.get("image"):
172 + images = [str(d["image"])]
173 +
174 + features = [f.strip() for f in str(d.get("options") or "").split("|")
175 + if f.strip()]
176 +
177 + doors = d.get("doors")
178 + seats = d.get("passengers")
179 +
180 + details: dict = {"branch": str(d.get("nameDealer") or "")}
181 + for src, dst in (("accident", "accidented"),
182 + ("certified", "certified"),
183 + ("uniqueOwner", "unique_owner")):
184 + if d.get(src) is not None:
185 + details[dst] = bool(d[src])
186 + if d.get("cityConsumption"):
187 + details["consumption_city_l_100km"] = d["cityConsumption"]
188 + if d.get("hightwayConsumption"):
189 + details["consumption_hwy_l_100km"] = d["hightwayConsumption"]
190 + if d.get("inventoryID"):
191 + details["inventory_id"] = str(d["inventoryID"])
192 +
193 + return Vehicle(
194 + source=self.source_id,
195 + external_id=slug,
196 + url=f"{self.base_url}/auto-usage/{slug}/",
197 + title=title,
198 + make=make,
199 + model=model,
200 + trim=trim,
201 + year=int(year) if year else None,
202 + price=price,
203 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
204 + mileage_km=km,
205 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
206 + transmission=str(d.get("transmission") or ""),
207 + fuel=str(d.get("labelEngine_fr") or d.get("engine") or ""),
208 + drivetrain=str(d.get("drivetrain") or ""),
209 + body_type=body,
210 + exterior_color=str(d.get("labelColorExt_fr") or ""),
211 + interior_color=str(d.get("labelColorInt_fr") or ""),
212 + engine=str(d.get("motor") or ""),
213 + doors=int(doors) if doors else None,
214 + seats=int(seats) if seats else None,
215 + vin=str(d.get("niv") or ""),
216 + stock_number=str(d.get("stockNO") or ""),
217 + dealer_name=str(d.get("nameDealer") or "").strip()
218 + or self.dealer_name,
219 + city=str(d.get("cityDealer") or "").strip(),
220 + description=_clean_html(d.get("description"))[:4000],
221 + features=features[:60],
222 + details=details,
223 + images=images[:20],
224 + carfax_url=str(d.get("carproofURL") or ""),
225 + )
added autoka/connectors/occasionbeaucage.py +235 −0
@@ -0,0 +1,235 @@
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/occasionbeaucage.py : connecteur Occasion Beaucage
5 +# (occasionbeaucage.com), groupe de mégacentres d'occasion de l'Estrie /
6 +# Centre-du-Québec / Montérégie (Sherbrooke, Magog, Granby, Drummondville,
7 +# Victoriaville, Saint-Hyacinthe, Cowansville) — ~1 200 véhicules.
8 +#
9 +# Plateforme : Next.js App Router (autoroot.io) sur Vercel, inventaire
10 +# EvalAuto. Chaque page liste /auto-usage/<n>/ (24 véhicules/page) embarque
11 +# dans son flux RSC (self.__next_f.push) un objet
12 +# {"vehicles":[...],"count":N,"page":n} où chaque véhicule est COMPLET :
13 +# prix, km, VIN, stock, transmission, carburant, carrosserie, couleurs,
14 +# moteur, portes, places, carfax, photos ET la concession du véhicule
15 +# (display_dealer_name / display_city). AUCUNE page détail nécessaire :
16 +# ~52 requêtes pour tout l'inventaire, ville exacte par véhicule.
17 +#
18 +# Les refs de flux («$313») pointant vers d'autres lignes RSC (les listes
19 +# de photos) sont résolues localement. AUTOKA_MAX_PAGES (env) limite la
20 +# pagination pour les tests.
21 +# -----------------------------------------------------------------------------
22 +from __future__ import annotations
23 +
24 +import json
25 +import os
26 +import re
27 +
28 +from ..schema import Vehicle
29 +from .base import BaseConnector
30 +
31 +# chunks du flux RSC : self.__next_f.push([1,"<chaîne JS>"])
32 +_FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,("(?:\\.|[^"\\])*")\]\)')
33 +
34 +# non-automobiles éventuels (garde-fou ; le groupe ne vend que des autos)
35 +_EXCLUDE_RE = re.compile(
36 + r"can-?am|harley|ski-?doo|sea-?doo|spyder|ryker|motoneige|vtt|"
37 + r"motomarine|roulotte|remorque", re.I)
38 +
39 +
40 +def _js_str(literal: str) -> str:
41 + """Chaîne littérale JS (avec guillemets) -> texte. Les chunks RSC sont
42 + encodés comme du JSON ; repli tolérant si un escape non-JSON traîne."""
43 + try:
44 + return json.loads(literal)
45 + except ValueError:
46 + out = literal[1:-1].encode("utf-8").decode("unicode_escape")
47 + try: # unicode_escape mange l'UTF-8 -> réparer
48 + return out.encode("latin-1").decode("utf-8")
49 + except (UnicodeEncodeError, UnicodeDecodeError):
50 + return out
51 +
52 +
53 +def _s(value) -> str:
54 + """Scalaire du flux RSC -> str propre ('$undefined', '$D...' -> '')."""
55 + if value is None:
56 + return ""
57 + text = str(value).strip()
58 + return "" if text.startswith("$") else text
59 +
60 +
61 +class OccasionBeaucage(BaseConnector):
62 + """Occasion Beaucage — inventaire complet via les pages liste (RSC)."""
63 +
64 + source_id = "occasionbeaucage"
65 + base_url = "https://www.occasionbeaucage.com"
66 + dealer_name = "Occasion Beaucage"
67 + request_delay = 1.0
68 + max_pages = 120 # garde-fou dur (~52 pages réelles)
69 +
70 + # -- flux RSC ----------------------------------------------------------
71 + def _flight_blob(self, html: str) -> str:
72 + return "".join(_js_str(m) for m in _FLIGHT_RE.findall(html))
73 +
74 + def _page_payload(self, page: int) -> dict:
75 + """{"vehicles": [...], "count": N, "page": n} de la page liste."""
76 + url = f"{self.base_url}/auto-usage/"
77 + if page > 1:
78 + url += f"{page}/"
79 + blob = self._flight_blob(self.get(url).text)
80 + decoder = json.JSONDecoder()
81 + for m in re.finditer(r'"vehicles":\[', blob):
82 + start = blob.rfind("{", 0, m.start())
83 + if start < 0:
84 + continue
85 + try:
86 + obj, _ = decoder.raw_decode(blob, start)
87 + except ValueError:
88 + continue
89 + if isinstance(obj, dict) and "count" in obj \
90 + and isinstance(obj.get("vehicles"), list):
91 + obj["_blob"] = blob # pour résoudre les refs $
92 + return obj
93 + return {}
94 +
95 + @staticmethod
96 + def _resolve_ref(blob: str, ref: str):
97 + """«$313» -> payload JSON de la ligne RSC 313 (ex. liste de photos).
98 +
99 + Les lignes à préfixe de longueur (T<hex>,) ne sont pas suivies d'un
100 + saut de ligne — on repère donc «<id>:» n'importe où et on valide en
101 + décodant le JSON qui suit (raw_decode s'arrête à la fin du tableau).
102 + """
103 + decoder = json.JSONDecoder()
104 + pattern = re.compile(re.escape(ref[1:]) + r':(?:T[0-9a-f]+,)?(?=[\["])')
105 + for row in pattern.finditer(blob):
106 + try:
107 + value, _ = decoder.raw_decode(blob, row.end())
108 + except ValueError:
109 + continue
110 + if isinstance(value, (list, str)):
111 + return value
112 + return None
113 +
114 + # -- contrat -----------------------------------------------------------
115 + def fetch(self) -> list[Vehicle]:
116 + cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))
117 + vehicles: list[Vehicle] = []
118 + seen: set[str] = set()
119 +
120 + page, last_page = 1, 1
121 + while page <= min(last_page, cap, self.max_pages):
122 + payload = self._page_payload(page)
123 + batch = payload.get("vehicles") or []
124 + if not batch:
125 + break
126 + per_page = max(len(batch), 1)
127 + count = int(payload.get("count") or 0)
128 + last_page = max(1, -(-count // per_page)) # ceil
129 + new = 0
130 + for d in batch:
131 + veh = self._to_vehicle(d, payload.get("_blob", ""))
132 + if veh is not None and veh.external_id not in seen:
133 + seen.add(veh.external_id)
134 + vehicles.append(veh)
135 + new += 1
136 + if new == 0: # page recyclée
137 + break
138 + page += 1
139 + return vehicles
140 +
141 + # -- enregistrement liste -> Vehicle -------------------------------------
142 + def _to_vehicle(self, d: dict, blob: str) -> Vehicle | None:
143 + if d.get("is_sold") or d.get("is_active") is False:
144 + return None
145 +
146 + slug = _s(d.get("slug"))
147 + ext_id = _s(d.get("inventory_provider_vehicle_id")) or slug
148 + if not ext_id or not slug:
149 + return None
150 +
151 + make = _s(d.get("make"))
152 + model = _s(d.get("model"))
153 + body = _s(d.get("body_type"))
154 + if _EXCLUDE_RE.search(f"{make} {model} {body}"): # non-automobile
155 + return None
156 +
157 + year = d.get("year")
158 + trim = _s(d.get("trim"))
159 + title = " ".join(x for x in (make, model, trim, _s(year)) if x)
160 +
161 + price = d.get("price")
162 + try:
163 + price = float(price) if price else None
164 + except (TypeError, ValueError):
165 + price = None
166 +
167 + km = d.get("mileage_from_odometer")
168 + try:
169 + km = float(km) if km not in (None, "") else None
170 + except (TypeError, ValueError):
171 + km = None
172 +
173 + images = d.get("images")
174 + if isinstance(images, str) and images.startswith("$"):
175 + images = self._resolve_ref(blob, images)
176 + if isinstance(images, str) and images.lstrip().startswith("["):
177 + try: # parfois une liste JSON sérialisée
178 + images = json.loads(images)
179 + except ValueError:
180 + images = []
181 + if isinstance(images, str):
182 + images = [images]
183 + images = [u for u in (images or []) if isinstance(u, str)][:20]
184 +
185 + doors = d.get("doors")
186 + seats = d.get("seating_capacity")
187 +
188 + details: dict = {}
189 + for src, dst in (("is_certified", "certified"),
190 + ("is_accidented", "accidented"),
191 + ("is_unique_owner", "unique_owner")):
192 + if d.get(src) is not None:
193 + details[dst] = bool(d[src])
194 + if _s(d.get("fuel_consumption_city")):
195 + details["consumption_city_l_100km"] = _s(d["fuel_consumption_city"])
196 + if _s(d.get("fuel_consumption_highway")):
197 + details["consumption_hwy_l_100km"] = _s(d["fuel_consumption_highway"])
198 + promo = _s(d.get("promotional_price"))
199 + if promo and promo not in ("0", "0.0"):
200 + details["promotional_price"] = promo
201 + if _s(d.get("dealer_id")):
202 + details["branch_slug"] = _s(d["dealer_id"])
203 +
204 + return Vehicle(
205 + source=self.source_id,
206 + external_id=ext_id,
207 + url=f"{self.base_url}/auto-usage/{slug}/",
208 + title=title,
209 + make=make,
210 + model=model,
211 + trim=trim,
212 + year=int(year) if year else None,
213 + price=price,
214 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
215 + mileage_km=km,
216 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
217 + transmission=_s(d.get("transmission")),
218 + fuel=_s(d.get("fuel_type")),
219 + drivetrain=_s(d.get("drive_wheel_configuration")),
220 + body_type=body,
221 + exterior_color=_s(d.get("exterior_color")),
222 + interior_color=_s(d.get("interior_color")),
223 + engine=_s(d.get("engine_type")),
224 + doors=int(doors) if doors else None,
225 + seats=int(seats) if seats else None,
226 + vin=_s(d.get("vin")),
227 + stock_number=_s(d.get("stock_number")),
228 + dealer_name=_s(d.get("display_dealer_name")) or
229 + _s(d.get("dealer_name")) or self.dealer_name,
230 + city=_s(d.get("display_city")) or _s(d.get("city")),
231 + description=_s(d.get("short_description"))[:4000],
232 + details=details,
233 + images=images,
234 + carfax_url=_s(d.get("carfax_url")),
235 + )
added autoka/connectors/okaze.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 +# connectors/okaze.py : OKAZE (Saguenay) — site Webflow (CMS w-dyn-list).
5 +#
6 +# Stratégie : la page /inventaire est rendue côté serveur avec TOUTES les
7 +# annonces (pas de pagination). Chaque carte embarque un <script> inline qui
8 +# pose des attributs data-* dont `data-search` : une chaîne CSV complète —
9 +# nom, année, carrosserie, catégorie, couleur ext., couleur int., marque,
10 +# modèle, moteur, portes, km, prix, motricité, transmission, carburant.
11 +# On parse ces cartes en HTML brut (regex) : une seule requête par sync.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from ..schema import Vehicle
18 +from .base import BaseConnector
19 +
20 +# OKAZE « vend tout » : écarter les catégories non automobiles
21 +_EXCLUDE_RE = re.compile(
22 + r"motoneige|vtt|bateau|ponton|motomarine|\bmoto\b|roulotte|remorque|"
23 + r"\bvr\b|campeur|tracteur|souffleuse|scooter|spyder|side-by-side|atv",
24 + re.I)
25 +
26 +_CARD_SPLIT = re.compile(r'class="item-inventaire w-dyn-item"')
27 +_HREF_RE = re.compile(r'href="(/vehicules/[^"]+)"')
28 +_IMG_RE = re.compile(r'<img src="(https://[^"]+)"')
29 +_ATTR_RE = re.compile(
30 + r"item\.setAttribute\('data-([a-z-]+)',\s*(?:formatDate)?\(\"(.*?)\"\)",
31 + re.S)
32 +
33 +
34 +class Okaze(BaseConnector):
35 + source_id = "okaze"
36 + base_url = "https://www.okaze.ca"
37 + dealer_name = "OKAZE"
38 + city = "Saguenay"
39 + request_delay: float = 1.0
40 +
41 + def fetch(self) -> list[Vehicle]:
42 + html = self.get(f"{self.base_url}/inventaire").text
43 + vehicles: list[Vehicle] = []
44 + for block in _CARD_SPLIT.split(html)[1:]:
45 + veh = self._parse_card(block)
46 + if veh is not None:
47 + vehicles.append(veh)
48 + return vehicles
49 +
50 + def _parse_card(self, block: str) -> Vehicle | None:
51 + m = _HREF_RE.search(block)
52 + if not m:
53 + return None
54 + path = m.group(1)
55 + ext_id = path.rstrip("/").rsplit("/", 1)[-1]
56 +
57 + attrs = {name: value for name, value in _ATTR_RE.findall(block)}
58 + title = " ".join((attrs.get("name") or "").split())
59 + search = attrs.get("search") or ""
60 + fields = [f.strip() for f in search.split(",")]
61 +
62 + # data-search : positions fixes quand la fiche est complète (15 champs)
63 + body = category = ext_color = int_color = engine = ""
64 + drivetrain = transmission = fuel = ""
65 + doors = km = None
66 + if len(fields) >= 15:
67 + (_, _, body, category, ext_color, int_color, _, _,
68 + engine) = fields[:9]
69 + drivetrain, transmission, fuel = fields[-3:]
70 + try:
71 + doors = int(fields[9]) or None
72 + except ValueError:
73 + doors = None
74 + try:
75 + km = float(fields[10])
76 + except ValueError:
77 + km = None
78 + else: # fiche partielle : heuristiques
79 + for f in fields:
80 + if re.fullmatch(r"\d{2,6}", f) and km is None:
81 + km = float(f)
82 + elif "intégrale" in f.lower() or "traction" in f.lower() \
83 + or "propulsion" in f.lower():
84 + drivetrain = f
85 + elif f.lower() in ("automatique", "manuelle", "cvt"):
86 + transmission = f
87 +
88 + if _EXCLUDE_RE.search(f"{title} {category} {body}"):
89 + return None
90 + if not title:
91 + return None
92 +
93 + price_label = attrs.get("price", "")
94 + if not price_label:
95 + pm = re.search(r'class="inventory-price"[^>]*>([^<]+)<', block)
96 + price_label = pm.group(1).strip() if pm else ""
97 +
98 + details = {}
99 + if category:
100 + details["category"] = category
101 + if attrs.get("added"):
102 + details["listed_date"] = attrs["added"]
103 +
104 + img = _IMG_RE.search(block)
105 +
106 + return Vehicle(
107 + source=self.source_id,
108 + external_id=ext_id,
109 + url=f"{self.base_url}{path}",
110 + title=title,
111 + make=attrs.get("brand", "").title(),
112 + model=attrs.get("model", ""),
113 + price_label=price_label,
114 + mileage_km=km,
115 + mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),
116 + transmission=transmission,
117 + fuel=fuel,
118 + drivetrain=drivetrain,
119 + body_type=body,
120 + exterior_color=ext_color.capitalize(),
121 + interior_color=int_color.capitalize(),
122 + engine=engine,
123 + doors=doors,
124 + dealer_name=self.dealer_name,
125 + city=self.city,
126 + details=details,
127 + images=[img.group(1)] if img else [],
128 + )
added autoka/connectors/ototr.py +174 −0
@@ -0,0 +1,174 @@
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/ototr.py : OTO Trois-Rivières — WordPress, thème « Motors »
5 +# (stm_vehicles_listing). Pas d'API REST exposée ni de JSON-LD.
6 +#
7 +# Stratégie : le sitemap natif WP `/wp-sitemap-posts-listings-1.xml` recense
8 +# les annonces (/listings/<slug>/). Chaque page détail est parsée en HTML
9 +# (BeautifulSoup) : h1 = titre, .single-regular-price = prix, tableau
10 +# .single-car-data = fiche technique (Odomètre, Carburant, Transmission,
11 +# Propulsion, Carrosserie, Moteur, Couleurs…). Pages détail en cache BD
12 +# (clé hebdomadaire).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import datetime
17 +import os
18 +import re
19 +
20 +from bs4 import BeautifulSoup
21 +
22 +from ..schema import Vehicle
23 +from .base import BaseConnector
24 +
25 +_EXCLUDE_RE = re.compile(
26 + r"motoneige|vtt|bateau|ponton|motomarine|\bmoto\b|roulotte|remorque|"
27 + r"campeur|tracteur|souffleuse|scooter|spyder|atv", re.I)
28 +
29 +# vignettes WordPress (-350x205 etc.) à écarter de la galerie
30 +_THUMB_RE = re.compile(r"-\d{2,4}x\d{2,4}\.(?:jpe?g|png|webp)$", re.I)
31 +
32 +# libellés de la fiche technique -> champ Vehicle
33 +_SPEC_FIELDS = {
34 + "carrosserie": "body_type",
35 + "carburant": "fuel",
36 + "transmission": "transmission",
37 + "propulsion": "drivetrain",
38 + "moteur": "engine",
39 + "couleur exterieure": "exterior_color",
40 + "couleur interieure": "interior_color",
41 +}
42 +
43 +
44 +def _flat(text: str) -> str:
45 + from ..schema import strip_accents
46 + return strip_accents(" ".join(text.split())).strip().lower()
47 +
48 +
49 +class OtoTroisRivieres(BaseConnector):
50 + source_id = "ototroisrivieres"
51 + base_url = "https://ototroisrivieres.com"
52 + dealer_name = "OTO Trois-Rivières"
53 + city = "Trois-Rivières"
54 + request_delay: float = 1.0
55 + max_details = 200
56 +
57 + # -- sitemap ----------------------------------------------------------------
58 + def _vehicle_urls(self) -> list[str]:
59 + xml = self.get(f"{self.base_url}/wp-sitemap-posts-listings-1.xml").text
60 + return [loc.strip() for loc in re.findall(r"<loc>([^<]+)</loc>", xml)
61 + if "/listings/" in loc]
62 +
63 + # -- page détail -> payload dict ---------------------------------------------
64 + def _fetch_detail(self, url: str) -> dict:
65 + soup = BeautifulSoup(self.get(url).text, "html.parser")
66 + payload: dict = {"specs": {}}
67 +
68 + h1 = soup.find("h1")
69 + if h1:
70 + payload["title"] = " ".join(h1.get_text(" ", strip=True).split())
71 +
72 + price_el = soup.select_one(".single-regular-price")
73 + if price_el:
74 + payload["price_label"] = price_el.get_text(" ", strip=True)
75 +
76 + for row in soup.select(".single-car-data tr"):
77 + label = row.select_one(".t-label")
78 + value = row.select_one(".t-value")
79 + if label and value:
80 + payload["specs"][label.get_text(" ", strip=True)] = \
81 + value.get_text(" ", strip=True)
82 +
83 + og_desc = soup.find("meta", attrs={"property": "og:description"})
84 + if og_desc and og_desc.get("content"):
85 + payload["description"] = og_desc["content"]
86 +
87 + seen: set[str] = set()
88 + images = []
89 + candidates = [img.get("src") or img.get("data-src") or ""
90 + for img in soup.find_all("img")]
91 + candidates += [a.get("href") or "" for a in soup.find_all("a")]
92 + for src in candidates:
93 + if "/wp-content/uploads/" not in src or _THUMB_RE.search(src) \
94 + or not re.search(r"\.(?:jpe?g|png|webp)$", src, re.I):
95 + continue
96 + if src not in seen:
97 + seen.add(src)
98 + images.append(src)
99 + payload["images"] = images[:20]
100 + return payload
101 +
102 + # -- contrat -----------------------------------------------------------------
103 + def fetch(self) -> list[Vehicle]:
104 + urls = self._vehicle_urls()
105 + week = datetime.date.today().isocalendar()
106 + cache_key = f"v1:{week.year}w{week.week}"
107 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
108 +
109 + vehicles: list[Vehicle] = []
110 + real_fetches = 0
111 + for url in urls:
112 + ext_id = url.rstrip("/").rsplit("/", 1)[-1]
113 +
114 + def _fetch(u=url):
115 + return self._fetch_detail(u)
116 +
117 + if real_fetches >= cap: # plafond : cache seulement
118 + from .. import db
119 + if self._detail_con is None:
120 + self._detail_con = db.connect()
121 + data = db.get_cached_detail(self._detail_con, self.source_id,
122 + ext_id, cache_key)
123 + if data is None:
124 + continue
125 + else:
126 + before = self._last_request
127 + data = self.detail(ext_id, cache_key, _fetch)
128 + if self._last_request != before:
129 + real_fetches += 1
130 + if not data or not data.get("title"):
131 + continue
132 + veh = self._to_vehicle(ext_id, url, data)
133 + if veh is not None:
134 + vehicles.append(veh)
135 + return vehicles
136 +
137 + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:
138 + title = d.get("title", "")
139 + if not title or _EXCLUDE_RE.search(title):
140 + return None
141 + if re.search(r"vendu", title, re.I): # annonce conservée mais vendue
142 + return None
143 + title = re.sub(r"\*+\s*", " ", title).strip()
144 +
145 + fields = {"body_type": "", "fuel": "", "transmission": "",
146 + "drivetrain": "", "engine": "", "exterior_color": "",
147 + "interior_color": ""}
148 + mileage_label = ""
149 + details: dict = {}
150 + for label, value in (d.get("specs") or {}).items():
151 + key = _flat(label)
152 + if key.startswith("odom"):
153 + # le thème affiche « 10500 KMkm » — garder tel quel,
154 + # parse_mileage s'en accommode
155 + mileage_label = value
156 + elif key in _SPEC_FIELDS:
157 + fields[_SPEC_FIELDS[key]] = value
158 + else:
159 + details[label] = value
160 +
161 + return Vehicle(
162 + source=self.source_id,
163 + external_id=ext_id,
164 + url=url,
165 + title=title.title() if title.isupper() else title,
166 + price_label=d.get("price_label", ""),
167 + mileage_label=mileage_label,
168 + dealer_name=self.dealer_name,
169 + city=self.city,
170 + description=" ".join(str(d.get("description") or "").split())[:4000],
171 + details=details,
172 + images=d.get("images") or [],
173 + **fields,
174 + )
added autoka/connectors/sm360_dealers.py +446 −0
@@ -0,0 +1,446 @@
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/sm360_dealers.py : connecteurs des concessionnaires sur plateforme
5 +# SM360 / 360.agency (fournisseur de sites très répandu chez les
6 +# concessionnaires franchisés au Québec).
7 +#
8 +# Stratégie : le sitemap (/fr/sitemap-xml) recense chaque page véhicule
9 +# d'occasion (/fr/inventaire-occasion/<marque>/<modèle>/<slug>-id<digits>).
10 +# Chaque page détail est server-rendered et expose :
11 +# 1. un JSON-LD schema.org (@type Car) : prix, km, VIN, transmission,
12 +# carrosserie, couleurs, portes/places, moteur, succursale vendeuse ;
13 +# 2. un dataLayer JS : stockNo, isNew/isDemo, catégorie de véhicule,
14 +# carburant, succursale (utile pour les groupes multi-succursales) ;
15 +# 3. la galerie photo complète (img.sm360.ca/…/images/inventory/…) ;
16 +# 4. le lien Carfax le cas échéant.
17 +# Les pages détail sont mises en cache BD (clé hebdomadaire) : seuls les
18 +# nouveaux véhicules — et une revalidation par semaine pour les prix —
19 +# génèrent de vraies requêtes.
20 +#
21 +# Le listing lui-même est un widget JS (GraphQL webauto-supplier-api) —
22 +# inutile ici, le sitemap suffit. robots.txt : Allow /*?page=* et /*/api/*,
23 +# Crawl-delay 10 → délai généreux + cache détail.
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import datetime
28 +import html as htmllib
29 +import json
30 +import os
31 +import re
32 +import time
33 +
34 +import requests
35 +
36 +from ..schema import Vehicle
37 +from .base import BaseConnector
38 +
39 +# URLs de véhicules d'occasion dans le sitemap (jamais catalogue-neuf /
40 +# inventaire-neuf, donc jamais de véhicules neufs)
41 +_VEH_URL_RE = re.compile(r"/fr/inventaire-occasion/.+-id(\d+)/?$")
42 +
43 +# segments marque/modèle qui ne sont PAS des automobiles (récréatif)
44 +_EXCLUDE_SEGMENTS = re.compile(
45 + r"ski-?doo|sea-?doo|can-?am|polaris|arctic-?cat|lynx|brp|harley|"
46 + r"kawasaki|suzuki-moto|ktm|husqvarna|triumph|ducati|vespa|"
47 + r"motoneige|vtt|moto(?:s)?\b|motomarine|bateau|ponton|roulotte|"
48 + r"remorque|\bvr\b|campeur|spyder|ryker|side-?by-?side|atv|snow|"
49 + r"powersport|nautique", re.I)
50 +
51 +# catégories dataLayer non automobiles (par prudence — normalement le
52 +# sitemap /fr/inventaire-occasion ne contient que des autos)
53 +_EXCLUDE_CATEGORIES = {
54 + "motorcycle", "atv", "snowmobile", "watercraft", "boat", "rv",
55 + "trailer", "sxs", "utv", "scooter", "powersport",
56 +}
57 +
58 +_LD_RE = re.compile(
59 + r'<script type=["\']application/ld\+json["\']>(.*?)</script>', re.S)
60 +
61 +# galerie photo : URLs img.sm360.ca, avec ou sans préfixe resizer /ir/wNNN/
62 +_IMG_RE = re.compile(
63 + r'https://img\.sm360\.ca/(?:ir/[a-zA-Z0-9]+/)?'
64 + r'(images/inventory/[^"\'\s<>\\]+\.(?:jpe?g|png|webp))', re.I)
65 +
66 +_CARFAX_RE = re.compile(r'https?://(?:vhr\.)?carfax\.ca/[^\s"\'<>]+', re.I)
67 +
68 +# clés extraites du dataLayer JS de la page détail (valeurs 'entre quotes')
69 +_DL_KEYS = (
70 + "salePrice", "regularPrice", "mileage", "stockNo", "vehicleVin",
71 + "isNew", "isDemo", "vehicleCategory", "condition", "vehicleLocation",
72 + "vehicleFuelType", "transmission", "trim", "make", "model", "year",
73 +)
74 +
75 +_DRIVE_MAP = {
76 + "FrontWheelDriveConfiguration": "Traction",
77 + "RearWheelDriveConfiguration": "Propulsion",
78 + "AllWheelDriveConfiguration": "Intégrale / 4x4",
79 + "FourWheelDriveConfiguration": "Intégrale / 4x4",
80 +}
81 +
82 +
83 +def _clean(text: str | None) -> str:
84 + """Désencode les entités HTML et retire balises / « s/o » SM360."""
85 + if not text:
86 + return ""
87 + out = htmllib.unescape(htmllib.unescape(str(text)))
88 + out = re.sub(r"<[^>]+>", " ", out)
89 + out = re.sub(r"\s{2,}", " ", out).strip()
90 + return "" if out.lower() in ("s/o", "n/d", "n/a") else out
91 +
92 +
93 +def _num(value) -> float | None:
94 + """float() tolérant ; 0 et négatif → None (SM360 met 0 = prix caché)."""
95 + try:
96 + f = float(value)
97 + except (TypeError, ValueError):
98 + return None
99 + return f if f > 0 else None
100 +
101 +
102 +class SM360Connector(BaseConnector):
103 + """Base commune aux sites SM360 / 360.agency. Sous-classes : base_url…"""
104 +
105 + base_url: str = ""
106 + dealer_name: str = ""
107 + city: str = ""
108 + request_delay: float = 1.5 # robots.txt SM360 demande de la patience
109 + use_firecrawl: bool = False # au cas où un site passe sous Cloudflare
110 +
111 + # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit
112 + # pour les tests) — le reste sera complété aux syncs suivants
113 + max_details = 600
114 +
115 + def _get_text(self, url: str) -> str:
116 + if self.use_firecrawl:
117 + return self.get_rendered(url)
118 + try:
119 + return self.get(url).text
120 + except requests.exceptions.ConnectionError:
121 + # le serveur SM360 ferme parfois la connexion keep-alive : on
122 + # repart sur une connexion neuve et on réessaie une fois
123 + self.session.close()
124 + time.sleep(2.0)
125 + return self.get(url).text
126 +
127 + # -- sitemap ---------------------------------------------------------------
128 + def _vehicle_urls(self) -> list[str]:
129 + xml = self._get_text(f"{self.base_url}/fr/sitemap-xml")
130 + urls: list[str] = []
131 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
132 + loc = loc.strip()
133 + if not _VEH_URL_RE.search(loc):
134 + continue
135 + # segments /fr/inventaire-occasion/<marque>/<modèle>/<slug>
136 + path = loc.split("/fr/inventaire-occasion/", 1)[1]
137 + segments = path.split("/")[:-1]
138 + if any(_EXCLUDE_SEGMENTS.search(s) for s in segments):
139 + continue
140 + urls.append(loc)
141 + return urls
142 +
143 + # -- page détail -> payload (JSON-LD + dataLayer + galerie + carfax) --------
144 + def _fetch_detail(self, url: str, ext_id: str) -> dict:
145 + html = self._get_text(url)
146 + payload: dict = {"ld": {}, "dl": {}, "images": [], "carfax": ""}
147 +
148 + for block in _LD_RE.findall(html):
149 + try:
150 + data = json.loads(block.strip(), strict=False)
151 + except ValueError:
152 + continue
153 + items = data if isinstance(data, list) else [data]
154 + for item in items:
155 + if not isinstance(item, dict):
156 + continue
157 + types = item.get("@type")
158 + types = types if isinstance(types, list) else [types]
159 + if "Car" in types or "Vehicle" in types:
160 + payload["ld"] = item
161 + break
162 + if payload["ld"]:
163 + break
164 +
165 + for key in _DL_KEYS:
166 + m = re.search(r"\b" + key + r"\s*:\s*'((?:[^'\\]|\\.)*)'", html)
167 + if m:
168 + payload["dl"][key] = m.group(1).replace("\\'", "'").strip()
169 +
170 + # galerie : uniquement les photos de CE véhicule, sans préfixe resizer,
171 + # triées par index _NNN de fin de nom de fichier
172 + seen: set[str] = set()
173 + images: list[str] = []
174 + for path in _IMG_RE.findall(html):
175 + if f"/{ext_id}/" not in path:
176 + continue
177 + full = "https://img.sm360.ca/" + path
178 + if full not in seen:
179 + seen.add(full)
180 + images.append(full)
181 +
182 + def _idx(u: str) -> int:
183 + m = re.search(r"_(\d+)\.\w+$", u)
184 + return int(m.group(1)) if m else 999
185 +
186 + payload["images"] = sorted(images, key=_idx)[:20]
187 +
188 + m = _CARFAX_RE.search(html)
189 + if m:
190 + payload["carfax"] = htmllib.unescape(m.group(0))
191 + return payload
192 +
193 + # -- multi-succursales (hook, surchargé par TLM) -----------------------------
194 + def _dealer_city(self, dl: dict, ld: dict) -> tuple[str, str]:
195 + return self.dealer_name, self.city
196 +
197 + # -- contrat ---------------------------------------------------------------
198 + def fetch(self) -> list[Vehicle]:
199 + urls = self._vehicle_urls()
200 + week = datetime.date.today().isocalendar()
201 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
202 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
203 +
204 + vehicles: list[Vehicle] = []
205 + real_fetches = 0
206 + for url in urls:
207 + ext_id = _VEH_URL_RE.search(url).group(1)
208 +
209 + def _fetch(u=url, e=ext_id):
210 + return self._fetch_detail(u, e)
211 +
212 + # plafond atteint : ne servir que le cache (fetch réel interdit)
213 + if real_fetches >= cap:
214 + from .. import db
215 + if self._detail_con is None:
216 + self._detail_con = db.connect()
217 + data = db.get_cached_detail(self._detail_con, self.source_id,
218 + ext_id, cache_key)
219 + if data is None:
220 + continue
221 + else:
222 + before = self._last_request
223 + try:
224 + data = self.detail(ext_id, cache_key, _fetch)
225 + except Exception:
226 + # page disparue (vendu) ou erreur ponctuelle : ne jamais
227 + # bloquer la source entière
228 + if self._last_request != before:
229 + real_fetches += 1
230 + continue
231 + if self._last_request != before: # une vraie requête a eu lieu
232 + real_fetches += 1
233 + if not data:
234 + continue
235 + veh = self._to_vehicle(ext_id, url, data)
236 + if veh is not None:
237 + vehicles.append(veh)
238 + return vehicles
239 +
240 + def _to_vehicle(self, ext_id: str, url: str, data: dict) -> Vehicle | None:
241 + ld: dict = data.get("ld") or {}
242 + dl: dict = data.get("dl") or {}
243 + if not ld and not dl:
244 + return None
245 +
246 + # filtres de sécurité : jamais de neuf, jamais de récréatif
247 + if dl.get("isNew", "").lower() == "true":
248 + return None
249 + if dl.get("vehicleCategory", "").lower() in _EXCLUDE_CATEGORIES:
250 + return None
251 +
252 + brand = ld.get("brand") or {}
253 + make = _clean(brand.get("name") if isinstance(brand, dict) else brand)
254 + model = _clean(ld.get("model")) or _clean(dl.get("model")).title()
255 +
256 + year = None
257 + for raw in (ld.get("vehicleModelDate"), ld.get("modelDate"),
258 + dl.get("year")):
259 + if raw:
260 + try:
261 + year = int(str(raw)[:4])
262 + break
263 + except ValueError:
264 + pass
265 +
266 + offers = ld.get("offers") or {}
267 + price = _num(offers.get("price")) or _num(dl.get("salePrice")) \
268 + or _num(dl.get("regularPrice"))
269 +
270 + odo = ld.get("mileageFromOdometer") or {}
271 + km = _num(odo.get("value") if isinstance(odo, dict) else odo) \
272 + or _num(dl.get("mileage"))
273 +
274 + # titre : le "name" JSON-LD varie (parfois "CR-V", parfois
275 + # "Chevrolet Colorado 4WD Trail Boss 2025") — on complète au besoin
276 + title = _clean(ld.get("name")) or f"{make} {model}".strip()
277 + if make and make.lower() not in title.lower():
278 + title = f"{make} {title}"
279 + if year and str(year) not in title:
280 + title = f"{title} {year}"
281 +
282 + drive_raw = str(ld.get("driveWheelConfiguration") or "")
283 + drivetrain = ""
284 + for key, canon in _DRIVE_MAP.items():
285 + if key in drive_raw:
286 + drivetrain = canon
287 + break
288 +
289 + engines = ld.get("vehicleEngine") or []
290 + if isinstance(engines, dict):
291 + engines = [engines]
292 + fuel, engine_txt = "", ""
293 + if engines and isinstance(engines[0], dict):
294 + fuel = _clean(engines[0].get("fuelType"))
295 + e_type = _clean(engines[0].get("engineType"))
296 + if len(e_type) > 3: # "4" = nb cylindres, pas descriptif
297 + engine_txt = e_type
298 + fuel = fuel or _clean(dl.get("vehicleFuelType"))
299 +
300 + transmission = _clean(ld.get("vehicleTransmission")) \
301 + or _clean(dl.get("transmission"))
302 +
303 + trim = _clean(ld.get("vehicleConfiguration"))
304 + if not trim:
305 + trim = _clean(dl.get("trim")).upper()
306 +
307 + images = list(data.get("images") or [])
308 + if not images:
309 + ld_img = ld.get("image") or []
310 + images = [ld_img] if isinstance(ld_img, str) else list(ld_img)
311 + # écarter les stubs sans nom de fichier (véhicule sans photo)
312 + images = [u for u in images if isinstance(u, str)
313 + and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I)]
314 +
315 + def _int(v):
316 + try:
317 + return int(str(v))
318 + except (TypeError, ValueError):
319 + return None
320 +
321 + dealer_name, city = self._dealer_city(dl, ld)
322 +
323 + details: dict = {}
324 + if dl.get("isDemo", "").lower() == "true":
325 + details["demo"] = True
326 + if dl.get("condition"):
327 + details["condition"] = dl["condition"]
328 +
329 + return Vehicle(
330 + source=self.source_id,
331 + external_id=ext_id,
332 + url=url,
333 + title=title,
334 + make=make,
335 + model=model,
336 + trim=trim,
337 + year=year,
338 + price=price,
339 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
340 + mileage_km=km,
341 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
342 + transmission=transmission,
343 + fuel=fuel,
344 + drivetrain=drivetrain,
345 + body_type=_clean(ld.get("bodyType")),
346 + exterior_color=_clean(ld.get("color")),
347 + interior_color=_clean(ld.get("vehicleInteriorColor")),
348 + engine=engine_txt,
349 + doors=_int(ld.get("numberOfDoors")),
350 + seats=_int(ld.get("vehicleSeatingCapacity")
351 + or ld.get("seatingCapacity")),
352 + vin=(_clean(ld.get("vehicleIdentificationNumber"))
353 + or _clean(dl.get("vehicleVin"))).upper(),
354 + stock_number=_clean(dl.get("stockNo")) or _clean(ld.get("sku")),
355 + dealer_name=dealer_name,
356 + city=city,
357 + description=_clean(ld.get("description"))[:4000],
358 + details=details,
359 + images=images,
360 + carfax_url=data.get("carfax") or "",
361 + )
362 +
363 +
364 +# ---------------------------------------------------------------------------
365 +# Concessionnaires (1 sous-classe = 1 source)
366 +# ---------------------------------------------------------------------------
367 +
368 +class ToutesLesMarques(SM360Connector):
369 + """Groupe multi-succursales (Chicoutimi, Baie-Comeau, Sept-Îles).
370 +
371 + Le dataLayer `vehicleLocation` donne la succursale qui détient le
372 + véhicule ; la ville est déduite du nom de la succursale.
373 + """
374 + source_id = "touteslesmarques"
375 + base_url = "https://www.touteslesmarques.ca"
376 + dealer_name = "Toutes les marques"
377 + city = "Chicoutimi"
378 +
379 + def _dealer_city(self, dl: dict, ld: dict) -> tuple[str, str]:
380 + branch = _clean(dl.get("vehicleLocation"))
381 + if not branch or branch.lower() == "touteslesmarques.ca":
382 + return self.dealer_name, self.city
383 + low = branch.lower()
384 + if "baie" in low and "comeau" in low:
385 + city = "Baie-Comeau"
386 + elif "sept" in low and ("iles" in low or "îles" in low):
387 + city = "Sept-Îles"
388 + else: # Arnold, L'Ami Junior, Intégral Subaru…
389 + city = "Chicoutimi"
390 + return branch, city
391 +
392 +
393 +class StJeromeAutoDepot(SM360Connector):
394 + source_id = "stjeromeautodepot"
395 + base_url = "https://www.stjeromeautodepot.com"
396 + dealer_name = "St-Jérôme Auto Dépôt"
397 + city = "Saint-Jérôme"
398 +
399 +
400 +class LamontagneAuto(SM360Connector):
401 + # concessionnaire Honda — le sitemap /fr/inventaire-occasion ne contient
402 + # que l'occasion (le neuf vit sous /fr/catalogue-neuf, jamais ramassé)
403 + source_id = "lamontagneauto"
404 + base_url = "https://www.lamontagneauto.com"
405 + dealer_name = "Lamontagne Auto"
406 + city = "Rimouski"
407 +
408 +
409 +# ---------------------------------------------------------------------------
410 +# Expansion 2 — concessionnaires SM360 supplémentaires
411 +# ---------------------------------------------------------------------------
412 +
413 +class Spinelli(SM360Connector):
414 + """Groupe multi-concessions de l'île de Montréal (Pointe-Claire, Lachine…)."""
415 + source_id = "spinelli"
416 + base_url = "https://www.spinelli.com"
417 + dealer_name = "Groupe Spinelli"
418 + city = "Montréal"
419 +
420 +
421 +class AutoDesjardins(SM360Connector):
422 + source_id = "autodesjardins"
423 + base_url = "https://www.autodesjardins.com"
424 + dealer_name = "Automobiles Desjardins"
425 + city = "Québec"
426 +
427 +
428 +class LallierHondaHull(SM360Connector):
429 + source_id = "lallierhondahull"
430 + base_url = "https://www.lallierhondahull.com"
431 + dealer_name = "Lallier Honda Hull"
432 + city = "Gatineau"
433 +
434 +
435 +class RiveSudChrysler(SM360Connector):
436 + source_id = "rivesudchrysler"
437 + base_url = "https://www.rivesudchrysler.com"
438 + dealer_name = "Rive-Sud Chrysler"
439 + city = "Brossard"
440 +
441 +
442 +class ToyotaGatineau(SM360Connector):
443 + source_id = "toyotagatineau"
444 + base_url = "https://www.toyotagatineau.ca"
445 + dealer_name = "Toyota Gatineau"
446 + city = "Gatineau"
added autoka/connectors/stefoychrysler.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/stefoychrysler.py : connecteur Ste-Foy Chrysler
5 +# (ste-foychrysler.com), gros concessionnaire Chrysler/Dodge/Jeep/RAM de
6 +# Québec (arrondissement Sainte-Foy) — ~400+ véhicules d'occasion.
7 +#
8 +# Plateforme : Next.js App Router (autoroot.io), inventaire EvalAuto —
9 +# exactement la même que Occasion Beaucage : chaque page liste
10 +# /auto-usage/<n>/ embarque dans son flux RSC (self.__next_f.push) l'objet
11 +# {"vehicles":[...],"count":N} avec tous les champs par véhicule (prix, km,
12 +# VIN, stock, transmission, carburant, carrosserie, couleurs, photos,
13 +# display_dealer_name / display_city). On réutilise donc OccasionBeaucage
14 +# telle quelle, seuls l'URL et les libellés changent.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +from .occasionbeaucage import OccasionBeaucage
19 +
20 +
21 +class SteFoyChrysler(OccasionBeaucage):
22 + """Ste-Foy Chrysler — pages liste Next.js RSC (pattern Occasion Beaucage)."""
23 +
24 + source_id = "stefoychrysler"
25 + base_url = "https://www.ste-foychrysler.com"
26 + dealer_name = "Ste-Foy Chrysler"
27 + city = "Québec"
28 + max_pages = 60 # ~18 pages réelles (427 véh. / 24)
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
added autoka/connectors/vvu_dealers.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/vvu_dealers.py : connecteurs des concessionnaires indépendants sur
5 +# la plateforme ADWS (adws.ca) — sites Next.js au titre commun « Vente de
6 +# Véhicules Usagés - Voitures d'Occasion de Qualité » (d'où « VVU »).
7 +#
8 +# Stratégie : chaque site expose une API JSON interne `/api/inventory`
9 +# (paginée : ?page=N&limit=M) qui retourne TOUTES les données véhicule
10 +# (prix, km, VIN, moteur, couleurs, équipements FR, photos…). Une ou deux
11 +# requêtes suffisent par sync — aucune page détail à visiter.
12 +#
13 +# L'URL de la page détail se reconstruit depuis le slug
14 +# `<make>-<model>-<year>-<_id>` ; le préfixe varie selon la version du
15 +# gabarit ADWS déployée (`/fr/vehicle/…` ou `/fr/occasion/…`).
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import re
20 +
21 +from ..schema import Vehicle, strip_accents
22 +from .base import BaseConnector
23 +
24 +# garde-fou : ces marchands ne vendent que des automobiles, mais on écarte
25 +# tout intrus « sport motorisé » au cas où
26 +_EXCLUDE_RE = re.compile(
27 + r"motoneige|vtt|bateau|ponton|motomarine|\bmoto\b|roulotte|remorque|"
28 + r"campeur|tracteur|souffleuse|scooter|spyder|side-by-side|atv|snowmobile",
29 + re.I)
30 +
31 +
32 +def _slugify(text: str) -> str:
33 + """« Mercedes-Benz C-Class » -> « mercedes-benz-c-class » (règle ADWS)."""
34 + out = strip_accents(str(text or "")).lower()
35 + out = re.sub(r"[^a-z0-9]+", "-", out)
36 + return out.strip("-")
37 +
38 +
39 +def _num(value) -> float | None:
40 + try:
41 + v = float(value)
42 + return v if v > 0 else None
43 + except (TypeError, ValueError):
44 + return None
45 +
46 +
47 +class ADWSConnector(BaseConnector):
48 + """Base commune aux sites ADWS. Sous-classes : base_url + chemin détail."""
49 +
50 + base_url: str = ""
51 + dealer_name: str = ""
52 + city: str = ""
53 + region: str = "" # si la ville échappe à infer_region()
54 + detail_path: str = "vehicle" # « vehicle » ou « vehicule » selon la
55 + # version du gabarit ADWS du site
56 + request_delay: float = 1.0
57 + page_size: int = 100
58 +
59 + def _get_json(self, url: str) -> dict:
60 + """GET avec retries : le backend Next.js/serverless d'ADWS renvoie
61 + parfois un 500 transitoire (démarrage à froid)."""
62 + import time
63 + import requests as _rq
64 + for attempt in range(3):
65 + try:
66 + return self.get(url).json()
67 + except (_rq.HTTPError, ValueError):
68 + if attempt == 2:
69 + raise
70 + time.sleep(3 * (attempt + 1))
71 +
72 + def _api_pages(self):
73 + page = 1
74 + while True:
75 + data = self._get_json(f"{self.base_url}/api/inventory"
76 + f"?page={page}&limit={self.page_size}")
77 + items = data.get("data") or []
78 + yield from items
79 + total = data.get("totalFiltered") or data.get("total") or 0
80 + if not items or page * self.page_size >= total:
81 + return
82 + page += 1
83 +
84 + def _detail_url(self, d: dict) -> str:
85 + slug = "-".join(filter(None, [
86 + _slugify(d.get("make")), _slugify(d.get("model")),
87 + str(d.get("year") or "")])) + f"-{d['_id']}"
88 + return f"{self.base_url}/fr/{self.detail_path}/{slug}"
89 +
90 + def fetch(self) -> list[Vehicle]:
91 + vehicles: list[Vehicle] = []
92 + for d in self._api_pages():
93 + if not d.get("_id"):
94 + continue
95 + if (d.get("saleclass") or "used").lower() != "used":
96 + continue # neufs / démonstrateurs
97 + status = (d.get("status") or "").lower()
98 + if status and status not in ("available", "disponible"):
99 + continue # vendus / en préparation
100 + title = " ".join(str(d.get("heading") or "").split())
101 + if not title:
102 + title = " ".join(filter(None, [str(d.get("year") or ""),
103 + d.get("make"), d.get("model")]))
104 + if _EXCLUDE_RE.search(title):
105 + continue
106 +
107 + price = _num(d.get("price"))
108 + special = _num(d.get("special_price"))
109 + details: dict = {}
110 + if special and price and special < price:
111 + details["regular_price"] = price
112 + price = special # le prix affiché = le spécial
113 +
114 + km = _num(d.get("km"))
115 + photos = [u for u in (d.get("photo") or []) if isinstance(u, str)]
116 + features = [f for f in (d.get("vehicle_options_fr")
117 + or d.get("vehicle_options") or [])
118 + if isinstance(f, str)]
119 +
120 + for key in ("version", "engine_size", "cylinders", "entrydate",
121 + "tyres_size", "tags"):
122 + if d.get(key):
123 + details[key] = d[key]
124 +
125 + vin = "" if d.get("hide_vin") else str(d.get("serialnumber") or "")
126 +
127 + vehicles.append(Vehicle(
128 + source=self.source_id,
129 + external_id=str(d["_id"]),
130 + url=self._detail_url(d),
131 + title=title,
132 + make=str(d.get("make") or ""),
133 + model=str(d.get("model") or ""),
134 + trim=str(d.get("trim") or ""),
135 + year=d.get("year") if isinstance(d.get("year"), int) else None,
136 + price=price,
137 + price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),
138 + mileage_km=km,
139 + mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),
140 + transmission=str(d.get("transtype") or ""),
141 + fuel=str(d.get("fuel") or ""),
142 + drivetrain=str(d.get("drivetrain") or ""),
143 + body_type=str(d.get("body_type") or ""),
144 + exterior_color=str(d.get("base_ext_color") or ""),
145 + interior_color=str(d.get("base_int_color") or ""),
146 + engine=str(d.get("engine") or ""),
147 + doors=d.get("doors") if isinstance(d.get("doors"), int) else None,
148 + seats=(d.get("passengers")
149 + if isinstance(d.get("passengers"), int) else None),
150 + vin=vin,
151 + stock_number=str(d.get("stocknumber") or ""),
152 + dealer_name=self.dealer_name,
153 + city=self.city,
154 + region=self.region,
155 + features=features[:80],
156 + details=details,
157 + images=photos[:20],
158 + carfax_url=str(d.get("carfax_url_fr")
159 + or d.get("carfax_url_en") or ""),
160 + ))
161 + return vehicles
162 +
163 +
164 +# ---------------------------------------------------------------------------
165 +# Concessionnaires (1 sous-classe = 1 source)
166 +# ---------------------------------------------------------------------------
167 +
168 +class AutoGlobalMTL(ADWSConnector):
169 + source_id = "autoglobalmtl"
170 + base_url = "https://www.autoglobalmtl.com"
171 + dealer_name = "Auto Global MTL"
172 + city = "Montréal"
173 + detail_path = "vehicle"
174 +
175 +
176 +class GroupeAutomobile(ADWSConnector):
177 + source_id = "groupeautomobile"
178 + base_url = "https://www.groupeautomobile.com"
179 + dealer_name = "Le Groupe Automobile Laval"
180 + city = "Laval"
181 + detail_path = "vehicule"
182 +
183 +
184 +class SummumAuto(ADWSConnector):
185 + source_id = "summumauto"
186 + base_url = "https://www.summumauto.com"
187 + dealer_name = "Summum Auto"
188 + city = "Laval"
189 + detail_path = "vehicle"
190 +
191 +
192 +class Comme1Neuf(ADWSConnector):
193 + # occasionvilledequebec.com redirige (301) vers comme1neuf.com — même commerce
194 + source_id = "occasionvilledequebec"
195 + base_url = "https://www.comme1neuf.com"
196 + dealer_name = "Comme1Neuf"
197 + city = "Château-Richer"
198 + region = "Capitale-Nationale"
199 + detail_path = "vehicule"
added autoka/connectors/yannicklaberge.py +160 −0
@@ -0,0 +1,160 @@
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/yannicklaberge.py : Automobiles Yannick Laberge (Beauport) —
5 +# WordPress sur la plateforme AMVOQ (thème « theme_central », inventaire
6 +# autousagee.ca, images image.autousagee.ca). API REST verrouillée (401).
7 +#
8 +# Stratégie : le sitemap Yoast `/vehicules-sitemap.xml` recense les annonces
9 +# (/vehicule-usage/<slug>/<id>/). Chaque page détail expose un JSON-LD
10 +# schema.org @type Vehicle : prix, VIN, km, transmission, carburant,
11 +# couleur intérieure, description. Galerie complétée par les URLs
12 +# image.autousagee.ca de la page. Pages détail en cache BD (clé hebdo).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import datetime
17 +import json
18 +import os
19 +import re
20 +
21 +from ..schema import Vehicle
22 +from .base import BaseConnector
23 +
24 +_VEH_URL_RE = re.compile(r"/vehicule-usage/[^/]+/(\d+)/?$")
25 +
26 +_LD_RE = re.compile(
27 + r'<script type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.S)
28 +
29 +_IMG_RE = re.compile(r'https://image\.autousagee\.ca/[^"\'\\\s)]+\.jpe?g')
30 +
31 +
32 +class YannickLaberge(BaseConnector):
33 + source_id = "automobilesyannicklaberge"
34 + base_url = "https://www.automobilesyannicklaberge.com"
35 + dealer_name = "Automobiles Yannick Laberge"
36 + city = "Beauport"
37 + request_delay: float = 1.0
38 + max_details = 200
39 +
40 + # -- sitemap ----------------------------------------------------------------
41 + def _vehicle_urls(self) -> list[str]:
42 + xml = self.get(f"{self.base_url}/vehicules-sitemap.xml").text
43 + return [loc.strip() for loc in re.findall(r"<loc>([^<]+)</loc>", xml)
44 + if _VEH_URL_RE.search(loc.strip())]
45 +
46 + # -- page détail -> JSON-LD Vehicle + galerie ---------------------------------
47 + def _fetch_detail(self, url: str) -> dict:
48 + html = self.get(url).text
49 + for block in _LD_RE.findall(html):
50 + try:
51 + data = json.loads(block.strip())
52 + except ValueError:
53 + continue
54 + nodes = data if isinstance(data, list) else [data]
55 + for node in nodes:
56 + types = node.get("@type")
57 + types = types if isinstance(types, list) else [types]
58 + if "Vehicle" in types or "Car" in types:
59 + seen: set[str] = set()
60 + node["_gallery"] = [
61 + u for u in _IMG_RE.findall(html)
62 + if not (u in seen or seen.add(u))][:20]
63 + return node
64 + return {}
65 +
66 + # -- contrat -----------------------------------------------------------------
67 + def fetch(self) -> list[Vehicle]:
68 + urls = self._vehicle_urls()
69 + week = datetime.date.today().isocalendar()
70 + cache_key = f"v1:{week.year}w{week.week}"
71 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
72 +
73 + vehicles: list[Vehicle] = []
74 + real_fetches = 0
75 + for url in urls:
76 + ext_id = _VEH_URL_RE.search(url).group(1)
77 +
78 + def _fetch(u=url):
79 + return self._fetch_detail(u)
80 +
81 + if real_fetches >= cap: # plafond : cache seulement
82 + from .. import db
83 + if self._detail_con is None:
84 + self._detail_con = db.connect()
85 + data = db.get_cached_detail(self._detail_con, self.source_id,
86 + ext_id, cache_key)
87 + if data is None:
88 + continue
89 + else:
90 + before = self._last_request
91 + data = self.detail(ext_id, cache_key, _fetch)
92 + if self._last_request != before:
93 + real_fetches += 1
94 + if not data:
95 + continue
96 + veh = self._to_vehicle(ext_id, url, data)
97 + if veh is not None:
98 + vehicles.append(veh)
99 + return vehicles
100 +
101 + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:
102 + title = " ".join(str(d.get("name") or "").split())
103 + if not title:
104 + return None
105 +
106 + offers = d.get("offers") or {}
107 + price = None
108 + try:
109 + p = float(offers.get("price"))
110 + price = p if p > 0 else None
111 + except (TypeError, ValueError):
112 + pass
113 +
114 + odo = d.get("mileageFromOdometer") or {}
115 + km = odo.get("value") if isinstance(odo, dict) else odo
116 + try:
117 + km = float(km)
118 + km = km if km > 0 else None
119 + except (TypeError, ValueError):
120 + km = None
121 +
122 + brand = d.get("brand") or {}
123 + make = brand.get("name") if isinstance(brand, dict) else str(brand)
124 +
125 + year = None
126 + try:
127 + year = int(str(d.get("productionDate"))[:4])
128 + except (TypeError, ValueError):
129 + pass
130 +
131 + try:
132 + doors = int(str(d.get("numberOfDoors")))
133 + except (TypeError, ValueError):
134 + doors = None
135 +
136 + images = d.get("_gallery") or []
137 + if not images and isinstance(d.get("image"), str):
138 + images = [d["image"]]
139 +
140 + return Vehicle(
141 + source=self.source_id,
142 + external_id=ext_id,
143 + url=url,
144 + title=title,
145 + make=str(make or ""),
146 + year=year,
147 + price=price,
148 + price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),
149 + mileage_km=km,
150 + mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),
151 + transmission=str(d.get("vehicleTransmission") or ""),
152 + fuel=str(d.get("fuelType") or ""),
153 + interior_color=str(d.get("vehicleInteriorColor") or ""),
154 + doors=doors,
155 + vin=str(d.get("vehicleIdentificationNumber") or ""),
156 + dealer_name=self.dealer_name,
157 + city=self.city,
158 + description=" ".join(str(d.get("description") or "").split())[:4000],
159 + images=images,
160 + )
added autoka/db.py +310 −0
@@ -0,0 +1,310 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# db.py : persistance SQLite — upsert avec détection de changements,
5 +# cycle de vie avec délai de grâce (2 syncs), détection de dérive,
6 +# historique de prix (baisses de prix = signal clé en auto usagée),
7 +# cache des pages détail.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +import sqlite3
13 +import statistics
14 +import time
15 +from pathlib import Path
16 +
17 +from .schema import Vehicle
18 +
19 +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "autoka.db"
20 +
21 +# Nombre d'exécutions consécutives où une annonce doit être absente de la
22 +# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).
23 +MISS_GRACE = 2
24 +
25 +# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique
26 +# (médiane >= DRIFT_MIN_BASE annonces), on alerte et on suspend les retraits.
27 +DRIFT_RATIO = 0.25
28 +DRIFT_MIN_BASE = 8
29 +DRIFT_HISTORY = 5
30 +
31 +_SCHEMA = """
32 +CREATE TABLE IF NOT EXISTS vehicles (
33 + uid TEXT PRIMARY KEY,
34 + source TEXT NOT NULL,
35 + external_id TEXT NOT NULL,
36 + url TEXT,
37 + title TEXT,
38 + make TEXT,
39 + model TEXT,
40 + trim TEXT,
41 + year INTEGER,
42 + price REAL,
43 + price_label TEXT,
44 + mileage_km REAL,
45 + mileage_label TEXT,
46 + transmission TEXT,
47 + fuel TEXT,
48 + drivetrain TEXT,
49 + body_type TEXT,
50 + exterior_color TEXT,
51 + interior_color TEXT,
52 + engine TEXT,
53 + doors INTEGER,
54 + seats INTEGER,
55 + vin TEXT,
56 + stock_number TEXT,
57 + dealer_name TEXT,
58 + city TEXT,
59 + region TEXT,
60 + description TEXT,
61 + features TEXT, -- JSON (liste de textes source)
62 + details TEXT, -- JSON (champs structurés)
63 + images TEXT, -- JSON
64 + carfax_url TEXT,
65 + content_hash TEXT,
66 + first_seen REAL,
67 + last_seen REAL,
68 + updated_at REAL,
69 + miss_count INTEGER DEFAULT 0,
70 + active INTEGER DEFAULT 1
71 +);
72 +CREATE INDEX IF NOT EXISTS idx_vehicles_source ON vehicles(source);
73 +CREATE INDEX IF NOT EXISTS idx_vehicles_make ON vehicles(make);
74 +CREATE INDEX IF NOT EXISTS idx_vehicles_region ON vehicles(region);
75 +CREATE INDEX IF NOT EXISTS idx_vehicles_active ON vehicles(active);
76 +CREATE INDEX IF NOT EXISTS idx_vehicles_price ON vehicles(price);
77 +
78 +CREATE TABLE IF NOT EXISTS sync_log (
79 + id INTEGER PRIMARY KEY AUTOINCREMENT,
80 + source TEXT,
81 + ts REAL,
82 + found INTEGER,
83 + added INTEGER,
84 + updated INTEGER,
85 + removed INTEGER,
86 + ok INTEGER,
87 + message TEXT,
88 + stats TEXT -- JSON : taux de champs null, missed, alerte…
89 +);
90 +
91 +CREATE TABLE IF NOT EXISTS detail_cache (
92 + source TEXT NOT NULL,
93 + external_id TEXT NOT NULL,
94 + key TEXT, -- hash du contenu « liste » de l'annonce
95 + payload TEXT, -- JSON opaque propre au connecteur
96 + fetched_at REAL,
97 + PRIMARY KEY (source, external_id)
98 +);
99 +
100 +CREATE TABLE IF NOT EXISTS price_log (
101 + uid TEXT NOT NULL,
102 + ts REAL NOT NULL,
103 + price REAL -- prix observé (NULL = retiré de l'affichage)
104 +);
105 +CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
106 +"""
107 +
108 +
109 +def connect() -> sqlite3.Connection:
110 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
111 + con = sqlite3.connect(DB_PATH, timeout=30)
112 + con.row_factory = sqlite3.Row
113 + # WAL : plusieurs processus de sync peuvent écrire sans se bloquer
114 + con.execute("PRAGMA journal_mode=WAL")
115 + con.execute("PRAGMA busy_timeout=15000")
116 + con.executescript(_SCHEMA)
117 + con.commit()
118 + return con
119 +
120 +
121 +# ---------------------------------------------------------------------------
122 +# Synchronisation d'une source
123 +# ---------------------------------------------------------------------------
124 +
125 +def _drift_alert(con: sqlite3.Connection, source: str, found: int,
126 + null_price_rate: float) -> str | None:
127 + """Détecte une dérive du connecteur (chute du volume ou des prix extraits)."""
128 + hist = con.execute(
129 + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1"
130 + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()
131 + if len(hist) < 3:
132 + return None
133 + med_found = statistics.median(r["found"] for r in hist)
134 + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:
135 + return (f"dérive: {found} annonce(s) trouvée(s) contre une médiane de "
136 + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")
137 + if found >= DRIFT_MIN_BASE and null_price_rate >= 0.8:
138 + rates = []
139 + for r in hist:
140 + try:
141 + rates.append(json.loads(r["stats"] or "{}")["null_price_rate"])
142 + except (KeyError, ValueError, TypeError):
143 + continue
144 + if rates and statistics.median(rates) <= 0.3:
145 + return (f"dérive: {null_price_rate:.0%} des annonces sans prix "
146 + f"(habituellement {statistics.median(rates):.0%}) — "
147 + "le format de la source a probablement changé")
148 + return None
149 +
150 +
151 +def sync_source(con: sqlite3.Connection, source: str,
152 + vehicles: list[Vehicle]) -> dict:
153 + """Synchronise les annonces d'une source.
154 +
155 + - nouvelle annonce -> insertion
156 + - annonce modifiée -> mise à jour (comparaison de content_hash)
157 + - annonce disparue -> miss_count += 1, puis active=0 après MISS_GRACE
158 + exécutions consécutives (délai de grâce) — véhicule vendu ou retiré
159 + - dérive détectée -> alerte consignée, retraits suspendus
160 + """
161 + now = time.time()
162 + added = updated = 0
163 + seen_uids = set()
164 +
165 + n = len(vehicles)
166 + null_price = sum(1 for v in vehicles if v.price is None)
167 + null_km = sum(1 for v in vehicles if v.mileage_km is None)
168 + null_price_rate = round(null_price / n, 3) if n else 0.0
169 +
170 + alert = _drift_alert(con, source, n, null_price_rate)
171 +
172 + for veh in vehicles:
173 + seen_uids.add(veh.uid)
174 + h = veh.content_hash()
175 + row = con.execute("SELECT content_hash, price FROM vehicles WHERE uid=?",
176 + (veh.uid,)).fetchone()
177 + params = dict(
178 + uid=veh.uid, source=veh.source, external_id=veh.external_id,
179 + url=veh.url, title=veh.title, make=veh.make, model=veh.model,
180 + trim=veh.trim, year=veh.year, price=veh.price,
181 + price_label=veh.price_label, mileage_km=veh.mileage_km,
182 + mileage_label=veh.mileage_label, transmission=veh.transmission,
183 + fuel=veh.fuel, drivetrain=veh.drivetrain, body_type=veh.body_type,
184 + exterior_color=veh.exterior_color, interior_color=veh.interior_color,
185 + engine=veh.engine, doors=veh.doors, seats=veh.seats, vin=veh.vin,
186 + stock_number=veh.stock_number, dealer_name=veh.dealer_name,
187 + city=veh.city, region=veh.region, description=veh.description,
188 + features=json.dumps(veh.features, ensure_ascii=False),
189 + details=json.dumps(veh.details, ensure_ascii=False),
190 + images=json.dumps(veh.images, ensure_ascii=False),
191 + carfax_url=veh.carfax_url, content_hash=h, now=now,
192 + )
193 + if row is None:
194 + con.execute(
195 + """INSERT INTO vehicles (uid, source, external_id, url, title,
196 + make, model, trim, year, price, price_label, mileage_km,
197 + mileage_label, transmission, fuel, drivetrain, body_type,
198 + exterior_color, interior_color, engine, doors, seats, vin,
199 + stock_number, dealer_name, city, region, description,
200 + features, details, images, carfax_url, content_hash,
201 + first_seen, last_seen, updated_at, miss_count, active)
202 + VALUES (:uid,:source,:external_id,:url,:title,:make,:model,
203 + :trim,:year,:price,:price_label,:mileage_km,:mileage_label,
204 + :transmission,:fuel,:drivetrain,:body_type,:exterior_color,
205 + :interior_color,:engine,:doors,:seats,:vin,:stock_number,
206 + :dealer_name,:city,:region,:description,:features,:details,
207 + :images,:carfax_url,:content_hash,:now,:now,:now,0,1)""",
208 + params)
209 + if veh.price is not None: # prix initial = départ de l'historique
210 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
211 + (veh.uid, now, veh.price))
212 + added += 1
213 + elif row["content_hash"] != h:
214 + con.execute(
215 + """UPDATE vehicles SET url=:url, title=:title, make=:make,
216 + model=:model, trim=:trim, year=:year, price=:price,
217 + price_label=:price_label, mileage_km=:mileage_km,
218 + mileage_label=:mileage_label, transmission=:transmission,
219 + fuel=:fuel, drivetrain=:drivetrain, body_type=:body_type,
220 + exterior_color=:exterior_color, interior_color=:interior_color,
221 + engine=:engine, doors=:doors, seats=:seats, vin=:vin,
222 + stock_number=:stock_number, dealer_name=:dealer_name,
223 + city=:city, region=:region, description=:description,
224 + features=:features, details=:details, images=:images,
225 + carfax_url=:carfax_url, content_hash=:content_hash,
226 + last_seen=:now, updated_at=:now, miss_count=0, active=1
227 + WHERE uid=:uid""", params)
228 + if veh.price != row["price"]: # changement de prix -> historique
229 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
230 + (veh.uid, now, veh.price))
231 + updated += 1
232 + else:
233 + con.execute(
234 + "UPDATE vehicles SET last_seen=?, miss_count=0, active=1 WHERE uid=?",
235 + (now, veh.uid))
236 +
237 + # Annonces de cette source qui n'apparaissent plus : délai de grâce,
238 + # puis désactivation (véhicule vendu). Suspendu si dérive détectée.
239 + removed = missed = 0
240 + if not alert:
241 + for r in con.execute(
242 + "SELECT uid, miss_count FROM vehicles WHERE source=? AND active=1",
243 + (source,)).fetchall():
244 + if r["uid"] in seen_uids:
245 + continue
246 + missed += 1
247 + if r["miss_count"] + 1 >= MISS_GRACE:
248 + con.execute(
249 + "UPDATE vehicles SET active=0, miss_count=?, updated_at=?"
250 + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"]))
251 + removed += 1
252 + else:
253 + con.execute("UPDATE vehicles SET miss_count=miss_count+1 WHERE uid=?",
254 + (r["uid"],))
255 +
256 + stats = {
257 + "null_price_rate": null_price_rate,
258 + "null_km_rate": round(null_km / n, 3) if n else 0.0,
259 + "missed": missed,
260 + }
261 + if alert:
262 + stats["alert"] = alert
263 + con.execute(
264 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"
265 + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",
266 + (source, now, n, added, updated, removed, alert or "ok",
267 + json.dumps(stats, ensure_ascii=False)))
268 + con.commit()
269 + out = {"source": source, "found": n, "added": added,
270 + "updated": updated, "removed": removed}
271 + if alert:
272 + out["alert"] = alert
273 + return out
274 +
275 +
276 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
277 + con.execute(
278 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
279 + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
280 + con.commit()
281 +
282 +
283 +# ---------------------------------------------------------------------------
284 +# Cache des pages détail (« détail si nouveau/modifié »)
285 +# ---------------------------------------------------------------------------
286 +
287 +def get_cached_detail(con: sqlite3.Connection, source: str,
288 + external_id: str, key: str) -> dict | None:
289 + """Payload détail mis en cache si la clé (hash liste) n'a pas changé."""
290 + row = con.execute(
291 + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",
292 + (source, external_id)).fetchone()
293 + if row and row["key"] == key and row["payload"]:
294 + try:
295 + return json.loads(row["payload"])
296 + except ValueError:
297 + return None
298 + return None
299 +
300 +
301 +def put_cached_detail(con: sqlite3.Connection, source: str,
302 + external_id: str, key: str, payload: dict) -> None:
303 + con.execute(
304 + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"
305 + " VALUES (?,?,?,?,?)"
306 + " ON CONFLICT(source, external_id) DO UPDATE SET"
307 + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",
308 + (source, external_id, key, json.dumps(payload, ensure_ascii=False),
309 + time.time()))
310 + con.commit()
added autoka/ingest.py +62 −0
@@ -0,0 +1,62 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# ingest.py : pipeline d'ingestion — exécute les connecteurs et synchronise
5 +# la base (ajouts / mises à jour / retraits) = inventaire à jour
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import sys
10 +import time
11 +import traceback
12 +
13 +from . import db
14 +from .connectors import CONNECTORS
15 +
16 +
17 +def run(sources: list[str] | None = None) -> list[dict]:
18 + """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
19 + con = db.connect()
20 + results = []
21 + targets = sources or list(CONNECTORS.keys())
22 + for sid in targets:
23 + cls = CONNECTORS.get(sid)
24 + if cls is None:
25 + print(f"[auto-ka] connecteur inconnu : {sid}", file=sys.stderr)
26 + continue
27 + t0 = time.time()
28 + print(f"[auto-ka] sync {sid} ...")
29 + try:
30 + vehicles = cls().fetch()
31 + finalized, dropped = [], 0
32 + for veh in vehicles:
33 + try:
34 + finalized.append(veh.finalize())
35 + except Exception: # une annonce malformée ne bloque pas la source
36 + dropped += 1
37 + stats = db.sync_source(con, sid, finalized)
38 + stats["seconds"] = round(time.time() - t0, 1)
39 + if dropped:
40 + stats["dropped"] = dropped
41 + if stats.get("alert"):
42 + print(f"[auto-ka] ⚠ ALERTE {sid} : {stats['alert']}")
43 + print(f"[auto-ka] {stats}")
44 + results.append(stats)
45 + except Exception as exc: # robustesse : une source ne bloque pas les autres
46 + db.log_failure(con, sid, f"{exc}")
47 + traceback.print_exc()
48 + results.append({"source": sid, "error": str(exc)})
49 + con.close()
50 + return results
51 +
52 +
53 +def watch(interval_seconds: int = 7200) -> None:
54 + """Boucle de rafraîchissement périodique (pseudo-webhook par sondage)."""
55 + while True:
56 + run()
57 + print(f"[auto-ka] prochaine synchronisation dans {interval_seconds}s")
58 + time.sleep(interval_seconds)
59 +
60 +
61 +if __name__ == "__main__":
62 + run(sys.argv[1:] or None)
added autoka/normalize.py +363 −0
@@ -0,0 +1,363 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# normalize.py : normalisation des champs véhicule — prix, kilométrage, année,
5 +# marque/modèle, transmission, carburant, motricité, carrosserie,
6 +# ville -> région administrative.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import re
11 +import unicodedata
12 +
13 +__all__ = [
14 + "strip_accents", "parse_price", "price_is_from", "parse_mileage",
15 + "parse_year", "normalize_make", "split_title", "normalize_transmission",
16 + "normalize_fuel", "normalize_drivetrain", "normalize_body", "infer_region",
17 + "MAKES",
18 +]
19 +
20 +
21 +def strip_accents(text: str) -> str:
22 + return "".join(c for c in unicodedata.normalize("NFD", text)
23 + if unicodedata.category(c) != "Mn")
24 +
25 +
26 +# ---------------------------------------------------------------------------
27 +# Prix et kilométrage
28 +# ---------------------------------------------------------------------------
29 +
30 +_PRICE_RE = re.compile(r"(\d{1,3}(?:[ ,.]\d{3})+|\d{3,6})(?:[,.](\d{2}))?\s*\$")
31 +_PRICE_BARE_RE = re.compile(r"\$\s*(\d{1,3}(?:[ ,.]\d{3})+|\d{3,6})")
32 +
33 +
34 +def parse_price(label: str | None) -> float | None:
35 + """« 21 495 $ », « $21,495 », « 21495$ » -> 21495.0 (None si absent/0)."""
36 + if not label:
37 + return None
38 + m = _PRICE_RE.search(label) or _PRICE_BARE_RE.search(label)
39 + if not m:
40 + return None
41 + raw = re.sub(r"[ ,.]", "", m.group(1))
42 + try:
43 + value = float(raw)
44 + except ValueError:
45 + return None
46 + # bornes de vraisemblance pour une auto usagée
47 + if value < 300 or value > 500_000:
48 + return None
49 + return value
50 +
51 +
52 +def price_is_from(label: str) -> bool:
53 + return bool(re.search(r"à partir|a partir|starting|from", label, re.I))
54 +
55 +
56 +_KM_RE = re.compile(r"(\d{1,3}(?:[ ,.]\d{3})+|\d+)\s*km", re.I)
57 +
58 +
59 +def parse_mileage(label: str | None) -> float | None:
60 + """« 45 678 km », « 45,678 KM » -> 45678.0."""
61 + if not label:
62 + return None
63 + m = _KM_RE.search(label)
64 + if not m:
65 + return None
66 + raw = re.sub(r"[ ,.]", "", m.group(1))
67 + try:
68 + value = float(raw)
69 + except ValueError:
70 + return None
71 + if value < 0 or value > 900_000:
72 + return None
73 + return value
74 +
75 +
76 +_YEAR_RE = re.compile(r"\b(19[89]\d|20[0-3]\d)\b")
77 +
78 +
79 +def parse_year(text: str | None) -> int | None:
80 + if not text:
81 + return None
82 + m = _YEAR_RE.search(text)
83 + return int(m.group(1)) if m else None
84 +
85 +
86 +# ---------------------------------------------------------------------------
87 +# Marques (canoniques) — clé = version sans accents/majuscules/espaces
88 +# ---------------------------------------------------------------------------
89 +
90 +MAKES = [
91 + "Acura", "Alfa Romeo", "Aston Martin", "Audi", "Bentley", "BMW", "Buick",
92 + "Cadillac", "Chevrolet", "Chrysler", "Dodge", "Ferrari", "Fiat", "Fisker",
93 + "Ford", "Genesis", "GMC", "Honda", "Hummer", "Hyundai", "Infiniti",
94 + "Jaguar", "Jeep", "Kia", "Lamborghini", "Land Rover", "Lexus", "Lincoln",
95 + "Lotus", "Maserati", "Mazda", "McLaren", "Mercedes-Benz", "Mercury",
96 + "MINI", "Mitsubishi", "Nissan", "Oldsmobile", "Plymouth", "Polestar",
97 + "Pontiac", "Porsche", "RAM", "Rivian", "Rolls-Royce", "Saab", "Saturn",
98 + "Scion", "Smart", "Subaru", "Suzuki", "Tesla", "Toyota", "VinFast",
99 + "Volkswagen", "Volvo",
100 +]
101 +
102 +_MAKE_ALIASES = {
103 + "mercedes": "Mercedes-Benz", "mercedesbenz": "Mercedes-Benz",
104 + "mercedesamg": "Mercedes-Benz", "vw": "Volkswagen", "chevy": "Chevrolet",
105 + "landrover": "Land Rover", "rangerover": "Land Rover", "mini": "MINI",
106 + "ram": "RAM", "gmc": "GMC", "bmw": "BMW", "alfa": "Alfa Romeo",
107 + "alfaromeo": "Alfa Romeo", "rollsroyce": "Rolls-Royce", "vinfast": "VinFast",
108 +}
109 +
110 +_MAKE_LOOKUP = {strip_accents(m).lower().replace(" ", "").replace("-", ""): m
111 + for m in MAKES}
112 +_MAKE_LOOKUP.update(_MAKE_ALIASES)
113 +
114 +
115 +def normalize_make(raw: str | None) -> str:
116 + if not raw:
117 + return ""
118 + key = strip_accents(raw.strip()).lower().replace(" ", "").replace("-", "")
119 + return _MAKE_LOOKUP.get(key, raw.strip().title() if raw.strip() else "")
120 +
121 +
122 +# marques triées par longueur décroissante pour matcher « Land Rover » avant « Rover »
123 +_MAKES_BY_LEN = sorted(MAKES + ["Mercedes"], key=len, reverse=True)
124 +
125 +
126 +def split_title(title: str) -> tuple[int | None, str, str]:
127 + """« Toyota RAV4 2019 XLE AWD » ou « 2019 Toyota RAV4 » ->
128 + (année, marque, modèle+version). Champs vides si non détectés."""
129 + if not title:
130 + return None, "", ""
131 + year = parse_year(title)
132 + text = _YEAR_RE.sub(" ", title)
133 + flat = strip_accents(text).lower()
134 + for make in _MAKES_BY_LEN:
135 + mk = strip_accents(make).lower()
136 + idx = flat.find(mk)
137 + if idx >= 0:
138 + before = flat[idx - 1] if idx > 0 else " "
139 + after_i = idx + len(mk)
140 + after = flat[after_i] if after_i < len(flat) else " "
141 + if not before.isalnum() and not after.isalnum():
142 + model = text[after_i:].strip(" -–|,·")
143 + model = re.sub(r"\s{2,}", " ", model).strip()
144 + return year, normalize_make(make), model
145 + return year, "", text.strip()
146 +
147 +
148 +# ---------------------------------------------------------------------------
149 +# Transmission, carburant, motricité, carrosserie
150 +# ---------------------------------------------------------------------------
151 +
152 +def normalize_transmission(raw: str | None) -> str:
153 + if not raw:
154 + return ""
155 + t = strip_accents(raw).lower()
156 + if "cvt" in t or "variation continue" in t:
157 + return "Automatique" # CVT présentée comme automatique
158 + if "man" in t:
159 + return "Manuelle"
160 + if "auto" in t or "tiptronic" in t or "dsg" in t or "pdk" in t:
161 + return "Automatique" # « Automatique 6 vitesses » = automatique
162 + if re.search(r"\b[56]\s?(vit|spd|speed)", t):
163 + return "Manuelle" # « 6 vitesses » seul = boîte manuelle
164 + return ""
165 +
166 +
167 +def normalize_fuel(raw: str | None) -> str:
168 + if not raw:
169 + return ""
170 + t = strip_accents(raw).lower()
171 + if "rechargeable" in t or "plug" in t or "phev" in t:
172 + return "Hybride rechargeable"
173 + if "hybrid" in t or "hybride" in t:
174 + return "Hybride"
175 + if "electr" in t or "ev" == t.strip() or "electric" in t:
176 + return "Électrique"
177 + if "diesel" in t or "tdi" in t:
178 + return "Diesel"
179 + if "essence" in t or "gas" in t or "petrol" in t or "sans plomb" in t:
180 + return "Essence"
181 + return ""
182 +
183 +
184 +def normalize_drivetrain(raw: str | None) -> str:
185 + if not raw:
186 + return ""
187 + t = strip_accents(raw).lower()
188 + if "awd" in t or "4wd" in t or "4x4" in t or "integrale" in t or "all" in t \
189 + or "quattro" in t or "xdrive" in t or "4matic" in t or "4motion" in t:
190 + return "Intégrale / 4x4"
191 + if "rwd" in t or "propulsion" in t or "arriere" in t or "rear" in t:
192 + return "Propulsion"
193 + if "fwd" in t or "traction" in t or "avant" in t or "front" in t or "2wd" in t:
194 + return "Traction"
195 + return ""
196 +
197 +
198 +# jetons STRICTS repérables dans un titre/une version (pas de faux positifs)
199 +_DRIVE_TOKEN_RE = re.compile(
200 + r"\b(awd|4wd|4x4|4rm|quattro|xdrive|4matic|4motion|sh-awd|e-four|"
201 + r"traction integrale|integrale|awc)\b", re.I)
202 +_RWD_TOKEN_RE = re.compile(r"\b(rwd|propulsion|sdrive)\b", re.I)
203 +_FWD_TOKEN_RE = re.compile(r"\b(fwd|2rm|traction avant)\b", re.I)
204 +
205 +
206 +def infer_drivetrain_from_text(text: str | None) -> str:
207 + """Motricité déduite des jetons explicites du titre/version (repli)."""
208 + if not text:
209 + return ""
210 + flat = strip_accents(text)
211 + if _DRIVE_TOKEN_RE.search(flat):
212 + return "Intégrale / 4x4"
213 + if _RWD_TOKEN_RE.search(flat):
214 + return "Propulsion"
215 + if _FWD_TOKEN_RE.search(flat):
216 + return "Traction"
217 + return ""
218 +
219 +
220 +_FUEL_HR_RE = re.compile(r"\b(phev|plug-?in|hybride?\s+rechargeable)\b", re.I)
221 +_FUEL_HYB_RE = re.compile(r"\b(hybride?|hybrid)\b", re.I)
222 +_FUEL_EV_RE = re.compile(
223 + r"\b(electrique|electric|ev|bev|e-tron|ioniq \d|kona electric|"
224 + r"bolt|leaf|model [3sxy]|id\.?4)\b", re.I)
225 +_FUEL_DIESEL_RE = re.compile(r"\b(diesel|tdi|duramax|cummins|ecodiesel)\b", re.I)
226 +
227 +
228 +def infer_fuel_from_text(text: str | None) -> str:
229 + """Carburant déduit des jetons explicites du titre/version (repli).
230 +
231 + Volontairement conservateur : ne regarde jamais la description
232 + (« sièges électriques » n'est pas un véhicule électrique).
233 + """
234 + if not text:
235 + return ""
236 + flat = strip_accents(text)
237 + if _FUEL_HR_RE.search(flat):
238 + return "Hybride rechargeable"
239 + if _FUEL_HYB_RE.search(flat):
240 + return "Hybride"
241 + if _FUEL_EV_RE.search(flat):
242 + return "Électrique"
243 + if _FUEL_DIESEL_RE.search(flat):
244 + return "Diesel"
245 + return ""
246 +
247 +
248 +_BODY_MAP = [
249 + (("vus", "suv", "utilitaire", "crossover", "multisegment"), "VUS"),
250 + (("camionnette", "camion", "pickup", "pick-up", "truck", "cabine"), "Camionnette"),
251 + (("fourgonnette", "minivan", "mini-fourgonnette", "van", "fourgon"), "Fourgonnette"),
252 + (("cabriolet", "convertible", "decapotable"), "Cabriolet"),
253 + (("coupe",), "Coupé"),
254 + (("hayon", "hatchback", "a hayon", "5 portes"), "Hayon"),
255 + (("familiale", "wagon", "break"), "Familiale"),
256 + (("berline", "sedan", "4 portes"), "Berline"),
257 +]
258 +
259 +
260 +def normalize_body(raw: str | None) -> str:
261 + if not raw:
262 + return ""
263 + t = strip_accents(raw).lower()
264 + for keys, canon in _BODY_MAP:
265 + if any(k in t for k in keys):
266 + return canon
267 + return ""
268 +
269 +
270 +# ---------------------------------------------------------------------------
271 +# Ville -> région administrative du Québec
272 +# ---------------------------------------------------------------------------
273 +
274 +_REGIONS: dict[str, tuple[str, ...]] = {
275 + "Capitale-Nationale": (
276 + "quebec", "ste-foy", "sainte-foy", "val-belair", "beauport", "charlesbourg",
277 + "loretteville", "l'ancienne-lorette", "ancienne-lorette", "donnacona",
278 + "cap-sante", "pont-rouge", "saint-augustin", "st-augustin", "baie-saint-paul",
279 + "la malbaie", "portneuf", "saint-raymond", "boischatel", "vanier",
280 + ),
281 + "Chaudière-Appalaches": (
282 + "levis", "saint-nicolas", "st-nicolas", "charny", "saint-romuald",
283 + "st-romuald", "saint-georges", "st-georges", "thetford", "montmagny",
284 + "sainte-marie", "ste-marie", "beauce", "saint-joseph", "lac-etchemin",
285 + "bernieres", "breakeyville", "laurier-station",
286 + ),
287 + "Montréal": (
288 + "montreal", "saint-leonard", "st-leonard", "anjou", "lasalle", "verdun",
289 + "dorval", "pointe-claire", "kirkland", "montreal-nord", "montreal-est",
290 + "ahuntsic", "villeray", "rosemont", "hochelaga", "lachine", "pierrefonds",
291 + ),
292 + "Laval": ("laval", "chomedey", "vimont", "duvernay", "sainte-rose", "ste-rose"),
293 + "Montérégie": (
294 + "longueuil", "brossard", "saint-hubert", "st-hubert", "boucherville",
295 + "chateauguay", "granby", "saint-hyacinthe", "st-hyacinthe", "sorel",
296 + "valleyfield", "salaberry", "vaudreuil", "saint-jean-sur-richelieu",
297 + "st-jean-sur-richelieu", "chambly", "beloeil", "la prairie", "candiac",
298 + "saint-constant", "st-constant", "varennes", "cowansville", "magog",
299 + "sainte-julie", "ste-julie", "mont-saint-hilaire", "acton vale",
300 + "carignan", "ile-perrot", "l'ile-perrot", "saint-basile", "st-basile",
301 + "marieville", "mont-saint-gregoire", "napierville", "saint-remi",
302 + ),
303 + "Laurentides": (
304 + "saint-jerome", "st-jerome", "blainville", "boisbriand", "mirabel",
305 + "saint-eustache", "st-eustache", "sainte-therese", "ste-therese",
306 + "lachute", "mont-tremblant", "sainte-agathe", "ste-agathe", "saint-sauveur",
307 + "st-sauveur", "rosemere", "deux-montagnes", "mont-laurier",
308 + ),
309 + "Lanaudière": (
310 + "joliette", "repentigny", "terrebonne", "mascouche", "l'assomption",
311 + "assomption", "rawdon", "berthierville", "lavaltrie", "saint-lin",
312 + "st-lin", "charlemagne",
313 + ),
314 + "Estrie": (
315 + "sherbrooke", "rock forest", "fleurimont", "coaticook", "windsor",
316 + "east angus", "lac-megantic", "asbestos", "val-des-sources", "bromont",
317 + ),
318 + "Mauricie": (
319 + "trois-rivieres", "shawinigan", "grand-mere", "louiseville", "nicolet",
320 + "la tuque", "cap-de-la-madeleine",
321 + ),
322 + "Centre-du-Québec": (
323 + "drummondville", "victoriaville", "plessisville", "becancour",
324 + "warwick", "princeville",
325 + ),
326 + "Saguenay–Lac-Saint-Jean": (
327 + "saguenay", "chicoutimi", "jonquiere", "alma", "roberval", "dolbeau",
328 + "saint-felicien", "st-felicien", "la baie",
329 + ),
330 + "Bas-Saint-Laurent": (
331 + "rimouski", "riviere-du-loup", "matane", "mont-joli", "amqui",
332 + "la pocatiere", "temiscouata", "trois-pistoles",
333 + ),
334 + "Gaspésie–Îles-de-la-Madeleine": (
335 + "gaspe", "caplan", "bonaventure", "carleton", "chandler", "new richmond",
336 + "sainte-anne-des-monts", "ste-anne-des-monts",
337 + ),
338 + "Côte-Nord": (
339 + "baie-comeau", "sept-iles", "port-cartier", "forestville", "havre-saint-pierre",
340 + ),
341 + "Abitibi-Témiscamingue": (
342 + "rouyn-noranda", "rouyn", "val-d'or", "val-dor", "amos", "la sarre",
343 + "ville-marie", "malartic", "senneterre",
344 + ),
345 + "Outaouais": (
346 + "gatineau", "hull", "aylmer", "buckingham", "maniwaki", "papineauville",
347 + ),
348 +}
349 +
350 +_CITY_TO_REGION = {city: region for region, cities in _REGIONS.items()
351 + for city in cities}
352 +
353 +
354 +def infer_region(city: str | None) -> str:
355 + if not city:
356 + return ""
357 + key = strip_accents(city.strip()).lower()
358 + if key in _CITY_TO_REGION:
359 + return _CITY_TO_REGION[key]
360 + for c, region in _CITY_TO_REGION.items():
361 + if c in key:
362 + return region
363 + return ""
added autoka/schema.py +141 −0
@@ -0,0 +1,141 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# schema.py : modèle de données standardisé (Vehicle) + normalisation centrale
5 +# -----------------------------------------------------------------------------
6 +"""Schéma standard d'une annonce de véhicule usagé.
7 +
8 +Chaque connecteur, peu importe le site source, produit des objets `Vehicle`
9 +conformes à ce schéma. `finalize()` applique la couche de normalisation
10 +commune (autoka/normalize.py) : prix, kilométrage, année/marque/modèle
11 +déduits du titre, transmission/carburant/carrosserie canoniques, région
12 +administrative — les connecteurs restent simples et remplissent le brut.
13 +"""
14 +from __future__ import annotations
15 +
16 +import hashlib
17 +import json
18 +from dataclasses import dataclass, field, asdict
19 +
20 +from .normalize import ( # ré-exportés pour les connecteurs
21 + infer_region,
22 + normalize_body,
23 + normalize_drivetrain,
24 + normalize_fuel,
25 + normalize_make,
26 + normalize_transmission,
27 + parse_mileage,
28 + parse_price,
29 + parse_year,
30 + price_is_from,
31 + split_title,
32 + strip_accents,
33 +)
34 +
35 +__all__ = [
36 + "Vehicle", "parse_price", "parse_mileage", "parse_year", "split_title",
37 + "normalize_make", "normalize_transmission", "normalize_fuel",
38 + "normalize_drivetrain", "normalize_body", "infer_region", "strip_accents",
39 +]
40 +
41 +
42 +@dataclass
43 +class Vehicle:
44 + """Annonce de véhicule usagé standardisée Auto-Ka."""
45 +
46 + source: str # id de la source (voir data/sources.json)
47 + external_id: str # identifiant chez la source (stock, VIN, slug)
48 + url: str # page de l'annonce chez le concessionnaire
49 + title: str = "" # ex. "Toyota RAV4 XLE 2019"
50 + make: str = "" # Toyota, Honda, ...
51 + model: str = "" # RAV4, Civic, ...
52 + trim: str = "" # XLE AWD, Sport, ...
53 + year: int | None = None
54 + price: float | None = None # $ CAD affiché
55 + price_label: str = "" # texte original (ex. "21 495 $ + tx")
56 + mileage_km: float | None = None
57 + mileage_label: str = "" # texte original (ex. "45 678 km")
58 + transmission: str = "" # Automatique | Manuelle
59 + fuel: str = "" # Essence | Diesel | Hybride | Hybride rechargeable | Électrique
60 + drivetrain: str = "" # Traction | Propulsion | Intégrale / 4x4
61 + body_type: str = "" # VUS | Berline | Camionnette | ...
62 + exterior_color: str = ""
63 + interior_color: str = ""
64 + engine: str = "" # ex. "2.5L 4 cyl."
65 + doors: int | None = None
66 + seats: int | None = None
67 + vin: str = ""
68 + stock_number: str = ""
69 + dealer_name: str = "" # nom d'affichage du commerce
70 + city: str = "" # ville du concessionnaire
71 + region: str = "" # région administrative (déduite de city)
72 + description: str = ""
73 + features: list[str] = field(default_factory=list) # équipements (texte source)
74 + details: dict = field(default_factory=dict) # champs structurés (JSON)
75 + images: list[str] = field(default_factory=list) # URLs absolues
76 + carfax_url: str = ""
77 +
78 + @property
79 + def uid(self) -> str:
80 + return f"{self.source}:{self.external_id}"
81 +
82 + def content_hash(self) -> str:
83 + """Hash du contenu pour la détection de changements (pseudo-webhook)."""
84 + payload = asdict(self)
85 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
86 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
87 +
88 + def finalize(self) -> "Vehicle":
89 + """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
90 +
91 + Idempotent ; ne remplace jamais une valeur explicite du connecteur.
92 + """
93 + self.title = " ".join((self.title or "").split())
94 +
95 + # année / marque / modèle déduits du titre si absents
96 + t_year, t_make, t_model = split_title(self.title)
97 + if self.year is None:
98 + self.year = t_year
99 + if not self.make:
100 + self.make = t_make
101 + if not self.model and t_model:
102 + # retirer la version du modèle déduit si elle duplique trim
103 + self.model = t_model.split(" ")[0]
104 + self.make = normalize_make(self.make)
105 + self.model = " ".join(self.model.split())
106 +
107 + if self.price is None:
108 + self.price = parse_price(self.price_label)
109 + if self.mileage_km is None:
110 + self.mileage_km = parse_mileage(self.mileage_label or self.description)
111 +
112 + self.transmission = normalize_transmission(self.transmission) or self.transmission
113 + self.fuel = normalize_fuel(self.fuel) or self.fuel
114 + self.drivetrain = normalize_drivetrain(self.drivetrain) or self.drivetrain
115 + self.body_type = normalize_body(self.body_type) or self.body_type
116 +
117 + # replis conservateurs depuis le titre/la version quand la source
118 + # n'expose pas le champ (jetons explicites seulement : AWD, PHEV…)
119 + badge = f"{self.title} {self.trim}"
120 + if not self.drivetrain:
121 + from .normalize import infer_drivetrain_from_text
122 + self.drivetrain = infer_drivetrain_from_text(badge)
123 + if not self.fuel:
124 + from .normalize import infer_fuel_from_text
125 + self.fuel = infer_fuel_from_text(badge)
126 +
127 + if not self.region:
128 + self.region = infer_region(self.city)
129 +
130 + if self.price_label and price_is_from(self.price_label):
131 + self.details["price_from"] = True
132 +
133 + # année plausible seulement
134 + if self.year is not None and not (1980 <= self.year <= 2027):
135 + self.year = None
136 +
137 + # dédoublonner les images en préservant l'ordre
138 + seen: set[str] = set()
139 + self.images = [u for u in self.images
140 + if u and not (u in seen or seen.add(u))]
141 + return self
added autoka/web.py +269 −0
@@ -0,0 +1,269 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import json
9 +import threading
10 +from pathlib import Path
11 +
12 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
13 +from fastapi.middleware.cors import CORSMiddleware
14 +from fastapi.responses import FileResponse
15 +from fastapi.staticfiles import StaticFiles
16 +
17 +from . import db, ingest
18 +
19 +ROOT = Path(__file__).resolve().parent.parent
20 +SOURCES_PATH = ROOT / "data" / "sources.json"
21 +FRONTEND_DIST = ROOT / "frontend" / "dist"
22 +
23 +app = FastAPI(title="Auto-Ka API", version="1.0",
24 + description="Agrégateur de voitures usagées — province de Québec")
25 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
26 + allow_methods=["*"], allow_headers=["*"])
27 +
28 +_sync_lock = threading.Lock()
29 +
30 +
31 +def _row_to_dict(row) -> dict:
32 + d = dict(row)
33 + d["features"] = json.loads(d.get("features") or "[]")
34 + d["images"] = json.loads(d.get("images") or "[]")
35 + d["details"] = json.loads(d.get("details") or "{}")
36 + return d
37 +
38 +
39 +def _apply_filters(sql: str, args: list, *, make=None, model=None, body_type=None,
40 + fuel=None, transmission=None, drivetrain=None, region=None,
41 + city=None, source=None, year_min=None, year_max=None,
42 + price_min=None, price_max=None, km_max=None, q=None) -> str:
43 + if make:
44 + sql += " AND make=?"; args.append(make)
45 + if model:
46 + sql += " AND model LIKE ?"; args.append(f"{model}%")
47 + if body_type:
48 + sql += " AND body_type=?"; args.append(body_type)
49 + if fuel:
50 + if fuel == "Hybride": # inclut l'hybride rechargeable
51 + sql += " AND fuel IN ('Hybride','Hybride rechargeable')"
52 + else:
53 + sql += " AND fuel=?"; args.append(fuel)
54 + if transmission:
55 + sql += " AND transmission=?"; args.append(transmission)
56 + if drivetrain:
57 + sql += " AND drivetrain=?"; args.append(drivetrain)
58 + if region:
59 + sql += " AND region=?"; args.append(region)
60 + if city:
61 + sql += " AND city LIKE ?"; args.append(f"%{city}%")
62 + if source:
63 + sql += " AND source=?"; args.append(source)
64 + if year_min is not None:
65 + sql += " AND year IS NOT NULL AND year>=?"; args.append(year_min)
66 + if year_max is not None:
67 + sql += " AND year IS NOT NULL AND year<=?"; args.append(year_max)
68 + if price_min is not None:
69 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
70 + if price_max is not None:
71 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
72 + if km_max is not None:
73 + sql += " AND mileage_km IS NOT NULL AND mileage_km<=?"; args.append(km_max)
74 + if q:
75 + sql += (" AND (title LIKE ? OR make LIKE ? OR model LIKE ?"
76 + " OR dealer_name LIKE ? OR city LIKE ?)")
77 + args += [f"%{q}%"] * 5
78 + return sql
79 +
80 +
81 +_SORTS = {
82 + "price_asc": "price IS NULL, price ASC",
83 + "price_desc": "price IS NULL, price DESC",
84 + "km_asc": "mileage_km IS NULL, mileage_km ASC",
85 + "year_desc": "year IS NULL, year DESC",
86 + "recent": "first_seen DESC",
87 +}
88 +
89 +
90 +@app.get("/api/vehicles")
91 +def list_vehicles(
92 + make: str | None = None,
93 + model: str | None = None,
94 + body_type: str | None = None,
95 + fuel: str | None = None,
96 + transmission: str | None = None,
97 + drivetrain: str | None = None,
98 + region: str | None = None,
99 + city: str | None = None,
100 + source: str | None = None,
101 + year_min: int | None = None,
102 + year_max: int | None = None,
103 + price_min: float | None = None,
104 + price_max: float | None = None,
105 + km_max: float | None = None,
106 + q: str | None = None,
107 + sort: str = "price_asc",
108 + active: int = 1,
109 + limit: int = Query(60, le=500),
110 + offset: int = 0,
111 +):
112 + con = db.connect()
113 + sql = "SELECT * FROM vehicles WHERE 1=1"
114 + args: list = []
115 + if active in (0, 1):
116 + sql += " AND active=?"; args.append(active)
117 + sql = _apply_filters(sql, args, make=make, model=model, body_type=body_type,
118 + fuel=fuel, transmission=transmission,
119 + drivetrain=drivetrain, region=region, city=city,
120 + source=source, year_min=year_min, year_max=year_max,
121 + price_min=price_min, price_max=price_max,
122 + km_max=km_max, q=q)
123 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
124 + sql += f" ORDER BY {_SORTS.get(sort, _SORTS['price_asc'])} LIMIT ? OFFSET ?"
125 + args += [limit, offset]
126 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
127 + con.close()
128 + return {"total": total, "count": len(rows), "vehicles": rows}
129 +
130 +
131 +@app.get("/api/vehicles/{uid}")
132 +def get_vehicle(uid: str):
133 + con = db.connect()
134 + row = con.execute("SELECT * FROM vehicles WHERE uid=?", (uid,)).fetchone()
135 + d = None
136 + if row is not None:
137 + d = _row_to_dict(row)
138 + d["price_history"] = [dict(r) for r in con.execute(
139 + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",
140 + (uid,)).fetchall()]
141 + # véhicules similaires : même marque+modèle, autres sources incluses
142 + if d.get("make") and d.get("model"):
143 + base_model = d["model"].split()[0]
144 + d["similar"] = [_row_to_dict(r) for r in con.execute(
145 + "SELECT * FROM vehicles WHERE active=1 AND make=? AND model LIKE ?"
146 + " AND uid<>? ORDER BY price IS NULL, price ASC LIMIT 6",
147 + (d["make"], f"{base_model}%", uid)).fetchall()]
148 + else:
149 + d["similar"] = []
150 + con.close()
151 + if d is None:
152 + raise HTTPException(404, "Véhicule introuvable")
153 + return d
154 +
155 +
156 +@app.get("/api/facets")
157 +def facets(make: str | None = None):
158 + """Valeurs distinctes pour construire les filtres du frontend.
159 +
160 + `make` (optionnel) restreint la liste des modèles à cette marque —
161 + utilisé par le sélecteur « Modèle » dépendant de « Marque ».
162 + """
163 + con = db.connect()
164 + model_sql = ("SELECT model, COUNT(*) n FROM vehicles"
165 + " WHERE active=1 AND model<>''")
166 + model_args: list = []
167 + if make:
168 + model_sql += " AND make=?"
169 + model_args.append(make)
170 + out = {
171 + "makes": [dict(r) for r in con.execute(
172 + "SELECT make, COUNT(*) n FROM vehicles WHERE active=1 AND make<>''"
173 + " GROUP BY make ORDER BY n DESC")],
174 + "models": [dict(r) for r in con.execute(
175 + model_sql + " GROUP BY model ORDER BY n DESC LIMIT 80", model_args)],
176 + "body_types": [r["body_type"] for r in con.execute(
177 + "SELECT DISTINCT body_type FROM vehicles WHERE active=1"
178 + " AND body_type<>'' ORDER BY body_type")],
179 + "fuels": [r["fuel"] for r in con.execute(
180 + "SELECT DISTINCT fuel FROM vehicles WHERE active=1 AND fuel<>''"
181 + " ORDER BY fuel")],
182 + "regions": [dict(r) for r in con.execute(
183 + "SELECT region, COUNT(*) n FROM vehicles WHERE active=1 AND region<>''"
184 + " GROUP BY region ORDER BY n DESC")],
185 + "sources": [dict(r) for r in con.execute(
186 + "SELECT source, dealer_name, COUNT(*) n FROM vehicles WHERE active=1"
187 + " GROUP BY source ORDER BY n DESC")],
188 + "years": [dict(r) for r in con.execute(
189 + "SELECT MIN(year) y_min, MAX(year) y_max FROM vehicles"
190 + " WHERE active=1 AND year IS NOT NULL")],
191 + }
192 + con.close()
193 + return out
194 +
195 +
196 +@app.get("/api/sources")
197 +def sources():
198 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
199 + con = db.connect()
200 + counts = {r["source"]: r["n"] for r in con.execute(
201 + "SELECT source, COUNT(*) n FROM vehicles WHERE active=1 GROUP BY source")}
202 + last = {r["source"]: r["ts"] for r in con.execute(
203 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
204 + con.close()
205 + for s in registry:
206 + s["active_listings"] = counts.get(s["id"], 0)
207 + s["last_sync"] = last.get(s["id"])
208 + return {"sources": registry}
209 +
210 +
211 +@app.get("/api/stats")
212 +def stats():
213 + con = db.connect()
214 + row = con.execute(
215 + """SELECT COUNT(*) total,
216 + COUNT(DISTINCT source) sources,
217 + COUNT(DISTINCT region) regions,
218 + AVG(price) avg_price,
219 + AVG(mileage_km) avg_km,
220 + AVG(year) avg_year
221 + FROM vehicles WHERE active=1""").fetchone()
222 + by_region = [dict(r) for r in con.execute(
223 + "SELECT region, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles"
224 + " WHERE active=1 AND region<>'' GROUP BY region ORDER BY n DESC")]
225 + by_make = [dict(r) for r in con.execute(
226 + "SELECT make, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles"
227 + " WHERE active=1 AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")]
228 + by_body = [dict(r) for r in con.execute(
229 + "SELECT body_type, COUNT(*) n FROM vehicles WHERE active=1"
230 + " AND body_type<>'' GROUP BY body_type ORDER BY n DESC")]
231 + # baisses de prix récentes (signal d'aubaine)
232 + drops = [dict(r) for r in con.execute(
233 + """SELECT v.uid, v.title, v.year, v.price, v.images, v.dealer_name, v.city,
234 + p.prev_price
235 + FROM vehicles v JOIN (
236 + SELECT uid, price prev_price,
237 + ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn
238 + FROM price_log) p ON p.uid=v.uid AND p.rn=2
239 + WHERE v.active=1 AND v.price IS NOT NULL AND p.prev_price > v.price
240 + ORDER BY (p.prev_price - v.price) DESC LIMIT 12""")]
241 + for d in drops:
242 + d["images"] = json.loads(d.get("images") or "[]")[:1]
243 + log = [dict(r) for r in con.execute(
244 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
245 + con.close()
246 + return {**dict(row), "by_region": by_region, "by_make": by_make,
247 + "by_body": by_body, "price_drops": drops, "recent_syncs": log}
248 +
249 +
250 +@app.post("/api/sync")
251 +def trigger_sync(background: BackgroundTasks, source: str | None = None):
252 + """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
253 + def _job():
254 + with _sync_lock:
255 + ingest.run([source] if source else None)
256 + background.add_task(_job)
257 + return {"status": "démarré", "source": source or "toutes"}
258 +
259 +
260 +# --- Frontend React (build Vite) --------------------------------------------
261 +if FRONTEND_DIST.exists():
262 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
263 +
264 + @app.get("/{full_path:path}")
265 + def spa(full_path: str):
266 + target = FRONTEND_DIST / full_path
267 + if full_path and target.is_file():
268 + return FileResponse(target)
269 + return FileResponse(FRONTEND_DIST / "index.html")
added data/autoka.db-shm +0 −0

Binary file not shown.

added data/autoka.db-wal +0 −0
added data/sources.json +1105 −0
@@ -0,0 +1,1105 @@
1 +{
2 + "_comment": "Auto-Ka — Registre des sources (concessionnaires et marchands de voitures usagées, province de Québec). Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 + "sources": [
4 + {
5 + "id": "forceoccasion",
6 + "name": "Force Occasion",
7 + "url": "https://www.forceoccasion.ca",
8 + "listing_url": "https://www.forceoccasion.ca/occasion/recherche.html",
9 + "city": "Québec",
10 + "region": "Capitale-Nationale",
11 + "platform": "D2C Media",
12 + "connector": "d2c_dealers",
13 + "status": "actif"
14 + },
15 + {
16 + "id": "girardautos",
17 + "name": "Girard Autos",
18 + "url": "https://www.girardautos.ca",
19 + "listing_url": "https://www.girardautos.ca/occasion/recherche.html",
20 + "city": "Trois-Rivières",
21 + "region": "Mauricie",
22 + "platform": "D2C Media",
23 + "connector": "d2c_dealers",
24 + "status": "actif"
25 + },
26 + {
27 + "id": "gervaisauto",
28 + "name": "Gervais Auto",
29 + "url": "https://www.gervaisauto.com",
30 + "listing_url": "https://www.gervaisauto.com/occasion/recherche.html",
31 + "city": "Shawinigan",
32 + "region": "Mauricie",
33 + "platform": "D2C Media",
34 + "connector": "d2c_dealers",
35 + "status": "actif"
36 + },
37 + {
38 + "id": "equipeformule",
39 + "name": "Équipe Formule",
40 + "url": "https://www.equipeformule.com",
41 + "listing_url": "https://www.equipeformule.com/occasion/recherche.html",
42 + "city": "Rimouski",
43 + "region": "Bas-Saint-Laurent",
44 + "platform": "D2C Media",
45 + "connector": "d2c_dealers",
46 + "status": "actif"
47 + },
48 + {
49 + "id": "autosevasion",
50 + "name": "Autos Évasion",
51 + "url": "https://www.autosevasion.com",
52 + "listing_url": "https://www.autosevasion.com/occasion/recherche.html",
53 + "city": "Sherbrooke",
54 + "region": "Estrie",
55 + "platform": "D2C Media",
56 + "connector": "d2c_dealers",
57 + "status": "actif"
58 + },
59 + {
60 + "id": "mferlandauto",
61 + "name": "M. Ferland Auto",
62 + "url": "https://www.mferlandauto.com",
63 + "listing_url": "https://www.mferlandauto.com/occasion/recherche.html",
64 + "city": "Sherbrooke",
65 + "region": "Estrie",
66 + "platform": "D2C Media",
67 + "connector": "d2c_dealers",
68 + "status": "actif"
69 + },
70 + {
71 + "id": "autosoccasionld",
72 + "name": "Autos Occasion LD",
73 + "url": "https://www.autosoccasions.ca",
74 + "listing_url": "https://www.autosoccasions.ca/occasion/recherche.html",
75 + "city": "Lévis",
76 + "region": "Chaudière-Appalaches",
77 + "platform": "D2C Media",
78 + "connector": "d2c_dealers",
79 + "status": "actif"
80 + },
81 + {
82 + "id": "levisautos",
83 + "name": "Lévis Autos",
84 + "url": "https://www.levisautos.com",
85 + "listing_url": "https://www.levisautos.com/occasion/recherche.html",
86 + "city": "Lévis",
87 + "region": "Chaudière-Appalaches",
88 + "platform": "D2C Media",
89 + "connector": "d2c_dealers",
90 + "status": "actif"
91 + },
92 + {
93 + "id": "autoquebeclevis",
94 + "name": "AutoQuébec Occasion Lévis",
95 + "url": "https://www.autoquebecoccasionlevis.com",
96 + "listing_url": "https://www.autoquebecoccasionlevis.com/occasion/recherche.html",
97 + "city": "Lévis",
98 + "region": "Chaudière-Appalaches",
99 + "platform": "D2C Media",
100 + "connector": "d2c_dealers",
101 + "status": "actif"
102 + },
103 + {
104 + "id": "autoquebectm",
105 + "name": "AutoQuébec Occasion Thetford Mines",
106 + "url": "https://www.autoquebectm.com",
107 + "listing_url": "https://www.autoquebectm.com/occasion/recherche.html",
108 + "city": "Thetford Mines",
109 + "region": "Chaudière-Appalaches",
110 + "platform": "D2C Media",
111 + "connector": "d2c_dealers",
112 + "status": "actif"
113 + },
114 + {
115 + "id": "abitibiautocredit",
116 + "name": "Abitibi Auto Crédit",
117 + "url": "https://www.abitibiautocredit.ca",
118 + "listing_url": "https://www.abitibiautocredit.ca/occasion/recherche.html",
119 + "city": "Rouyn-Noranda",
120 + "region": "Abitibi-Témiscamingue",
121 + "platform": "D2C Media",
122 + "connector": "d2c_dealers",
123 + "status": "actif"
124 + },
125 + {
126 + "id": "nicoloccasion",
127 + "name": "Nicol Occasion",
128 + "url": "https://www.nicoloccasion.com",
129 + "listing_url": "https://www.nicoloccasion.com/occasion/recherche.html",
130 + "city": "La Sarre",
131 + "region": "Abitibi-Témiscamingue",
132 + "platform": "D2C Media",
133 + "connector": "d2c_dealers",
134 + "status": "actif"
135 + },
136 + {
137 + "id": "poirieroccasion",
138 + "name": "Poirier Occasion",
139 + "url": "https://www.poirieroccasion.com",
140 + "listing_url": "https://www.poirieroccasion.com/occasion/recherche.html",
141 + "city": "Val-d'Or",
142 + "region": "Abitibi-Témiscamingue",
143 + "platform": "D2C Media",
144 + "connector": "d2c_dealers",
145 + "status": "actif"
146 + },
147 + {
148 + "id": "autosthubert",
149 + "name": "Auto St-Hubert",
150 + "url": "https://www.autosthubert.com",
151 + "listing_url": "https://www.autosthubert.com/occasion/recherche.html",
152 + "city": "Saint-Hubert",
153 + "region": "Montérégie",
154 + "platform": "D2C Media",
155 + "connector": "d2c_dealers",
156 + "status": "actif"
157 + },
158 + {
159 + "id": "autoflash",
160 + "name": "AutoFlash",
161 + "url": "https://www.autoflash.net",
162 + "listing_url": "https://www.autoflash.net/occasion/recherche.html",
163 + "city": "Saint-Hubert",
164 + "region": "Montérégie",
165 + "platform": "WordPress Convertus VMS (Cloudflare, via Firecrawl)",
166 + "connector": "convertus_fc",
167 + "status": "actif"
168 + },
169 + {
170 + "id": "bernieresauto",
171 + "name": "Bernières Auto",
172 + "url": "https://www.bernieresauto.com",
173 + "listing_url": "https://www.bernieresauto.com/occasion/recherche.html",
174 + "city": "Saint-Nicolas",
175 + "region": "Chaudière-Appalaches",
176 + "platform": "WordPress Convertus VMS (Cloudflare, via Firecrawl)",
177 + "connector": "convertus_fc",
178 + "status": "actif"
179 + },
180 + {
181 + "id": "complexeautoplus",
182 + "name": "Complexe Auto Plus",
183 + "url": "https://www.complexeautoplus.com",
184 + "listing_url": "https://www.complexeautoplus.com/occasion/recherche.html",
185 + "city": "Saint-Jérôme",
186 + "region": "Laurentides",
187 + "platform": "WordPress Convertus VMS (Cloudflare, via Firecrawl)",
188 + "connector": "convertus_fc",
189 + "status": "actif"
190 + },
191 + {
192 + "id": "denisauto",
193 + "name": "Denis Auto",
194 + "url": "https://www.denisauto.ca",
195 + "listing_url": "https://www.denisauto.ca/inventaire/",
196 + "city": "Gatineau",
197 + "region": "Outaouais",
198 + "platform": "AMVOQ Focus 360 (theme_central)",
199 + "connector": "central_dealers",
200 + "status": "actif"
201 + },
202 + {
203 + "id": "jrautotr",
204 + "name": "J.R. Auto",
205 + "url": "https://www.jrautotr.com",
206 + "listing_url": "https://www.jrautotr.com/inventaire/",
207 + "city": "Trois-Rivières",
208 + "region": "Mauricie",
209 + "platform": "AMVOQ Focus 360 (theme_central)",
210 + "connector": "central_dealers",
211 + "status": "actif"
212 + },
213 + {
214 + "id": "autosag",
215 + "name": "Autosag",
216 + "url": "https://www.autosag.com",
217 + "listing_url": "https://www.autosag.com/inventaire/",
218 + "city": "Chicoutimi",
219 + "region": "Saguenay-Lac-Saint-Jean",
220 + "platform": "AMVOQ Focus 360 (theme_central)",
221 + "connector": "central_dealers",
222 + "status": "actif"
223 + },
224 + {
225 + "id": "lecentreautoquebec",
226 + "name": "Le Centre Auto Québec Plus",
227 + "url": "https://www.lecentreautoquebec.com",
228 + "listing_url": "https://www.lecentreautoquebec.com/inventaire/",
229 + "city": "Québec",
230 + "region": "Capitale-Nationale",
231 + "platform": "AMVOQ Focus 360 (theme_central)",
232 + "connector": "central_dealers",
233 + "status": "actif"
234 + },
235 + {
236 + "id": "autocreditplus",
237 + "name": "Auto Crédit Plus",
238 + "url": "https://www.autocreditplus.ca",
239 + "listing_url": "https://www.autocreditplus.ca/inventaire/",
240 + "city": "Trois-Rivières",
241 + "region": "Mauricie",
242 + "platform": "AMVOQ Focus 360 (theme_central)",
243 + "connector": "central_dealers",
244 + "status": "actif"
245 + },
246 + {
247 + "id": "hgregoire",
248 + "name": "HGrégoire",
249 + "url": "https://www.hgregoire.com",
250 + "listing_url": "https://www.hgregoire.com/auto-usage",
251 + "city": "St-Léonard",
252 + "region": "Montréal",
253 + "platform": "Site custom (WordPress HG)",
254 + "connector": "hgregoire",
255 + "status": "actif"
256 + },
257 + {
258 + "id": "automobileendirect",
259 + "name": "Automobile En Direct",
260 + "url": "https://www.automobileendirect.com",
261 + "listing_url": "https://www.automobileendirect.com/auto-usage",
262 + "city": "Saint-Constant",
263 + "region": "Montérégie",
264 + "platform": "Site custom (SPA React + API REST)",
265 + "connector": "automobileendirect",
266 + "status": "actif"
267 + },
268 + {
269 + "id": "occasionbeaucage",
270 + "name": "Occasion Beaucage",
271 + "url": "https://www.occasionbeaucage.com",
272 + "listing_url": "https://www.occasionbeaucage.com/auto-usage/",
273 + "city": "Sherbrooke",
274 + "region": "Estrie",
275 + "platform": "Next.js Autoroot / EvalAuto (multi-succursales : Sherbrooke, Magog, Granby, Drummondville, Victoriaville, Saint-Hyacinthe, Cowansville — ville par véhicule)",
276 + "connector": "occasionbeaucage",
277 + "status": "actif"
278 + },
279 + {
280 + "id": "megacentredeliquidation",
281 + "name": "Méga Centre de liquidation",
282 + "url": "https://www.megacentredeliquidation.com",
283 + "listing_url": "https://www.megacentredeliquidation.com/auto-usage/",
284 + "city": "Mont-Joli",
285 + "region": "Bas-Saint-Laurent",
286 + "platform": "Gatsby / EvalAuto (Groupe Laplante — Québec, Trois-Rivières, Rimouski, Matane, Mont-Joli, Amqui, Lanaudière — ville par véhicule)",
287 + "connector": "megacentre",
288 + "status": "actif"
289 + },
290 + {
291 + "id": "leprixdugros",
292 + "name": "Le Prix du Gros",
293 + "url": "https://www.leprixdugros.com",
294 + "listing_url": "https://www.leprixdugros.com/inventaire/occasion/",
295 + "city": "Trois-Rivières",
296 + "region": "Mauricie",
297 + "platform": "WordPress Stereodev (JSON paginé ?output=json — groupe Kia/Nissan/Hyundai/Mazda : Trois-Rivières, Shawinigan, Donnacona, Lévis, Québec, Laval, Sherbrooke, Joliette — ville par véhicule via page détail)",
298 + "connector": "leprixdugros",
299 + "status": "actif"
300 + },
301 + {
302 + "id": "autoglobalmtl",
303 + "name": "Auto Global MTL",
304 + "url": "https://www.autoglobalmtl.com",
305 + "listing_url": "https://www.autoglobalmtl.com/fr/occasion",
306 + "city": "Montréal",
307 + "region": "Montréal",
308 + "platform": "ADWS (adws.ca — Next.js, API JSON /api/inventory)",
309 + "connector": "vvu_dealers",
310 + "status": "actif"
311 + },
312 + {
313 + "id": "groupeautomobile",
314 + "name": "Le Groupe Automobile Laval",
315 + "url": "https://www.groupeautomobile.com",
316 + "listing_url": "https://www.groupeautomobile.com/fr/occasion",
317 + "city": "Laval",
318 + "region": "Laval",
319 + "platform": "ADWS (adws.ca — Next.js, API JSON /api/inventory)",
320 + "connector": "vvu_dealers",
321 + "status": "actif"
322 + },
323 + {
324 + "id": "summumauto",
325 + "name": "Summum Auto",
326 + "url": "https://www.summumauto.com",
327 + "listing_url": "https://www.summumauto.com/fr/occasion",
328 + "city": "Laval",
329 + "region": "Laval",
330 + "platform": "ADWS (adws.ca — Next.js, API JSON /api/inventory)",
331 + "connector": "vvu_dealers",
332 + "status": "actif"
333 + },
334 + {
335 + "id": "occasionvilledequebec",
336 + "name": "Comme1Neuf",
337 + "url": "https://www.comme1neuf.com",
338 + "listing_url": "https://www.comme1neuf.com/fr/occasion",
339 + "city": "Château-Richer",
340 + "region": "Capitale-Nationale",
341 + "platform": "ADWS (adws.ca — Next.js, API JSON /api/inventory ; occasionvilledequebec.com redirige 301 vers comme1neuf.com)",
342 + "connector": "vvu_dealers",
343 + "status": "actif"
344 + },
345 + {
346 + "id": "jlkauto",
347 + "name": "JLK Auto",
348 + "url": "https://www.jlkauto.ca",
349 + "listing_url": "https://www.jlkauto.ca/inventaire/?condition=USED",
350 + "city": "Montréal",
351 + "region": "Montréal",
352 + "platform": "EDealer (sitemap Yoast inventory-listing + JSON-LD [Product, Car] sur pages détail)",
353 + "connector": "jlkauto",
354 + "status": "actif"
355 + },
356 + {
357 + "id": "clubautozone",
358 + "name": "Club Auto Zone",
359 + "url": "https://www.clubautozone.ca",
360 + "listing_url": "https://www.clubautozone.ca/vehicles/used/",
361 + "city": "Longueuil",
362 + "region": "Montérégie",
363 + "platform": "WordPress Convertus (thème achilles — API VMS vms.prod.convertus.rocks via proxy ajax-vehicles.php, cp=2292)",
364 + "connector": "clubautozone",
365 + "status": "actif"
366 + },
367 + {
368 + "id": "ototroisrivieres",
369 + "name": "OTO Trois-Rivières",
370 + "url": "https://ototroisrivieres.com",
371 + "listing_url": "https://ototroisrivieres.com/inventaire/",
372 + "city": "Trois-Rivières",
373 + "region": "Mauricie",
374 + "platform": "WordPress thème Motors/STM (sitemap wp-sitemap-posts-listings + parsing HTML pages détail)",
375 + "connector": "ototr",
376 + "status": "actif"
377 + },
378 + {
379 + "id": "okaze",
380 + "name": "OKAZE",
381 + "url": "https://www.okaze.ca",
382 + "listing_url": "https://www.okaze.ca/inventaire",
383 + "city": "Saguenay",
384 + "region": "Saguenay–Lac-Saint-Jean",
385 + "platform": "Webflow CMS (cartes /inventaire rendues serveur avec attributs data-search complets)",
386 + "connector": "okaze",
387 + "status": "actif"
388 + },
389 + {
390 + "id": "automobilesyannicklaberge",
391 + "name": "Automobiles Yannick Laberge",
392 + "url": "https://www.automobilesyannicklaberge.com",
393 + "listing_url": "https://www.automobilesyannicklaberge.com/inventaire/",
394 + "city": "Beauport",
395 + "region": "Capitale-Nationale",
396 + "platform": "WordPress AMVOQ theme_central (sitemap Yoast vehicules-sitemap + JSON-LD Vehicle sur pages détail ; REST verrouillé)",
397 + "connector": "yannicklaberge",
398 + "status": "actif"
399 + },
400 + {
401 + "id": "sherbrookeautooccasion",
402 + "name": "Sherbrooke Auto Occasion",
403 + "url": "https://www.sherbrookeautooccasion.com",
404 + "listing_url": "https://www.sherbrookeautooccasion.com/occasion/recherche.html",
405 + "city": "Sherbrooke",
406 + "region": "Estrie",
407 + "platform": "D2C Media",
408 + "connector": "d2c_dealers",
409 + "status": "actif"
410 + },
411 + {
412 + "id": "autodesjardins",
413 + "name": "Automobiles Desjardins",
414 + "url": "https://www.autodesjardins.com",
415 + "listing_url": "https://www.autodesjardins.com/fr/inventaire-occasion",
416 + "city": "Québec",
417 + "region": "Capitale-Nationale",
418 + "platform": "sm360",
419 + "connector": "sm360_dealers",
420 + "status": "actif"
421 + },
422 + {
423 + "id": "berniercrepeau",
424 + "name": "Bernier Crépeau",
425 + "url": "https://www.berniercrepeau.com",
426 + "listing_url": "https://www.berniercrepeau.com/occasion/recherche.html",
427 + "city": "Trois-Rivières",
428 + "region": "Mauricie",
429 + "platform": "D2C Media",
430 + "connector": "d2c_dealers",
431 + "status": "actif"
432 + },
433 + {
434 + "id": "chomedeytoyota",
435 + "name": "Chomedey Toyota Laval",
436 + "url": "https://www.chomedeytoyotalaval.ca",
437 + "listing_url": "https://www.chomedeytoyotalaval.ca/occasion/recherche.html",
438 + "city": "Laval",
439 + "region": "Laval",
440 + "platform": "D2C Media",
441 + "connector": "d2c_dealers",
442 + "status": "actif"
443 + },
444 + {
445 + "id": "drummondvilleford",
446 + "name": "Drummondville Ford",
447 + "url": "https://www.drummondvilleford.com",
448 + "listing_url": "https://www.drummondvilleford.com/occasion/recherche.html",
449 + "city": "Drummondville",
450 + "region": "Centre-du-Québec",
451 + "platform": "D2C Media",
452 + "connector": "d2c_dealers",
453 + "status": "actif"
454 + },
455 + {
456 + "id": "drummondvillevw",
457 + "name": "Drummondville Volkswagen",
458 + "url": "https://www.drummondvillevolkswagen.ca",
459 + "listing_url": "https://www.drummondvillevolkswagen.ca/occasion/recherche.html",
460 + "city": "Drummondville",
461 + "region": "Centre-du-Québec",
462 + "platform": "D2C Media",
463 + "connector": "d2c_dealers",
464 + "status": "actif"
465 + },
466 + {
467 + "id": "dubechrysler",
468 + "name": "Dubé Chrysler",
469 + "url": "https://www.dubechrysler.com",
470 + "listing_url": "https://www.dubechrysler.com/occasion/recherche.html",
471 + "city": "Rivière-du-Loup",
472 + "region": "Bas-Saint-Laurent",
473 + "platform": "D2C Media",
474 + "connector": "d2c_dealers",
475 + "status": "actif"
476 + },
477 + {
478 + "id": "ducloslongueuil",
479 + "name": "Duclos Longueuil Chrysler",
480 + "url": "https://www.ducloschryslerlongueuil.com",
481 + "listing_url": "https://www.ducloschryslerlongueuil.com/occasion/recherche.html",
482 + "city": "Longueuil",
483 + "region": "Montérégie",
484 + "platform": "D2C Media",
485 + "connector": "d2c_dealers",
486 + "status": "actif"
487 + },
488 + {
489 + "id": "dynastiehyundai",
490 + "name": "Dynastie Hyundai",
491 + "url": "https://www.dynastiehyundai.com",
492 + "listing_url": "https://www.dynastiehyundai.com/occasion/recherche.html",
493 + "city": "Rouyn-Noranda",
494 + "region": "Abitibi-Témiscamingue",
495 + "platform": "D2C Media",
496 + "connector": "d2c_dealers",
497 + "status": "actif"
498 + },
499 + {
500 + "id": "fichaultkia",
501 + "name": "Fichault Kia",
502 + "url": "https://www.fichaultkia.com",
503 + "listing_url": "https://www.fichaultkia.com/occasion/recherche.html",
504 + "city": "Châteauguay",
505 + "region": "Montérégie",
506 + "platform": "D2C Media",
507 + "connector": "d2c_dealers",
508 + "status": "actif"
509 + },
510 + {
511 + "id": "fleuryautogroupe",
512 + "name": "Fleury Auto Groupe",
513 + "url": "https://www.fleuryautogroupe.com",
514 + "listing_url": "https://www.fleuryautogroupe.com/inventaire/",
515 + "city": "Gatineau",
516 + "region": "Outaouais",
517 + "platform": "AMVOQ Focus 360 (theme_central)",
518 + "connector": "central_dealers",
519 + "status": "actif"
520 + },
521 + {
522 + "id": "fordthetford",
523 + "name": "Ford Thetford",
524 + "url": "https://www.fordthetford.com",
525 + "listing_url": "https://www.fordthetford.com/occasion/recherche.html",
526 + "city": "Thetford Mines",
527 + "region": "Chaudière-Appalaches",
528 + "platform": "D2C Media",
529 + "connector": "d2c_dealers",
530 + "status": "actif"
531 + },
532 + {
533 + "id": "gaspesiehyundai",
534 + "name": "Gaspésie Hyundai",
535 + "url": "https://www.gaspesiehyundai.com",
536 + "listing_url": "https://www.gaspesiehyundai.com/occasion/recherche.html",
537 + "city": "Bonaventure",
538 + "region": "Gaspésie–Îles-de-la-Madeleine",
539 + "platform": "D2C Media",
540 + "connector": "d2c_dealers",
541 + "status": "actif"
542 + },
543 + {
544 + "id": "gaspetoyota",
545 + "name": "Gaspé Toyota",
546 + "url": "https://www.gaspe-toyota.com",
547 + "listing_url": "https://www.gaspe-toyota.com/occasion/recherche.html",
548 + "city": "Gaspé",
549 + "region": "Gaspésie–Îles-de-la-Madeleine",
550 + "platform": "D2C Media",
551 + "connector": "d2c_dealers",
552 + "status": "actif"
553 + },
554 + {
555 + "id": "granbytoyota",
556 + "name": "Granby Toyota",
557 + "url": "https://www.granbytoyota.ca",
558 + "listing_url": "https://www.granbytoyota.ca/occasion/recherche.html",
559 + "city": "Granby",
560 + "region": "Montérégie",
561 + "platform": "D2C Media",
562 + "connector": "d2c_dealers",
563 + "status": "actif"
564 + },
565 + {
566 + "id": "grandportage",
567 + "name": "Groupe Grand-Portage",
568 + "url": "https://www.grandportageauto.com",
569 + "listing_url": "https://www.grandportageauto.com/occasion/recherche.html",
570 + "city": "Rivière-du-Loup",
571 + "region": "Bas-Saint-Laurent",
572 + "platform": "D2C Media",
573 + "connector": "d2c_dealers",
574 + "status": "actif"
575 + },
576 + {
577 + "id": "groupemotion",
578 + "name": "Groupe Motion",
579 + "url": "https://www.groupemotion.com",
580 + "listing_url": "https://www.groupemotion.com/occasion/recherche.html",
581 + "city": "Longueuil",
582 + "region": "Montérégie",
583 + "platform": "D2C Media",
584 + "connector": "d2c_dealers",
585 + "status": "actif"
586 + },
587 + {
588 + "id": "groupepaquin",
589 + "name": "Groupe Paquin",
590 + "url": "https://www.groupepaquin.com",
591 + "listing_url": "https://www.groupepaquin.com/occasion/recherche.html",
592 + "city": "Rouyn-Noranda",
593 + "region": "Abitibi-Témiscamingue",
594 + "platform": "D2C Media",
595 + "connector": "d2c_dealers",
596 + "status": "actif"
597 + },
598 + {
599 + "id": "hullhyundai",
600 + "name": "Hull Hyundai",
601 + "url": "https://www.hullhyundai.com",
602 + "listing_url": "https://www.hullhyundai.com/occasion/recherche.html",
603 + "city": "Gatineau",
604 + "region": "Outaouais",
605 + "platform": "D2C Media",
606 + "connector": "d2c_dealers",
607 + "status": "actif"
608 + },
609 + {
610 + "id": "hyundaibeauce",
611 + "name": "Hyundai Beauce",
612 + "url": "https://www.hyundaibeauce.com",
613 + "listing_url": "https://www.hyundaibeauce.com/occasion/recherche.html",
614 + "city": "Saint-Georges",
615 + "region": "Chaudière-Appalaches",
616 + "platform": "D2C Media",
617 + "connector": "d2c_dealers",
618 + "status": "actif"
619 + },
620 + {
621 + "id": "hyundaidrummondville",
622 + "name": "Hyundai Drummondville",
623 + "url": "https://www.hyundaidrummondville.com",
624 + "listing_url": "https://www.hyundaidrummondville.com/occasion/recherche.html",
625 + "city": "Drummondville",
626 + "region": "Centre-du-Québec",
627 + "platform": "D2C Media",
628 + "connector": "d2c_dealers",
629 + "status": "actif"
630 + },
631 + {
632 + "id": "hyundailanaudiere",
633 + "name": "Hyundai Lanaudière",
634 + "url": "https://www.hyundailanaudiere.com",
635 + "listing_url": "https://www.hyundailanaudiere.com/occasion/recherche.html",
636 + "city": "Joliette",
637 + "region": "Lanaudière",
638 + "platform": "D2C Media",
639 + "connector": "d2c_dealers",
640 + "status": "actif"
641 + },
642 + {
643 + "id": "hyundailongueuil",
644 + "name": "Hyundai Longueuil",
645 + "url": "https://www.hyundailongueuil.com",
646 + "listing_url": "https://www.hyundailongueuil.com/occasion/recherche.html",
647 + "city": "Longueuil",
648 + "region": "Montérégie",
649 + "platform": "D2C Media",
650 + "connector": "d2c_dealers",
651 + "status": "actif"
652 + },
653 + {
654 + "id": "hyundaistjerome",
655 + "name": "Hyundai St-Jérôme",
656 + "url": "https://www.hyundaistjerome.com",
657 + "listing_url": "https://www.hyundaistjerome.com/occasion/recherche.html",
658 + "city": "Mirabel",
659 + "region": "Laurentides",
660 + "platform": "D2C Media",
661 + "connector": "d2c_dealers",
662 + "status": "actif"
663 + },
664 + {
665 + "id": "jeandumas",
666 + "name": "Jean Dumas Multiconcessionnaire",
667 + "url": "https://www.jeandumas.com",
668 + "listing_url": "https://www.jeandumas.com/occasion/recherche.html",
669 + "city": "Alma",
670 + "region": "Saguenay–Lac-Saint-Jean",
671 + "platform": "D2C Media",
672 + "connector": "d2c_dealers",
673 + "status": "actif"
674 + },
675 + {
676 + "id": "jeandumasfordbc",
677 + "name": "Jean Dumas Ford Baie-Comeau",
678 + "url": "https://www.jeandumasfordbaiecomeau.com",
679 + "listing_url": "https://www.jeandumasfordbaiecomeau.com/occasion/recherche.html",
680 + "city": "Baie-Comeau",
681 + "region": "Côte-Nord",
682 + "platform": "D2C Media",
683 + "connector": "d2c_dealers",
684 + "status": "actif"
685 + },
686 + {
687 + "id": "jeandumashondabc",
688 + "name": "Jean Dumas Honda Baie-Comeau",
689 + "url": "https://www.jeandumashondabaiecomeau.com",
690 + "listing_url": "https://www.jeandumashondabaiecomeau.com/occasion/recherche.html",
691 + "city": "Baie-Comeau",
692 + "region": "Côte-Nord",
693 + "platform": "D2C Media",
694 + "connector": "d2c_dealers",
695 + "status": "actif"
696 + },
697 + {
698 + "id": "joliettemitsubishi",
699 + "name": "Joliette Mitsubishi",
700 + "url": "https://www.joliettemitsubishi.com",
701 + "listing_url": "https://www.joliettemitsubishi.com/occasion/recherche.html",
702 + "city": "Joliette",
703 + "region": "Lanaudière",
704 + "platform": "D2C Media",
705 + "connector": "d2c_dealers",
706 + "status": "actif"
707 + },
708 + {
709 + "id": "kiajoliette",
710 + "name": "Kia Joliette",
711 + "url": "https://www.kiajoliette.com",
712 + "listing_url": "https://www.kiajoliette.com/occasion/recherche.html",
713 + "city": "Joliette",
714 + "region": "Lanaudière",
715 + "platform": "D2C Media",
716 + "connector": "d2c_dealers",
717 + "status": "actif"
718 + },
719 + {
720 + "id": "lallierhondahull",
721 + "name": "Lallier Honda Hull",
722 + "url": "https://www.lallierhondahull.com",
723 + "listing_url": "https://www.lallierhondahull.com/fr/inventaire-occasion",
724 + "city": "Gatineau",
725 + "region": "Outaouais",
726 + "platform": "sm360",
727 + "connector": "sm360_dealers",
728 + "status": "actif"
729 + },
730 + {
731 + "id": "lamihonda",
732 + "name": "L'Ami Honda",
733 + "url": "https://www.lamihonda.com",
734 + "listing_url": "https://www.lamihonda.com/occasion/recherche.html",
735 + "city": "Rouyn-Noranda",
736 + "region": "Abitibi-Témiscamingue",
737 + "platform": "D2C Media",
738 + "connector": "d2c_dealers",
739 + "status": "actif"
740 + },
741 + {
742 + "id": "lamontagneauto",
743 + "name": "Lamontagne Auto",
744 + "url": "https://www.lamontagneauto.com",
745 + "listing_url": "https://www.lamontagneauto.com/fr/inventaire-occasion",
746 + "city": "Rimouski",
747 + "region": "Bas-Saint-Laurent",
748 + "platform": "sm360",
749 + "connector": "sm360_dealers",
750 + "status": "actif"
751 + },
752 + {
753 + "id": "leoautomobile",
754 + "name": "Léo Automobile",
755 + "url": "https://www.leoautomobile.com",
756 + "listing_url": "https://www.leoautomobile.com/occasion/recherche.html",
757 + "city": "Chicoutimi",
758 + "region": "Saguenay–Lac-Saint-Jean",
759 + "platform": "D2C Media",
760 + "connector": "d2c_dealers",
761 + "status": "actif"
762 + },
763 + {
764 + "id": "levistoyota",
765 + "name": "Lévis Toyota",
766 + "url": "https://www.levistoyota.ca",
767 + "listing_url": "https://www.levistoyota.ca/occasion/recherche.html",
768 + "city": "Lévis",
769 + "region": "Chaudière-Appalaches",
770 + "platform": "D2C Media",
771 + "connector": "d2c_dealers",
772 + "status": "actif"
773 + },
774 + {
775 + "id": "longueuilhonda",
776 + "name": "Longueuil Honda",
777 + "url": "https://www.longueuilhonda.com",
778 + "listing_url": "https://www.longueuilhonda.com/occasion/recherche.html",
779 + "city": "Longueuil",
780 + "region": "Montérégie",
781 + "platform": "D2C Media",
782 + "connector": "d2c_dealers",
783 + "status": "actif"
784 + },
785 + {
786 + "id": "olivierchryslerbc",
787 + "name": "Olivier Chrysler Baie-Comeau",
788 + "url": "https://www.olivierchryslerbaiecomeau.com",
789 + "listing_url": "https://www.olivierchryslerbaiecomeau.com/occasion/recherche.html",
790 + "city": "Baie-Comeau",
791 + "region": "Côte-Nord",
792 + "platform": "D2C Media",
793 + "connector": "d2c_dealers",
794 + "status": "actif"
795 + },
796 + {
797 + "id": "rivesudchrysler",
798 + "name": "Rive-Sud Chrysler",
799 + "url": "https://www.rivesudchrysler.com",
800 + "listing_url": "https://www.rivesudchrysler.com/fr/inventaire-occasion",
801 + "city": "Brossard",
802 + "region": "Montérégie",
803 + "platform": "sm360",
804 + "connector": "sm360_dealers",
805 + "status": "actif"
806 + },
807 + {
808 + "id": "spinelli",
809 + "name": "Groupe Spinelli",
810 + "url": "https://www.spinelli.com",
811 + "listing_url": "https://www.spinelli.com/fr/inventaire-occasion",
812 + "city": "Montréal",
813 + "region": "Montréal",
814 + "platform": "sm360",
815 + "connector": "sm360_dealers",
816 + "status": "actif"
817 + },
818 + {
819 + "id": "stgeorgesgm",
820 + "name": "St-Georges Chevrolet Buick GMC",
821 + "url": "https://www.stgeorgesgm.ca",
822 + "listing_url": "https://www.stgeorgesgm.ca/occasion/recherche.html",
823 + "city": "Saint-Georges",
824 + "region": "Chaudière-Appalaches",
825 + "platform": "D2C Media",
826 + "connector": "d2c_dealers",
827 + "status": "actif"
828 + },
829 + {
830 + "id": "stjeromeautodepot",
831 + "name": "St-Jérôme Auto Dépôt",
832 + "url": "https://www.stjeromeautodepot.com",
833 + "listing_url": "https://www.stjeromeautodepot.com/fr/inventaire-occasion",
834 + "city": "Saint-Jérôme",
835 + "region": "Laurentides",
836 + "platform": "sm360",
837 + "connector": "sm360_dealers",
838 + "status": "actif"
839 + },
840 + {
841 + "id": "stjeromemitsubishi",
842 + "name": "St-Jérôme Mitsubishi",
843 + "url": "https://www.stjeromemitsubishi.ca",
844 + "listing_url": "https://www.stjeromemitsubishi.ca/occasion/recherche.html",
845 + "city": "Saint-Jérôme",
846 + "region": "Laurentides",
847 + "platform": "D2C Media",
848 + "connector": "d2c_dealers",
849 + "status": "actif"
850 + },
851 + {
852 + "id": "subaruvictoriaville",
853 + "name": "Subaru Victoriaville",
854 + "url": "https://www.subaruvictoriaville.ca",
855 + "listing_url": "https://www.subaruvictoriaville.ca/occasion/recherche.html",
856 + "city": "Victoriaville",
857 + "region": "Centre-du-Québec",
858 + "platform": "D2C Media",
859 + "connector": "d2c_dealers",
860 + "status": "actif"
861 + },
862 + {
863 + "id": "touteslesmarques",
864 + "name": "Toutes les marques",
865 + "url": "https://www.touteslesmarques.ca",
866 + "listing_url": "https://www.touteslesmarques.ca/fr/inventaire-occasion",
867 + "city": "Chicoutimi",
868 + "region": "Saguenay–Lac-Saint-Jean",
869 + "platform": "sm360",
870 + "connector": "sm360_dealers",
871 + "status": "actif"
872 + },
873 + {
874 + "id": "toyotabdc",
875 + "name": "Toyota Baie des Chaleurs",
876 + "url": "https://www.toyotabaiedeschaleurs.com",
877 + "listing_url": "https://www.toyotabaiedeschaleurs.com/occasion/recherche.html",
878 + "city": "Caplan",
879 + "region": "Gaspésie–Îles-de-la-Madeleine",
880 + "platform": "D2C Media",
881 + "connector": "d2c_dealers",
882 + "status": "actif"
883 + },
884 + {
885 + "id": "toyotagatineau",
886 + "name": "Toyota Gatineau",
887 + "url": "https://www.toyotagatineau.ca",
888 + "listing_url": "https://www.toyotagatineau.ca/fr/inventaire-occasion",
889 + "city": "Gatineau",
890 + "region": "Outaouais",
891 + "platform": "sm360",
892 + "connector": "sm360_dealers",
893 + "status": "actif"
894 + },
895 + {
896 + "id": "troisriviereshonda",
897 + "name": "Trois-Rivières Honda",
898 + "url": "https://www.troisriviereshonda.com",
899 + "listing_url": "https://www.troisriviereshonda.com/occasion/recherche.html",
900 + "city": "Trois-Rivières",
901 + "region": "Mauricie",
902 + "platform": "D2C Media",
903 + "connector": "d2c_dealers",
904 + "status": "actif"
905 + },
906 + {
907 + "id": "trtoyota",
908 + "name": "Trois-Rivières Toyota",
909 + "url": "https://www.trtoyota.ca",
910 + "listing_url": "https://www.trtoyota.ca/occasion/recherche.html",
911 + "city": "Trois-Rivières",
912 + "region": "Mauricie",
913 + "platform": "D2C Media",
914 + "connector": "d2c_dealers",
915 + "status": "actif"
916 + },
917 + {
918 + "id": "vaudreuilvw",
919 + "name": "Vaudreuil Volkswagen",
920 + "url": "https://www.vaudreuilvolkswagen.com",
921 + "listing_url": "https://www.vaudreuilvolkswagen.com/occasion/recherche.html",
922 + "city": "Vaudreuil-Dorion",
923 + "region": "Montérégie",
924 + "platform": "D2C Media",
925 + "connector": "d2c_dealers",
926 + "status": "actif"
927 + },
928 + {
929 + "id": "vwsherbrooke",
930 + "name": "VW Sherbrooke",
931 + "url": "https://www.vwsherbrooke.com",
932 + "listing_url": "https://www.vwsherbrooke.com/occasion/recherche.html",
933 + "city": "Sherbrooke",
934 + "region": "Estrie",
935 + "platform": "D2C Media",
936 + "connector": "d2c_dealers",
937 + "status": "actif"
938 + },
939 + {
940 + "id": "kiagranby",
941 + "name": "Kia de Granby",
942 + "url": "https://www.kiagranby.com",
943 + "listing_url": "https://www.kiagranby.com/auto-usage/",
944 + "city": "Granby",
945 + "region": "Montérégie",
946 + "platform": "Gatsby autoroot.io / EvalAuto (Groupe Beaucage — énumération via AppSync + page-data)",
947 + "connector": "gatsby_dealers",
948 + "status": "actif"
949 + },
950 + {
951 + "id": "kiadrummondville",
952 + "name": "Kia Drummondville",
953 + "url": "https://www.kiadrummondville.com",
954 + "listing_url": "https://www.kiadrummondville.com/auto-usage/",
955 + "city": "Drummondville",
956 + "region": "Centre-du-Québec",
957 + "platform": "Gatsby autoroot.io / EvalAuto (Groupe Beaucage — énumération via AppSync + page-data)",
958 + "connector": "gatsby_dealers",
959 + "status": "actif"
960 + },
961 + {
962 + "id": "mazdadesherbrooke",
963 + "name": "Mazda de Sherbrooke",
964 + "url": "https://www.mazdadesherbrooke.com",
965 + "listing_url": "https://www.mazdadesherbrooke.com/auto-usage/",
966 + "city": "Sherbrooke",
967 + "region": "Estrie",
968 + "platform": "Gatsby autoroot.io / EvalAuto (Groupe Beaucage — énumération via AppSync + page-data)",
969 + "connector": "gatsby_dealers",
970 + "status": "actif"
971 + },
972 + {
973 + "id": "mitsubishidrummondville",
974 + "name": "Mitsubishi Drummondville",
975 + "url": "https://www.mitsubishidrummondville.com",
976 + "listing_url": "https://www.mitsubishidrummondville.com/auto-usage/",
977 + "city": "Drummondville",
978 + "region": "Centre-du-Québec",
979 + "platform": "Gatsby autoroot.io / EvalAuto (Groupe Beaucage — énumération via AppSync + page-data)",
980 + "connector": "gatsby_dealers",
981 + "status": "actif"
982 + },
983 + {
984 + "id": "nissandrummondville",
985 + "name": "Nissan Drummondville",
986 + "url": "https://www.nissandrummondville.com",
987 + "listing_url": "https://www.nissandrummondville.com/auto-usage/",
988 + "city": "Drummondville",
989 + "region": "Centre-du-Québec",
990 + "platform": "Gatsby autoroot.io / EvalAuto (Groupe Beaucage — énumération via AppSync + page-data)",
991 + "connector": "gatsby_dealers",
992 + "status": "actif"
993 + },
994 + {
995 + "id": "nissanvictoriaville",
996 + "name": "Nissan de Victoriaville",
997 + "url": "https://www.nissanvictoriaville.com",
998 + "listing_url": "https://www.nissanvictoriaville.com/auto-usage/",
999 + "city": "Victoriaville",
1000 + "region": "Centre-du-Québec",
1001 + "platform": "Gatsby autoroot.io / EvalAuto (Groupe Beaucage — énumération via AppSync + page-data)",
1002 + "connector": "gatsby_dealers",
1003 + "status": "actif"
1004 + },
1005 + {
1006 + "id": "stefoychrysler",
1007 + "name": "Ste-Foy Chrysler",
1008 + "url": "https://www.ste-foychrysler.com",
1009 + "listing_url": "https://www.ste-foychrysler.com/auto-usage/",
1010 + "city": "Québec",
1011 + "region": "Capitale-Nationale",
1012 + "platform": "Next.js RSC autoroot.io / EvalAuto (pattern Occasion Beaucage — ~430 véhicules)",
1013 + "connector": "stefoychrysler",
1014 + "status": "actif"
1015 + },
1016 + {
1017 + "id": "baiecomeautoyota",
1018 + "name": "Baie-Comeau Toyota",
1019 + "url": "https://baiecomeautoyota.com",
1020 + "listing_url": "https://baiecomeautoyota.com/inventaire/occasion",
1021 + "city": "Baie-Comeau",
1022 + "region": "Côte-Nord",
1023 + "platform": "WordPress magnetis-dealer / SyncAuto (réseau Côte-Nord–Saguenay : Toyota/Chevrolet/Nissan Baie-Comeau, Arnold GM, Sept-Îles… — concession et ville par véhicule)",
1024 + "connector": "magnetis_dealers",
1025 + "status": "actif"
1026 + },
1027 + {
1028 + "id": "sherbrooketoyota",
1029 + "name": "Sherbrooke Toyota",
1030 + "url": "https://sherbrooketoyota.ca",
1031 + "listing_url": "https://sherbrooketoyota.ca/inventaire/occasion",
1032 + "city": "Sherbrooke",
1033 + "region": "Estrie",
1034 + "platform": "WordPress magnetis-dealer / SyncAuto",
1035 + "connector": "magnetis_dealers",
1036 + "status": "actif"
1037 + },
1038 + {
1039 + "id": "stgnissan",
1040 + "name": "St-Georges Nissan",
1041 + "url": "https://www.stgnissan.com",
1042 + "listing_url": "https://www.stgnissan.com/inventaire/occasion",
1043 + "city": "Saint-Georges",
1044 + "region": "Chaudière-Appalaches",
1045 + "platform": "WordPress magnetis-dealer / SyncAuto",
1046 + "connector": "magnetis_dealers",
1047 + "status": "actif"
1048 + },
1049 + {
1050 + "id": "lussierchevrolet",
1051 + "name": "Lussier Chevrolet Buick GMC",
1052 + "url": "https://www.lussierchevrolet.com",
1053 + "listing_url": "https://www.lussierchevrolet.com/inventaire/occasion",
1054 + "city": "Saint-Hyacinthe",
1055 + "region": "Montérégie",
1056 + "platform": "WordPress magnetis-dealer / SyncAuto (remplace autobasprixst-hyacinthe.com, qui redirige ici depuis la fusion Auto Bas Prix / Lussier)",
1057 + "connector": "magnetis_dealers",
1058 + "status": "actif"
1059 + },
1060 + {
1061 + "id": "albioccasion",
1062 + "name": "ALBI Occasion",
1063 + "url": "https://www.albioccasion.com",
1064 + "listing_url": "https://www.albioccasion.com/fr/inventaire/",
1065 + "city": "Mascouche",
1066 + "region": "Lanaudière",
1067 + "platform": "ASP.NET WebForms maison (ALBI le Géant — multi-succursales Mascouche/Repentigny/Blainville/Laval…, ~1 100 véhicules, succursale par véhicule ; microdata schema.org sur les pages détail)",
1068 + "connector": "albioccasion",
1069 + "status": "en pause (rate-limit 429 — connecteur prêt, à réactiver)"
1070 + },
1071 + {
1072 + "id": "desmeules",
1073 + "name": "Desmeules Chrysler",
1074 + "url": "https://www.desmeules.ca",
1075 + "listing_url": "https://www.desmeules.ca/fr/vehicules-doccasion/",
1076 + "city": "Laval",
1077 + "region": "Laval",
1078 + "platform": "ColdFusion maison + AutoJini (flux RSS inventaire complet + pages détail)",
1079 + "connector": "desmeules",
1080 + "status": "actif"
1081 + },
1082 + {
1083 + "id": "dupontdupontford",
1084 + "name": "Dupont & Dupont Ford",
1085 + "url": "https://dupontdupontford.com",
1086 + "listing_url": "https://dupontdupontford.com/recherche/",
1087 + "city": "Gatineau",
1088 + "region": "Outaouais",
1089 + "platform": "WordPress Leadbox lbx-egypt (export JSON statique /wp-content/uploads/data/inventory.json — 1 requête pour tout l'inventaire)",
1090 + "connector": "dupontford",
1091 + "status": "actif"
1092 + },
1093 + {
1094 + "id": "autodurocher",
1095 + "name": "Entrepôt Auto Durocher",
1096 + "url": "https://www.autodurocher.com",
1097 + "listing_url": "https://www.autodurocher.com/fr/inventaire-occasion/",
1098 + "city": "Mirabel",
1099 + "region": "Laurentides",
1100 + "platform": "WordPress Elementor + JetEngine (sitemaps vehicles-sitemapN.xml + JSON-LD fute-custom-schema-vehicles)",
1101 + "connector": "autodurocher",
1102 + "status": "actif"
1103 + }
1104 + ]
1105 +}
\ No newline at end of file
added docs/screenshot-home.png +0 −0

Binary file not shown.

added docs/screenshot-stats.png +0 −0

Binary file not shown.

added frontend/index.html +20 −0
@@ -0,0 +1,20 @@
1 +<!doctype html>
2 +<html lang="fr">
3 + <head>
4 + <meta charset="UTF-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6 + <meta name="description" content="Auto-Ka — toutes les voitures usagées à vendre au Québec, agrégées directement depuis les sites des concessionnaires. Un seul endroit, toujours à jour." />
7 + <meta name="theme-color" content="#17181c" />
8 + <title>Auto·Ka — Voitures usagées à vendre au Québec</title>
9 + <link rel="preconnect" href="https://fonts.googleapis.com" />
10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11 + <link
12 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
13 + rel="stylesheet"
14 + />
15 + </head>
16 + <body>
17 + <div id="root"></div>
18 + <script type="module" src="/src/main.tsx"></script>
19 + </body>
20 +</html>
added frontend/package-lock.json +1834 −0
@@ -0,0 +1,1834 @@
1 +{
2 + "name": "auto-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "auto-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "react": "^18.3.1",
12 + "react-dom": "^18.3.1",
13 + "react-router-dom": "^6.26.0"
14 + },
15 + "devDependencies": {
16 + "@types/react": "^18.3.3",
17 + "@types/react-dom": "^18.3.0",
18 + "@vitejs/plugin-react": "^4.3.1",
19 + "typescript": "^5.5.4",
20 + "vite": "^5.4.0"
21 + }
22 + },
23 + "node_modules/@babel/code-frame": {
24 + "version": "7.29.7",
25 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
26 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
27 + "dev": true,
28 + "license": "MIT",
29 + "dependencies": {
30 + "@babel/helper-validator-identifier": "^7.29.7",
31 + "js-tokens": "^4.0.0",
32 + "picocolors": "^1.1.1"
33 + },
34 + "engines": {
35 + "node": ">=6.9.0"
36 + }
37 + },
38 + "node_modules/@babel/compat-data": {
39 + "version": "7.29.7",
40 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
41 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
42 + "dev": true,
43 + "license": "MIT",
44 + "engines": {
45 + "node": ">=6.9.0"
46 + }
47 + },
48 + "node_modules/@babel/core": {
49 + "version": "7.29.7",
50 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
51 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
52 + "dev": true,
53 + "license": "MIT",
54 + "dependencies": {
55 + "@babel/code-frame": "^7.29.7",
56 + "@babel/generator": "^7.29.7",
57 + "@babel/helper-compilation-targets": "^7.29.7",
58 + "@babel/helper-module-transforms": "^7.29.7",
59 + "@babel/helpers": "^7.29.7",
60 + "@babel/parser": "^7.29.7",
61 + "@babel/template": "^7.29.7",
62 + "@babel/traverse": "^7.29.7",
63 + "@babel/types": "^7.29.7",
64 + "@jridgewell/remapping": "^2.3.5",
65 + "convert-source-map": "^2.0.0",
66 + "debug": "^4.1.0",
67 + "gensync": "^1.0.0-beta.2",
68 + "json5": "^2.2.3",
69 + "semver": "^6.3.1"
70 + },
71 + "engines": {
72 + "node": ">=6.9.0"
73 + },
74 + "funding": {
75 + "type": "opencollective",
76 + "url": "https://opencollective.com/babel"
77 + }
78 + },
79 + "node_modules/@babel/generator": {
80 + "version": "7.29.8",
81 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
82 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
83 + "dev": true,
84 + "license": "MIT",
85 + "dependencies": {
86 + "@babel/parser": "^7.29.8",
87 + "@babel/types": "^7.29.8",
88 + "@jridgewell/gen-mapping": "^0.3.12",
89 + "@jridgewell/trace-mapping": "^0.3.28",
90 + "jsesc": "^3.0.2"
91 + },
92 + "engines": {
93 + "node": ">=6.9.0"
94 + }
95 + },
96 + "node_modules/@babel/helper-compilation-targets": {
97 + "version": "7.29.7",
98 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
99 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
100 + "dev": true,
101 + "license": "MIT",
102 + "dependencies": {
103 + "@babel/compat-data": "^7.29.7",
104 + "@babel/helper-validator-option": "^7.29.7",
105 + "browserslist": "^4.24.0",
106 + "lru-cache": "^5.1.1",
107 + "semver": "^6.3.1"
108 + },
109 + "engines": {
110 + "node": ">=6.9.0"
111 + }
112 + },
113 + "node_modules/@babel/helper-globals": {
114 + "version": "7.29.7",
115 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
116 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
117 + "dev": true,
118 + "license": "MIT",
119 + "engines": {
120 + "node": ">=6.9.0"
121 + }
122 + },
123 + "node_modules/@babel/helper-module-imports": {
124 + "version": "7.29.7",
125 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
126 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
127 + "dev": true,
128 + "license": "MIT",
129 + "dependencies": {
130 + "@babel/traverse": "^7.29.7",
131 + "@babel/types": "^7.29.7"
132 + },
133 + "engines": {
134 + "node": ">=6.9.0"
135 + }
136 + },
137 + "node_modules/@babel/helper-module-transforms": {
138 + "version": "7.29.7",
139 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
140 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
141 + "dev": true,
142 + "license": "MIT",
143 + "dependencies": {
144 + "@babel/helper-module-imports": "^7.29.7",
145 + "@babel/helper-validator-identifier": "^7.29.7",
146 + "@babel/traverse": "^7.29.7"
147 + },
148 + "engines": {
149 + "node": ">=6.9.0"
150 + },
151 + "peerDependencies": {
152 + "@babel/core": "^7.0.0"
153 + }
154 + },
155 + "node_modules/@babel/helper-plugin-utils": {
156 + "version": "7.29.7",
157 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
158 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
159 + "dev": true,
160 + "license": "MIT",
161 + "engines": {
162 + "node": ">=6.9.0"
163 + }
164 + },
165 + "node_modules/@babel/helper-string-parser": {
166 + "version": "7.29.7",
167 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
168 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
169 + "dev": true,
170 + "license": "MIT",
171 + "engines": {
172 + "node": ">=6.9.0"
173 + }
174 + },
175 + "node_modules/@babel/helper-validator-identifier": {
176 + "version": "7.29.7",
177 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
178 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
179 + "dev": true,
180 + "license": "MIT",
181 + "engines": {
182 + "node": ">=6.9.0"
183 + }
184 + },
185 + "node_modules/@babel/helper-validator-option": {
186 + "version": "7.29.7",
187 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
188 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
189 + "dev": true,
190 + "license": "MIT",
191 + "engines": {
192 + "node": ">=6.9.0"
193 + }
194 + },
195 + "node_modules/@babel/helpers": {
196 + "version": "7.29.7",
197 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
198 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
199 + "dev": true,
200 + "license": "MIT",
201 + "dependencies": {
202 + "@babel/template": "^7.29.7",
203 + "@babel/types": "^7.29.7"
204 + },
205 + "engines": {
206 + "node": ">=6.9.0"
207 + }
208 + },
209 + "node_modules/@babel/parser": {
210 + "version": "7.29.8",
211 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
212 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
213 + "dev": true,
214 + "license": "MIT",
215 + "dependencies": {
216 + "@babel/types": "^7.29.8"
217 + },
218 + "bin": {
219 + "parser": "bin/babel-parser.js"
220 + },
221 + "engines": {
222 + "node": ">=6.0.0"
223 + }
224 + },
225 + "node_modules/@babel/plugin-transform-react-jsx-self": {
226 + "version": "7.29.7",
227 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
228 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
229 + "dev": true,
230 + "license": "MIT",
231 + "dependencies": {
232 + "@babel/helper-plugin-utils": "^7.29.7"
233 + },
234 + "engines": {
235 + "node": ">=6.9.0"
236 + },
237 + "peerDependencies": {
238 + "@babel/core": "^7.0.0-0"
239 + }
240 + },
241 + "node_modules/@babel/plugin-transform-react-jsx-source": {
242 + "version": "7.29.7",
243 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
244 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
245 + "dev": true,
246 + "license": "MIT",
247 + "dependencies": {
248 + "@babel/helper-plugin-utils": "^7.29.7"
249 + },
250 + "engines": {
251 + "node": ">=6.9.0"
252 + },
253 + "peerDependencies": {
254 + "@babel/core": "^7.0.0-0"
255 + }
256 + },
257 + "node_modules/@babel/template": {
258 + "version": "7.29.7",
259 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
260 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
261 + "dev": true,
262 + "license": "MIT",
263 + "dependencies": {
264 + "@babel/code-frame": "^7.29.7",
265 + "@babel/parser": "^7.29.7",
266 + "@babel/types": "^7.29.7"
267 + },
268 + "engines": {
269 + "node": ">=6.9.0"
270 + }
271 + },
272 + "node_modules/@babel/traverse": {
273 + "version": "7.29.8",
274 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
275 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
276 + "dev": true,
277 + "license": "MIT",
278 + "dependencies": {
279 + "@babel/code-frame": "^7.29.7",
280 + "@babel/generator": "^7.29.8",
281 + "@babel/helper-globals": "^7.29.7",
282 + "@babel/parser": "^7.29.8",
283 + "@babel/template": "^7.29.7",
284 + "@babel/types": "^7.29.8",
285 + "debug": "^4.3.1"
286 + },
287 + "engines": {
288 + "node": ">=6.9.0"
289 + }
290 + },
291 + "node_modules/@babel/types": {
292 + "version": "7.29.8",
293 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
294 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
295 + "dev": true,
296 + "license": "MIT",
297 + "dependencies": {
298 + "@babel/helper-string-parser": "^7.29.7",
299 + "@babel/helper-validator-identifier": "^7.29.7"
300 + },
301 + "engines": {
302 + "node": ">=6.9.0"
303 + }
304 + },
305 + "node_modules/@esbuild/aix-ppc64": {
306 + "version": "0.21.5",
307 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
308 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
309 + "cpu": [
310 + "ppc64"
311 + ],
312 + "dev": true,
313 + "license": "MIT",
314 + "optional": true,
315 + "os": [
316 + "aix"
317 + ],
318 + "engines": {
319 + "node": ">=12"
320 + }
321 + },
322 + "node_modules/@esbuild/android-arm": {
323 + "version": "0.21.5",
324 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
325 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
326 + "cpu": [
327 + "arm"
328 + ],
329 + "dev": true,
330 + "license": "MIT",
331 + "optional": true,
332 + "os": [
333 + "android"
334 + ],
335 + "engines": {
336 + "node": ">=12"
337 + }
338 + },
339 + "node_modules/@esbuild/android-arm64": {
340 + "version": "0.21.5",
341 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
342 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
343 + "cpu": [
344 + "arm64"
345 + ],
346 + "dev": true,
347 + "license": "MIT",
348 + "optional": true,
349 + "os": [
350 + "android"
351 + ],
352 + "engines": {
353 + "node": ">=12"
354 + }
355 + },
356 + "node_modules/@esbuild/android-x64": {
357 + "version": "0.21.5",
358 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
359 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
360 + "cpu": [
361 + "x64"
362 + ],
363 + "dev": true,
364 + "license": "MIT",
365 + "optional": true,
366 + "os": [
367 + "android"
368 + ],
369 + "engines": {
370 + "node": ">=12"
371 + }
372 + },
373 + "node_modules/@esbuild/darwin-arm64": {
374 + "version": "0.21.5",
375 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
376 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
377 + "cpu": [
378 + "arm64"
379 + ],
380 + "dev": true,
381 + "license": "MIT",
382 + "optional": true,
383 + "os": [
384 + "darwin"
385 + ],
386 + "engines": {
387 + "node": ">=12"
388 + }
389 + },
390 + "node_modules/@esbuild/darwin-x64": {
391 + "version": "0.21.5",
392 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
393 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
394 + "cpu": [
395 + "x64"
396 + ],
397 + "dev": true,
398 + "license": "MIT",
399 + "optional": true,
400 + "os": [
401 + "darwin"
402 + ],
403 + "engines": {
404 + "node": ">=12"
405 + }
406 + },
407 + "node_modules/@esbuild/freebsd-arm64": {
408 + "version": "0.21.5",
409 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
410 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
411 + "cpu": [
412 + "arm64"
413 + ],
414 + "dev": true,
415 + "license": "MIT",
416 + "optional": true,
417 + "os": [
418 + "freebsd"
419 + ],
420 + "engines": {
421 + "node": ">=12"
422 + }
423 + },
424 + "node_modules/@esbuild/freebsd-x64": {
425 + "version": "0.21.5",
426 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
427 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
428 + "cpu": [
429 + "x64"
430 + ],
431 + "dev": true,
432 + "license": "MIT",
433 + "optional": true,
434 + "os": [
435 + "freebsd"
436 + ],
437 + "engines": {
438 + "node": ">=12"
439 + }
440 + },
441 + "node_modules/@esbuild/linux-arm": {
442 + "version": "0.21.5",
443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
444 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
445 + "cpu": [
446 + "arm"
447 + ],
448 + "dev": true,
449 + "license": "MIT",
450 + "optional": true,
451 + "os": [
452 + "linux"
453 + ],
454 + "engines": {
455 + "node": ">=12"
456 + }
457 + },
458 + "node_modules/@esbuild/linux-arm64": {
459 + "version": "0.21.5",
460 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
461 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
462 + "cpu": [
463 + "arm64"
464 + ],
465 + "dev": true,
466 + "license": "MIT",
467 + "optional": true,
468 + "os": [
469 + "linux"
470 + ],
471 + "engines": {
472 + "node": ">=12"
473 + }
474 + },
475 + "node_modules/@esbuild/linux-ia32": {
476 + "version": "0.21.5",
477 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
478 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
479 + "cpu": [
480 + "ia32"
481 + ],
482 + "dev": true,
483 + "license": "MIT",
484 + "optional": true,
485 + "os": [
486 + "linux"
487 + ],
488 + "engines": {
489 + "node": ">=12"
490 + }
491 + },
492 + "node_modules/@esbuild/linux-loong64": {
493 + "version": "0.21.5",
494 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
495 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
496 + "cpu": [
497 + "loong64"
498 + ],
499 + "dev": true,
500 + "license": "MIT",
501 + "optional": true,
502 + "os": [
503 + "linux"
504 + ],
505 + "engines": {
506 + "node": ">=12"
507 + }
508 + },
509 + "node_modules/@esbuild/linux-mips64el": {
510 + "version": "0.21.5",
511 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
512 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
513 + "cpu": [
514 + "mips64el"
515 + ],
516 + "dev": true,
517 + "license": "MIT",
518 + "optional": true,
519 + "os": [
520 + "linux"
521 + ],
522 + "engines": {
523 + "node": ">=12"
524 + }
525 + },
526 + "node_modules/@esbuild/linux-ppc64": {
527 + "version": "0.21.5",
528 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
529 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
530 + "cpu": [
531 + "ppc64"
532 + ],
533 + "dev": true,
534 + "license": "MIT",
535 + "optional": true,
536 + "os": [
537 + "linux"
538 + ],
539 + "engines": {
540 + "node": ">=12"
541 + }
542 + },
543 + "node_modules/@esbuild/linux-riscv64": {
544 + "version": "0.21.5",
545 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
546 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
547 + "cpu": [
548 + "riscv64"
549 + ],
550 + "dev": true,
551 + "license": "MIT",
552 + "optional": true,
553 + "os": [
554 + "linux"
555 + ],
556 + "engines": {
557 + "node": ">=12"
558 + }
559 + },
560 + "node_modules/@esbuild/linux-s390x": {
561 + "version": "0.21.5",
562 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
563 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
564 + "cpu": [
565 + "s390x"
566 + ],
567 + "dev": true,
568 + "license": "MIT",
569 + "optional": true,
570 + "os": [
571 + "linux"
572 + ],
573 + "engines": {
574 + "node": ">=12"
575 + }
576 + },
577 + "node_modules/@esbuild/linux-x64": {
578 + "version": "0.21.5",
579 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
580 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
581 + "cpu": [
582 + "x64"
583 + ],
584 + "dev": true,
585 + "license": "MIT",
586 + "optional": true,
587 + "os": [
588 + "linux"
589 + ],
590 + "engines": {
591 + "node": ">=12"
592 + }
593 + },
594 + "node_modules/@esbuild/netbsd-x64": {
595 + "version": "0.21.5",
596 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
597 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
598 + "cpu": [
599 + "x64"
600 + ],
601 + "dev": true,
602 + "license": "MIT",
603 + "optional": true,
604 + "os": [
605 + "netbsd"
606 + ],
607 + "engines": {
608 + "node": ">=12"
609 + }
610 + },
611 + "node_modules/@esbuild/openbsd-x64": {
612 + "version": "0.21.5",
613 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
614 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
615 + "cpu": [
616 + "x64"
617 + ],
618 + "dev": true,
619 + "license": "MIT",
620 + "optional": true,
621 + "os": [
622 + "openbsd"
623 + ],
624 + "engines": {
625 + "node": ">=12"
626 + }
627 + },
628 + "node_modules/@esbuild/sunos-x64": {
629 + "version": "0.21.5",
630 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
631 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
632 + "cpu": [
633 + "x64"
634 + ],
635 + "dev": true,
636 + "license": "MIT",
637 + "optional": true,
638 + "os": [
639 + "sunos"
640 + ],
641 + "engines": {
642 + "node": ">=12"
643 + }
644 + },
645 + "node_modules/@esbuild/win32-arm64": {
646 + "version": "0.21.5",
647 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
648 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
649 + "cpu": [
650 + "arm64"
651 + ],
652 + "dev": true,
653 + "license": "MIT",
654 + "optional": true,
655 + "os": [
656 + "win32"
657 + ],
658 + "engines": {
659 + "node": ">=12"
660 + }
661 + },
662 + "node_modules/@esbuild/win32-ia32": {
663 + "version": "0.21.5",
664 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
665 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
666 + "cpu": [
667 + "ia32"
668 + ],
669 + "dev": true,
670 + "license": "MIT",
671 + "optional": true,
672 + "os": [
673 + "win32"
674 + ],
675 + "engines": {
676 + "node": ">=12"
677 + }
678 + },
679 + "node_modules/@esbuild/win32-x64": {
680 + "version": "0.21.5",
681 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
682 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
683 + "cpu": [
684 + "x64"
685 + ],
686 + "dev": true,
687 + "license": "MIT",
688 + "optional": true,
689 + "os": [
690 + "win32"
691 + ],
692 + "engines": {
693 + "node": ">=12"
694 + }
695 + },
696 + "node_modules/@jridgewell/gen-mapping": {
697 + "version": "0.3.13",
698 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
699 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
700 + "dev": true,
701 + "license": "MIT",
702 + "dependencies": {
703 + "@jridgewell/sourcemap-codec": "^1.5.0",
704 + "@jridgewell/trace-mapping": "^0.3.24"
705 + }
706 + },
707 + "node_modules/@jridgewell/remapping": {
708 + "version": "2.3.5",
709 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
710 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
711 + "dev": true,
712 + "license": "MIT",
713 + "dependencies": {
714 + "@jridgewell/gen-mapping": "^0.3.5",
715 + "@jridgewell/trace-mapping": "^0.3.24"
716 + }
717 + },
718 + "node_modules/@jridgewell/resolve-uri": {
719 + "version": "3.1.2",
720 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
721 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
722 + "dev": true,
723 + "license": "MIT",
724 + "engines": {
725 + "node": ">=6.0.0"
726 + }
727 + },
728 + "node_modules/@jridgewell/sourcemap-codec": {
729 + "version": "1.5.5",
730 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
731 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
732 + "dev": true,
733 + "license": "MIT"
734 + },
735 + "node_modules/@jridgewell/trace-mapping": {
736 + "version": "0.3.31",
737 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
738 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
739 + "dev": true,
740 + "license": "MIT",
741 + "dependencies": {
742 + "@jridgewell/resolve-uri": "^3.1.0",
743 + "@jridgewell/sourcemap-codec": "^1.4.14"
744 + }
745 + },
746 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
747 + "version": "1.5.1",
748 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
749 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
750 + "cpu": [
751 + "x64"
752 + ],
753 + "dev": true,
754 + "libc": [
755 + "glibc"
756 + ],
757 + "license": "MIT",
758 + "optional": true,
759 + "os": [
760 + "linux"
761 + ],
762 + "engines": {
763 + "node": "^22.20 || ^24.12 || >=25"
764 + }
765 + },
766 + "node_modules/@remix-run/router": {
767 + "version": "1.23.3",
768 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
769 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
770 + "license": "MIT",
771 + "engines": {
772 + "node": ">=14.0.0"
773 + }
774 + },
775 + "node_modules/@rolldown/pluginutils": {
776 + "version": "1.0.0-beta.27",
777 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
778 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
779 + "dev": true,
780 + "license": "MIT"
781 + },
782 + "node_modules/@rollup/rollup-android-arm-eabi": {
783 + "version": "4.62.4",
784 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
785 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
786 + "cpu": [
787 + "arm"
788 + ],
789 + "dev": true,
790 + "license": "MIT",
791 + "optional": true,
792 + "os": [
793 + "android"
794 + ]
795 + },
796 + "node_modules/@rollup/rollup-android-arm64": {
797 + "version": "4.62.4",
798 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
799 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
800 + "cpu": [
801 + "arm64"
802 + ],
803 + "dev": true,
804 + "license": "MIT",
805 + "optional": true,
806 + "os": [
807 + "android"
808 + ]
809 + },
810 + "node_modules/@rollup/rollup-darwin-arm64": {
811 + "version": "4.62.4",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
813 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
814 + "cpu": [
815 + "arm64"
816 + ],
817 + "dev": true,
818 + "license": "MIT",
819 + "optional": true,
820 + "os": [
821 + "darwin"
822 + ]
823 + },
824 + "node_modules/@rollup/rollup-darwin-x64": {
825 + "version": "4.62.4",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
827 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
828 + "cpu": [
829 + "x64"
830 + ],
831 + "dev": true,
832 + "license": "MIT",
833 + "optional": true,
834 + "os": [
835 + "darwin"
836 + ]
837 + },
838 + "node_modules/@rollup/rollup-freebsd-arm64": {
839 + "version": "4.62.4",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
841 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
842 + "cpu": [
843 + "arm64"
844 + ],
845 + "dev": true,
846 + "license": "MIT",
847 + "optional": true,
848 + "os": [
849 + "freebsd"
850 + ]
851 + },
852 + "node_modules/@rollup/rollup-freebsd-x64": {
853 + "version": "4.62.4",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
855 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
856 + "cpu": [
857 + "x64"
858 + ],
859 + "dev": true,
860 + "license": "MIT",
861 + "optional": true,
862 + "os": [
863 + "freebsd"
864 + ]
865 + },
866 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
867 + "version": "4.62.4",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
869 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
870 + "cpu": [
871 + "arm"
872 + ],
873 + "dev": true,
874 + "libc": [
875 + "glibc"
876 + ],
877 + "license": "MIT",
878 + "optional": true,
879 + "os": [
880 + "linux"
881 + ]
882 + },
883 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
884 + "version": "4.62.4",
885 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
886 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
887 + "cpu": [
888 + "arm"
889 + ],
890 + "dev": true,
891 + "libc": [
892 + "musl"
893 + ],
894 + "license": "MIT",
895 + "optional": true,
896 + "os": [
897 + "linux"
898 + ]
899 + },
900 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
901 + "version": "4.62.4",
902 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
903 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
904 + "cpu": [
905 + "arm64"
906 + ],
907 + "dev": true,
908 + "libc": [
909 + "glibc"
910 + ],
911 + "license": "MIT",
912 + "optional": true,
913 + "os": [
914 + "linux"
915 + ]
916 + },
917 + "node_modules/@rollup/rollup-linux-arm64-musl": {
918 + "version": "4.62.4",
919 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
920 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
921 + "cpu": [
922 + "arm64"
923 + ],
924 + "dev": true,
925 + "libc": [
926 + "musl"
927 + ],
928 + "license": "MIT",
929 + "optional": true,
930 + "os": [
931 + "linux"
932 + ]
933 + },
934 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
935 + "version": "4.62.4",
936 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
937 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
938 + "cpu": [
939 + "loong64"
940 + ],
941 + "dev": true,
942 + "libc": [
943 + "glibc"
944 + ],
945 + "license": "MIT",
946 + "optional": true,
947 + "os": [
948 + "linux"
949 + ]
950 + },
951 + "node_modules/@rollup/rollup-linux-loong64-musl": {
952 + "version": "4.62.4",
953 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
954 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
955 + "cpu": [
956 + "loong64"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "musl"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ]
967 + },
968 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
969 + "version": "4.62.4",
970 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
971 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
972 + "cpu": [
973 + "ppc64"
974 + ],
975 + "dev": true,
976 + "libc": [
977 + "glibc"
978 + ],
979 + "license": "MIT",
980 + "optional": true,
981 + "os": [
982 + "linux"
983 + ]
984 + },
985 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
986 + "version": "4.62.4",
987 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
988 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
989 + "cpu": [
990 + "ppc64"
991 + ],
992 + "dev": true,
993 + "libc": [
994 + "musl"
995 + ],
996 + "license": "MIT",
997 + "optional": true,
998 + "os": [
999 + "linux"
1000 + ]
1001 + },
1002 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1003 + "version": "4.62.4",
1004 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1005 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1006 + "cpu": [
1007 + "riscv64"
1008 + ],
1009 + "dev": true,
1010 + "libc": [
1011 + "glibc"
1012 + ],
1013 + "license": "MIT",
1014 + "optional": true,
1015 + "os": [
1016 + "linux"
1017 + ]
1018 + },
1019 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1020 + "version": "4.62.4",
1021 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1022 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1023 + "cpu": [
1024 + "riscv64"
1025 + ],
1026 + "dev": true,
1027 + "libc": [
1028 + "musl"
1029 + ],
1030 + "license": "MIT",
1031 + "optional": true,
1032 + "os": [
1033 + "linux"
1034 + ]
1035 + },
1036 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1037 + "version": "4.62.4",
1038 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1039 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1040 + "cpu": [
1041 + "s390x"
1042 + ],
1043 + "dev": true,
1044 + "libc": [
1045 + "glibc"
1046 + ],
1047 + "license": "MIT",
1048 + "optional": true,
1049 + "os": [
1050 + "linux"
1051 + ]
1052 + },
1053 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1054 + "version": "4.62.4",
1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1056 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1057 + "cpu": [
1058 + "x64"
1059 + ],
1060 + "dev": true,
1061 + "libc": [
1062 + "glibc"
1063 + ],
1064 + "license": "MIT",
1065 + "optional": true,
1066 + "os": [
1067 + "linux"
1068 + ]
1069 + },
1070 + "node_modules/@rollup/rollup-linux-x64-musl": {
1071 + "version": "4.62.4",
1072 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1073 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1074 + "cpu": [
1075 + "x64"
1076 + ],
1077 + "dev": true,
1078 + "libc": [
1079 + "musl"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "linux"
1085 + ]
1086 + },
1087 + "node_modules/@rollup/rollup-openbsd-x64": {
1088 + "version": "4.62.4",
1089 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1090 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1091 + "cpu": [
1092 + "x64"
1093 + ],
1094 + "dev": true,
1095 + "license": "MIT",
1096 + "optional": true,
1097 + "os": [
1098 + "openbsd"
1099 + ]
1100 + },
1101 + "node_modules/@rollup/rollup-openharmony-arm64": {
1102 + "version": "4.62.4",
1103 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1104 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1105 + "cpu": [
1106 + "arm64"
1107 + ],
1108 + "dev": true,
1109 + "license": "MIT",
1110 + "optional": true,
1111 + "os": [
1112 + "openharmony"
1113 + ]
1114 + },
1115 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1116 + "version": "4.62.4",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1118 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1119 + "cpu": [
1120 + "arm64"
1121 + ],
1122 + "dev": true,
1123 + "license": "MIT",
1124 + "optional": true,
1125 + "os": [
1126 + "win32"
1127 + ]
1128 + },
1129 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1130 + "version": "4.62.4",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1132 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1133 + "cpu": [
1134 + "ia32"
1135 + ],
1136 + "dev": true,
1137 + "license": "MIT",
1138 + "optional": true,
1139 + "os": [
1140 + "win32"
1141 + ]
1142 + },
1143 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1144 + "version": "4.62.4",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1146 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1147 + "cpu": [
1148 + "x64"
1149 + ],
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "optional": true,
1153 + "os": [
1154 + "win32"
1155 + ]
1156 + },
1157 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1158 + "version": "4.62.4",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1160 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1161 + "cpu": [
1162 + "x64"
1163 + ],
1164 + "dev": true,
1165 + "license": "MIT",
1166 + "optional": true,
1167 + "os": [
1168 + "win32"
1169 + ]
1170 + },
1171 + "node_modules/@types/babel__core": {
1172 + "version": "7.20.5",
1173 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1174 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1175 + "dev": true,
1176 + "license": "MIT",
1177 + "dependencies": {
1178 + "@babel/parser": "^7.20.7",
1179 + "@babel/types": "^7.20.7",
1180 + "@types/babel__generator": "*",
1181 + "@types/babel__template": "*",
1182 + "@types/babel__traverse": "*"
1183 + }
1184 + },
1185 + "node_modules/@types/babel__generator": {
1186 + "version": "7.27.0",
1187 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1188 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1189 + "dev": true,
1190 + "license": "MIT",
1191 + "dependencies": {
1192 + "@babel/types": "^7.0.0"
1193 + }
1194 + },
1195 + "node_modules/@types/babel__template": {
1196 + "version": "7.4.4",
1197 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1198 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1199 + "dev": true,
1200 + "license": "MIT",
1201 + "dependencies": {
1202 + "@babel/parser": "^7.1.0",
1203 + "@babel/types": "^7.0.0"
1204 + }
1205 + },
1206 + "node_modules/@types/babel__traverse": {
1207 + "version": "7.28.0",
1208 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1209 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1210 + "dev": true,
1211 + "license": "MIT",
1212 + "dependencies": {
1213 + "@babel/types": "^7.28.2"
1214 + }
1215 + },
1216 + "node_modules/@types/estree": {
1217 + "version": "1.0.9",
1218 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1219 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1220 + "dev": true,
1221 + "license": "MIT"
1222 + },
1223 + "node_modules/@types/prop-types": {
1224 + "version": "15.7.15",
1225 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1226 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1227 + "dev": true,
1228 + "license": "MIT"
1229 + },
1230 + "node_modules/@types/react": {
1231 + "version": "18.3.31",
1232 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1233 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1234 + "dev": true,
1235 + "license": "MIT",
1236 + "dependencies": {
1237 + "@types/prop-types": "*",
1238 + "csstype": "^3.2.2"
1239 + }
1240 + },
1241 + "node_modules/@types/react-dom": {
1242 + "version": "18.3.7",
1243 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1244 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1245 + "dev": true,
1246 + "license": "MIT",
1247 + "peerDependencies": {
1248 + "@types/react": "^18.0.0"
1249 + }
1250 + },
1251 + "node_modules/@vitejs/plugin-react": {
1252 + "version": "4.7.0",
1253 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1254 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1255 + "dev": true,
1256 + "license": "MIT",
1257 + "dependencies": {
1258 + "@babel/core": "^7.28.0",
1259 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1260 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1261 + "@rolldown/pluginutils": "1.0.0-beta.27",
1262 + "@types/babel__core": "^7.20.5",
1263 + "react-refresh": "^0.17.0"
1264 + },
1265 + "engines": {
1266 + "node": "^14.18.0 || >=16.0.0"
1267 + },
1268 + "peerDependencies": {
1269 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1270 + }
1271 + },
1272 + "node_modules/baseline-browser-mapping": {
1273 + "version": "2.11.13",
1274 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
1275 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
1276 + "dev": true,
1277 + "license": "Apache-2.0",
1278 + "bin": {
1279 + "baseline-browser-mapping": "dist/cli.cjs"
1280 + },
1281 + "engines": {
1282 + "node": ">=6.0.0"
1283 + }
1284 + },
1285 + "node_modules/browserslist": {
1286 + "version": "4.28.8",
1287 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1288 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1289 + "dev": true,
1290 + "funding": [
1291 + {
1292 + "type": "opencollective",
1293 + "url": "https://opencollective.com/browserslist"
1294 + },
1295 + {
1296 + "type": "tidelift",
1297 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1298 + },
1299 + {
1300 + "type": "github",
1301 + "url": "https://github.com/sponsors/ai"
1302 + }
1303 + ],
1304 + "license": "MIT",
1305 + "dependencies": {
1306 + "baseline-browser-mapping": "^2.11.12",
1307 + "caniuse-lite": "^1.0.30001809",
1308 + "electron-to-chromium": "^1.5.402",
1309 + "node-releases": "^2.0.53",
1310 + "update-browserslist-db": "^1.3.0"
1311 + },
1312 + "bin": {
1313 + "browserslist": "cli.js"
1314 + },
1315 + "engines": {
1316 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1317 + }
1318 + },
1319 + "node_modules/caniuse-lite": {
1320 + "version": "1.0.30001809",
1321 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1322 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1323 + "dev": true,
1324 + "funding": [
1325 + {
1326 + "type": "opencollective",
1327 + "url": "https://opencollective.com/browserslist"
1328 + },
1329 + {
1330 + "type": "tidelift",
1331 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1332 + },
1333 + {
1334 + "type": "github",
1335 + "url": "https://github.com/sponsors/ai"
1336 + }
1337 + ],
1338 + "license": "CC-BY-4.0"
1339 + },
1340 + "node_modules/convert-source-map": {
1341 + "version": "2.0.0",
1342 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1343 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1344 + "dev": true,
1345 + "license": "MIT"
1346 + },
1347 + "node_modules/csstype": {
1348 + "version": "3.2.3",
1349 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1350 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1351 + "dev": true,
1352 + "license": "MIT"
1353 + },
1354 + "node_modules/debug": {
1355 + "version": "4.4.3",
1356 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1357 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1358 + "dev": true,
1359 + "license": "MIT",
1360 + "dependencies": {
1361 + "ms": "^2.1.3"
1362 + },
1363 + "engines": {
1364 + "node": ">=6.0"
1365 + },
1366 + "peerDependenciesMeta": {
1367 + "supports-color": {
1368 + "optional": true
1369 + }
1370 + }
1371 + },
1372 + "node_modules/electron-to-chromium": {
1373 + "version": "1.5.405",
1374 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
1375 + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
1376 + "dev": true,
1377 + "license": "ISC"
1378 + },
1379 + "node_modules/esbuild": {
1380 + "version": "0.21.5",
1381 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1382 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1383 + "dev": true,
1384 + "hasInstallScript": true,
1385 + "license": "MIT",
1386 + "bin": {
1387 + "esbuild": "bin/esbuild"
1388 + },
1389 + "engines": {
1390 + "node": ">=12"
1391 + },
1392 + "optionalDependencies": {
1393 + "@esbuild/aix-ppc64": "0.21.5",
1394 + "@esbuild/android-arm": "0.21.5",
1395 + "@esbuild/android-arm64": "0.21.5",
1396 + "@esbuild/android-x64": "0.21.5",
1397 + "@esbuild/darwin-arm64": "0.21.5",
1398 + "@esbuild/darwin-x64": "0.21.5",
1399 + "@esbuild/freebsd-arm64": "0.21.5",
1400 + "@esbuild/freebsd-x64": "0.21.5",
1401 + "@esbuild/linux-arm": "0.21.5",
1402 + "@esbuild/linux-arm64": "0.21.5",
1403 + "@esbuild/linux-ia32": "0.21.5",
1404 + "@esbuild/linux-loong64": "0.21.5",
1405 + "@esbuild/linux-mips64el": "0.21.5",
1406 + "@esbuild/linux-ppc64": "0.21.5",
1407 + "@esbuild/linux-riscv64": "0.21.5",
1408 + "@esbuild/linux-s390x": "0.21.5",
1409 + "@esbuild/linux-x64": "0.21.5",
1410 + "@esbuild/netbsd-x64": "0.21.5",
1411 + "@esbuild/openbsd-x64": "0.21.5",
1412 + "@esbuild/sunos-x64": "0.21.5",
1413 + "@esbuild/win32-arm64": "0.21.5",
1414 + "@esbuild/win32-ia32": "0.21.5",
1415 + "@esbuild/win32-x64": "0.21.5"
1416 + }
1417 + },
1418 + "node_modules/escalade": {
1419 + "version": "3.2.0",
1420 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1421 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1422 + "dev": true,
1423 + "license": "MIT",
1424 + "engines": {
1425 + "node": ">=6"
1426 + }
1427 + },
1428 + "node_modules/fsevents": {
1429 + "version": "2.3.3",
1430 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1431 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1432 + "dev": true,
1433 + "hasInstallScript": true,
1434 + "license": "MIT",
1435 + "optional": true,
1436 + "os": [
1437 + "darwin"
1438 + ],
1439 + "engines": {
1440 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1441 + }
1442 + },
1443 + "node_modules/gensync": {
1444 + "version": "1.0.0-beta.2",
1445 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1446 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1447 + "dev": true,
1448 + "license": "MIT",
1449 + "engines": {
1450 + "node": ">=6.9.0"
1451 + }
1452 + },
1453 + "node_modules/js-tokens": {
1454 + "version": "4.0.0",
1455 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1456 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1457 + "license": "MIT"
1458 + },
1459 + "node_modules/jsesc": {
1460 + "version": "3.1.0",
1461 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1462 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1463 + "dev": true,
1464 + "license": "MIT",
1465 + "bin": {
1466 + "jsesc": "bin/jsesc"
1467 + },
1468 + "engines": {
1469 + "node": ">=6"
1470 + }
1471 + },
1472 + "node_modules/json5": {
1473 + "version": "2.2.3",
1474 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1475 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1476 + "dev": true,
1477 + "license": "MIT",
1478 + "bin": {
1479 + "json5": "lib/cli.js"
1480 + },
1481 + "engines": {
1482 + "node": ">=6"
1483 + }
1484 + },
1485 + "node_modules/loose-envify": {
1486 + "version": "1.4.0",
1487 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1488 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1489 + "license": "MIT",
1490 + "dependencies": {
1491 + "js-tokens": "^3.0.0 || ^4.0.0"
1492 + },
1493 + "bin": {
1494 + "loose-envify": "cli.js"
1495 + }
1496 + },
1497 + "node_modules/lru-cache": {
1498 + "version": "5.1.1",
1499 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1500 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1501 + "dev": true,
1502 + "license": "ISC",
1503 + "dependencies": {
1504 + "yallist": "^3.0.2"
1505 + }
1506 + },
1507 + "node_modules/ms": {
1508 + "version": "2.1.3",
1509 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1510 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1511 + "dev": true,
1512 + "license": "MIT"
1513 + },
1514 + "node_modules/nanoid": {
1515 + "version": "3.3.18",
1516 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1517 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1518 + "dev": true,
1519 + "funding": [
1520 + {
1521 + "type": "github",
1522 + "url": "https://github.com/sponsors/ai"
1523 + }
1524 + ],
1525 + "license": "MIT",
1526 + "bin": {
1527 + "nanoid": "bin/nanoid.cjs"
1528 + },
1529 + "engines": {
1530 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1531 + }
1532 + },
1533 + "node_modules/node-releases": {
1534 + "version": "2.0.53",
1535 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1536 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1537 + "dev": true,
1538 + "license": "MIT",
1539 + "engines": {
1540 + "node": ">=18"
1541 + }
1542 + },
1543 + "node_modules/picocolors": {
1544 + "version": "1.1.1",
1545 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1546 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1547 + "dev": true,
1548 + "license": "ISC"
1549 + },
1550 + "node_modules/postcss": {
1551 + "version": "8.5.26",
1552 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1553 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1554 + "dev": true,
1555 + "funding": [
1556 + {
1557 + "type": "opencollective",
1558 + "url": "https://opencollective.com/postcss/"
1559 + },
1560 + {
1561 + "type": "tidelift",
1562 + "url": "https://tidelift.com/funding/github/npm/postcss"
1563 + },
1564 + {
1565 + "type": "github",
1566 + "url": "https://github.com/sponsors/ai"
1567 + }
1568 + ],
1569 + "license": "MIT",
1570 + "dependencies": {
1571 + "nanoid": "^3.3.17",
1572 + "picocolors": "^1.1.1",
1573 + "source-map-js": "^1.2.1"
1574 + },
1575 + "engines": {
1576 + "node": "^10 || ^12 || >=14"
1577 + }
1578 + },
1579 + "node_modules/react": {
1580 + "version": "18.3.1",
1581 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1582 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1583 + "license": "MIT",
1584 + "dependencies": {
1585 + "loose-envify": "^1.1.0"
1586 + },
1587 + "engines": {
1588 + "node": ">=0.10.0"
1589 + }
1590 + },
1591 + "node_modules/react-dom": {
1592 + "version": "18.3.1",
1593 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1594 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1595 + "license": "MIT",
1596 + "dependencies": {
1597 + "loose-envify": "^1.1.0",
1598 + "scheduler": "^0.23.2"
1599 + },
1600 + "peerDependencies": {
1601 + "react": "^18.3.1"
1602 + }
1603 + },
1604 + "node_modules/react-refresh": {
1605 + "version": "0.17.0",
1606 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1607 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1608 + "dev": true,
1609 + "license": "MIT",
1610 + "engines": {
1611 + "node": ">=0.10.0"
1612 + }
1613 + },
1614 + "node_modules/react-router": {
1615 + "version": "6.30.4",
1616 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1617 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1618 + "license": "MIT",
1619 + "dependencies": {
1620 + "@remix-run/router": "1.23.3"
1621 + },
1622 + "engines": {
1623 + "node": ">=14.0.0"
1624 + },
1625 + "peerDependencies": {
1626 + "react": ">=16.8"
1627 + }
1628 + },
1629 + "node_modules/react-router-dom": {
1630 + "version": "6.30.4",
1631 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1632 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1633 + "license": "MIT",
1634 + "dependencies": {
1635 + "@remix-run/router": "1.23.3",
1636 + "react-router": "6.30.4"
1637 + },
1638 + "engines": {
1639 + "node": ">=14.0.0"
1640 + },
1641 + "peerDependencies": {
1642 + "react": ">=16.8",
1643 + "react-dom": ">=16.8"
1644 + }
1645 + },
1646 + "node_modules/rollup": {
1647 + "version": "4.62.4",
1648 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1649 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1650 + "dev": true,
1651 + "license": "MIT",
1652 + "dependencies": {
1653 + "@types/estree": "1.0.9"
1654 + },
1655 + "bin": {
1656 + "rollup": "dist/bin/rollup"
1657 + },
1658 + "engines": {
1659 + "node": ">=18.0.0",
1660 + "npm": ">=8.0.0"
1661 + },
1662 + "optionalDependencies": {
1663 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1664 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1665 + "@rollup/rollup-android-arm64": "4.62.4",
1666 + "@rollup/rollup-darwin-arm64": "4.62.4",
1667 + "@rollup/rollup-darwin-x64": "4.62.4",
1668 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1669 + "@rollup/rollup-freebsd-x64": "4.62.4",
1670 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1671 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1672 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1673 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1674 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1675 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1676 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1677 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1678 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1679 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1680 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1681 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1682 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1683 + "@rollup/rollup-openbsd-x64": "4.62.4",
1684 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1685 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1686 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1687 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1688 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1689 + "fsevents": "~2.3.2"
1690 + }
1691 + },
1692 + "node_modules/scheduler": {
1693 + "version": "0.23.2",
1694 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1695 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1696 + "license": "MIT",
1697 + "dependencies": {
1698 + "loose-envify": "^1.1.0"
1699 + }
1700 + },
1701 + "node_modules/semver": {
1702 + "version": "6.3.1",
1703 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1704 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1705 + "dev": true,
1706 + "license": "ISC",
1707 + "bin": {
1708 + "semver": "bin/semver.js"
1709 + }
1710 + },
1711 + "node_modules/source-map-js": {
1712 + "version": "1.2.1",
1713 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1714 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1715 + "dev": true,
1716 + "license": "BSD-3-Clause",
1717 + "engines": {
1718 + "node": ">=0.10.0"
1719 + }
1720 + },
1721 + "node_modules/typescript": {
1722 + "version": "5.9.3",
1723 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1724 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1725 + "dev": true,
1726 + "license": "Apache-2.0",
1727 + "bin": {
1728 + "tsc": "bin/tsc",
1729 + "tsserver": "bin/tsserver"
1730 + },
1731 + "engines": {
1732 + "node": ">=14.17"
1733 + }
1734 + },
1735 + "node_modules/update-browserslist-db": {
1736 + "version": "1.3.1",
1737 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
1738 + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
1739 + "dev": true,
1740 + "funding": [
1741 + {
1742 + "type": "opencollective",
1743 + "url": "https://opencollective.com/browserslist"
1744 + },
1745 + {
1746 + "type": "tidelift",
1747 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1748 + },
1749 + {
1750 + "type": "github",
1751 + "url": "https://github.com/sponsors/ai"
1752 + }
1753 + ],
1754 + "license": "MIT",
1755 + "dependencies": {
1756 + "escalade": "^3.2.0",
1757 + "picocolors": "^1.1.1"
1758 + },
1759 + "bin": {
1760 + "update-browserslist-db": "cli.js"
1761 + },
1762 + "peerDependencies": {
1763 + "browserslist": ">= 4.21.0"
1764 + }
1765 + },
1766 + "node_modules/vite": {
1767 + "version": "5.4.21",
1768 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1769 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1770 + "dev": true,
1771 + "license": "MIT",
1772 + "dependencies": {
1773 + "esbuild": "^0.21.3",
1774 + "postcss": "^8.4.43",
1775 + "rollup": "^4.20.0"
1776 + },
1777 + "bin": {
1778 + "vite": "bin/vite.js"
1779 + },
1780 + "engines": {
1781 + "node": "^18.0.0 || >=20.0.0"
1782 + },
1783 + "funding": {
1784 + "url": "https://github.com/vitejs/vite?sponsor=1"
1785 + },
1786 + "optionalDependencies": {
1787 + "fsevents": "~2.3.3"
1788 + },
1789 + "peerDependencies": {
1790 + "@types/node": "^18.0.0 || >=20.0.0",
1791 + "less": "*",
1792 + "lightningcss": "^1.21.0",
1793 + "sass": "*",
1794 + "sass-embedded": "*",
1795 + "stylus": "*",
1796 + "sugarss": "*",
1797 + "terser": "^5.4.0"
1798 + },
1799 + "peerDependenciesMeta": {
1800 + "@types/node": {
1801 + "optional": true
1802 + },
1803 + "less": {
1804 + "optional": true
1805 + },
1806 + "lightningcss": {
1807 + "optional": true
1808 + },
1809 + "sass": {
1810 + "optional": true
1811 + },
1812 + "sass-embedded": {
1813 + "optional": true
1814 + },
1815 + "stylus": {
1816 + "optional": true
1817 + },
1818 + "sugarss": {
1819 + "optional": true
1820 + },
1821 + "terser": {
1822 + "optional": true
1823 + }
1824 + }
1825 + },
1826 + "node_modules/yallist": {
1827 + "version": "3.1.1",
1828 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1829 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1830 + "dev": true,
1831 + "license": "ISC"
1832 + }
1833 + }
1834 +}
added frontend/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "auto-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/react": "^18.3.3",
19 + "@types/react-dom": "^18.3.0",
20 + "@vitejs/plugin-react": "^4.3.1",
21 + "typescript": "^5.5.4",
22 + "vite": "^5.4.0"
23 + }
24 +}
added frontend/src/App.tsx +124 −0
@@ -0,0 +1,124 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { NavLink, Route, Routes } from "react-router-dom";
8 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames } from "./api";
9 +import Home from "./pages/Home";
10 +import VehiclePage from "./pages/Vehicle";
11 +import SourcesPage from "./pages/Sources";
12 +import StatsPage from "./pages/Stats";
13 +
14 +function Ticker() {
15 + const [items, setItems] = useState<string[]>([]);
16 +
17 + useEffect(() => {
18 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
19 + .then(([stats, facets, src]) => {
20 + registerSourceNames(src.sources);
21 + const parts: string[] = [
22 + `${stats.total.toLocaleString("fr-CA")} véhicules à vendre`,
23 + `${stats.sources} concessionnaires connectés`,
24 + ];
25 + if (stats.avg_price != null)
26 + parts.push(`Prix moyen ${Math.round(stats.avg_price).toLocaleString("fr-CA")} $`);
27 + for (const r of stats.by_region.slice(0, 8))
28 + parts.push(`${r.region} · ${r.n}`);
29 + for (const m of facets.makes.slice(0, 8))
30 + parts.push(`${m.make} · ${m.n}`);
31 + parts.push("Directement des sites des concessionnaires");
32 + setItems(parts);
33 + })
34 + .catch(() => setItems(["Auto-Ka — voitures usagées à vendre au Québec"]));
35 + }, []);
36 +
37 + if (items.length === 0) return null;
38 + // contenu doublé pour une boucle de défilement continue
39 + return (
40 + <div className="ticker" aria-hidden="true">
41 + <div className="ticker-track">
42 + {[...items, ...items].map((t, i) => (
43 + <span key={i}>{t}</span>
44 + ))}
45 + </div>
46 + </div>
47 + );
48 +}
49 +
50 +function Header() {
51 + return (
52 + <>
53 + <header className="header">
54 + <div className="container header-inner">
55 + <NavLink to="/" className="brand" aria-label="Auto-Ka — accueil">
56 + Auto<span className="ka">Ka</span>
57 + <span className="brand-tag">Voitures usagées · tout le Québec · toujours à jour</span>
58 + </NavLink>
59 + <nav className="nav" aria-label="Navigation principale">
60 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
61 + Véhicules
62 + </NavLink>
63 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
64 + Stats
65 + </NavLink>
66 + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
67 + Sources
68 + </NavLink>
69 + </nav>
70 + </div>
71 + </header>
72 + <Ticker />
73 + </>
74 + );
75 +}
76 +
77 +function Footer() {
78 + return (
79 + <footer className="footer">
80 + <div className="container">
81 + <div className="fbrand">
82 + Auto<span className="ka">Ka</span>
83 + </div>
84 + <div className="frow">
85 + <b>Agrégateur indépendant</b> de voitures usagées à vendre dans la
86 + province de Québec. Les annonces proviennent directement des sites
87 + publics des concessionnaires — pas des plateformes d'annonces — et
88 + sont rafraîchies automatiquement. Chaque fiche renvoie vers l'annonce
89 + originale du commerçant.
90 + </div>
91 + <div className="fmono">
92 + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai
93 + </div>
94 + </div>
95 + </footer>
96 + );
97 +}
98 +
99 +export default function App() {
100 + return (
101 + <>
102 + <Header />
103 + <main>
104 + <Routes>
105 + <Route path="/" element={<Home />} />
106 + <Route path="/vehicule/:uid" element={<VehiclePage />} />
107 + <Route path="/stats" element={<StatsPage />} />
108 + <Route path="/sources" element={<SourcesPage />} />
109 + <Route
110 + path="*"
111 + element={
112 + <div className="notice container">
113 + <div className="big">🧭</div>
114 + <h2>Page introuvable</h2>
115 + <p>Le lien demandé n'existe pas.</p>
116 + </div>
117 + }
118 + />
119 + </Routes>
120 + </main>
121 + <Footer />
122 + </>
123 + );
124 +}
added frontend/src/api.ts +171 −0
@@ -0,0 +1,171 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts : client typé de l'API FastAPI
5 +// -----------------------------------------------------------------------------
6 +
7 +export interface Vehicle {
8 + uid: string;
9 + source: string;
10 + external_id: string;
11 + url: string;
12 + title: string;
13 + make: string;
14 + model: string;
15 + trim: string;
16 + year: number | null;
17 + price: number | null;
18 + price_label: string;
19 + mileage_km: number | null;
20 + mileage_label: string;
21 + transmission: string;
22 + fuel: string;
23 + drivetrain: string;
24 + body_type: string;
25 + exterior_color: string;
26 + interior_color: string;
27 + engine: string;
28 + doors: number | null;
29 + seats: number | null;
30 + vin: string;
31 + stock_number: string;
32 + dealer_name: string;
33 + city: string;
34 + region: string;
35 + description: string;
36 + features: string[];
37 + details: Record<string, unknown>;
38 + images: string[];
39 + carfax_url: string;
40 + first_seen: number;
41 + updated_at: number;
42 + active: number;
43 +}
44 +
45 +export interface VehicleDetail extends Vehicle {
46 + price_history: { ts: number; price: number | null }[];
47 + similar: Vehicle[];
48 +}
49 +
50 +export interface Facets {
51 + makes: { make: string; n: number }[];
52 + models: { model: string; n: number }[];
53 + body_types: string[];
54 + fuels: string[];
55 + regions: { region: string; n: number }[];
56 + sources: { source: string; dealer_name: string; n: number }[];
57 + years: { y_min: number | null; y_max: number | null }[];
58 +}
59 +
60 +export interface Source {
61 + id: string;
62 + name: string;
63 + url: string;
64 + listing_url?: string;
65 + city?: string;
66 + region?: string;
67 + platform?: string;
68 + status?: string;
69 + active_listings: number;
70 + last_sync: number | null;
71 +}
72 +
73 +export interface Stats {
74 + total: number;
75 + sources: number;
76 + regions: number;
77 + avg_price: number | null;
78 + avg_km: number | null;
79 + avg_year: number | null;
80 + by_region: { region: string; n: number; avg_price: number | null }[];
81 + by_make: { make: string; n: number; avg_price: number | null }[];
82 + by_body: { body_type: string; n: number }[];
83 + price_drops: {
84 + uid: string; title: string; year: number | null; price: number;
85 + prev_price: number; images: string[]; dealer_name: string; city: string;
86 + }[];
87 + recent_syncs: {
88 + source: string; ts: number; found: number; added: number;
89 + updated: number; removed: number; ok: number; message: string;
90 + }[];
91 +}
92 +
93 +export interface VehicleQuery {
94 + make?: string;
95 + model?: string;
96 + body_type?: string;
97 + fuel?: string;
98 + transmission?: string;
99 + drivetrain?: string;
100 + region?: string;
101 + source?: string;
102 + year_min?: number;
103 + year_max?: number;
104 + price_min?: number;
105 + price_max?: number;
106 + km_max?: number;
107 + q?: string;
108 + sort?: string;
109 + limit?: number;
110 + offset?: number;
111 +}
112 +
113 +async function get<T>(path: string): Promise<T> {
114 + const res = await fetch(path);
115 + if (!res.ok) throw new Error(`API ${res.status}`);
116 + return res.json();
117 +}
118 +
119 +export function fetchVehicles(query: VehicleQuery) {
120 + const params = new URLSearchParams();
121 + for (const [k, v] of Object.entries(query)) {
122 + if (v !== undefined && v !== null && v !== "") params.set(k, String(v));
123 + }
124 + return get<{ total: number; count: number; vehicles: Vehicle[] }>(
125 + `/api/vehicles?${params.toString()}`
126 + );
127 +}
128 +
129 +export function fetchVehicle(uid: string) {
130 + return get<VehicleDetail>(`/api/vehicles/${encodeURIComponent(uid)}`);
131 +}
132 +
133 +export function fetchFacets(make?: string) {
134 + const qs = make ? `?make=${encodeURIComponent(make)}` : "";
135 + return get<Facets>(`/api/facets${qs}`);
136 +}
137 +
138 +export function fetchSources() {
139 + return get<{ sources: Source[] }>("/api/sources");
140 +}
141 +
142 +export function fetchStats() {
143 + return get<Stats>("/api/stats");
144 +}
145 +
146 +// -- noms d'affichage des sources (peuplés depuis /api/sources) ---------------
147 +const names: Record<string, string> = {};
148 +
149 +export function registerSourceNames(sources: Source[]) {
150 + for (const s of sources) names[s.id] = s.name;
151 +}
152 +
153 +export function sourceName(id: string): string {
154 + return names[id] ?? id;
155 +}
156 +
157 +// -- formatteurs ---------------------------------------------------------------
158 +export const fmtPrice = (p: number | null | undefined) =>
159 + p == null ? "Prix sur demande" : `${Math.round(p).toLocaleString("fr-CA")} $`;
160 +
161 +export const fmtKm = (km: number | null | undefined) =>
162 + km == null ? "— km" : `${Math.round(km).toLocaleString("fr-CA")} km`;
163 +
164 +export const fmtDate = (ts: number | null | undefined) =>
165 + ts == null
166 + ? "—"
167 + : new Date(ts * 1000).toLocaleDateString("fr-CA", {
168 + day: "numeric",
169 + month: "short",
170 + year: "numeric",
171 + });
added frontend/src/components/VehicleCard.tsx +46 −0
@@ -0,0 +1,46 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// VehicleCard.tsx : carte véhicule de la grille de résultats
5 +// -----------------------------------------------------------------------------
6 +import { Link } from "react-router-dom";
7 +import { Vehicle, fmtKm, fmtPrice } from "../api";
8 +
9 +export default function VehicleCard({ v }: { v: Vehicle }) {
10 + const chips = [
11 + v.mileage_km != null ? fmtKm(v.mileage_km) : null,
12 + v.transmission || null,
13 + v.fuel || null,
14 + v.body_type || null,
15 + v.drivetrain || null,
16 + ].filter(Boolean) as string[];
17 +
18 + return (
19 + <Link to={`/vehicule/${encodeURIComponent(v.uid)}`} className="vcard">
20 + <div className="photo">
21 + {v.images.length > 0 ? (
22 + <img src={v.images[0]} alt={v.title} loading="lazy" />
23 + ) : (
24 + <div className="nopic" aria-hidden="true">🚗</div>
25 + )}
26 + {v.year != null && <span className="year-chip">{v.year}</span>}
27 + </div>
28 + <div className="body">
29 + <h3>{v.title}</h3>
30 + <div className="price">
31 + {fmtPrice(v.price)}
32 + {v.details?.price_from ? <small> à partir de</small> : null}
33 + </div>
34 + <div className="specs">
35 + {chips.slice(0, 4).map((c) => (
36 + <span key={c} className="spec-chip">{c}</span>
37 + ))}
38 + </div>
39 + <div className="foot">
40 + <span>{v.dealer_name || v.source}</span>
41 + <span>{v.city}</span>
42 + </div>
43 + </div>
44 + </Link>
45 + );
46 +}
added frontend/src/main.tsx +17 −0
@@ -0,0 +1,17 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// -----------------------------------------------------------------------------
5 +import React from "react";
6 +import ReactDOM from "react-dom/client";
7 +import { BrowserRouter } from "react-router-dom";
8 +import App from "./App";
9 +import "./styles.css";
10 +
11 +ReactDOM.createRoot(document.getElementById("root")!).render(
12 + <React.StrictMode>
13 + <BrowserRouter>
14 + <App />
15 + </BrowserRouter>
16 + </React.StrictMode>
17 +);
added frontend/src/pages/Home.tsx +263 −0
@@ -0,0 +1,263 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Home.tsx : recherche — hero, filtres auto, grille de véhicules, pagination
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useMemo, useState } from "react";
7 +import { useSearchParams } from "react-router-dom";
8 +import {
9 + Facets, Vehicle, VehicleQuery, fetchFacets, fetchStats, fetchVehicles,
10 +} from "../api";
11 +import VehicleCard from "../components/VehicleCard";
12 +
13 +const PAGE_SIZE = 24;
14 +
15 +const SORTS = [
16 + { v: "price_asc", label: "Prix croissant" },
17 + { v: "price_desc", label: "Prix décroissant" },
18 + { v: "km_asc", label: "Kilométrage croissant" },
19 + { v: "year_desc", label: "Année récente" },
20 + { v: "recent", label: "Arrivages récents" },
21 +];
22 +
23 +const PRICE_STEPS = [5000, 10000, 15000, 20000, 25000, 30000, 40000, 50000, 75000, 100000];
24 +const KM_STEPS = [20000, 40000, 60000, 80000, 100000, 130000, 160000, 200000];
25 +
26 +export default function Home() {
27 + const [params, setParams] = useSearchParams();
28 + const [facets, setFacets] = useState<Facets | null>(null);
29 + const [vehicles, setVehicles] = useState<Vehicle[]>([]);
30 + const [total, setTotal] = useState(0);
31 + const [loading, setLoading] = useState(true);
32 + const [heroStats, setHeroStats] = useState<{ total: number; sources: number; regions: number } | null>(null);
33 +
34 + const query: VehicleQuery = useMemo(() => {
35 + const g = (k: string) => params.get(k) || undefined;
36 + const gn = (k: string) => (params.get(k) ? Number(params.get(k)) : undefined);
37 + return {
38 + make: g("make"), model: g("model"), body_type: g("body"),
39 + fuel: g("fuel"), transmission: g("trans"), region: g("region"),
40 + source: g("source"), year_min: gn("ymin"), year_max: gn("ymax"),
41 + price_max: gn("pmax"), km_max: gn("kmax"), q: g("q"),
42 + sort: g("sort") || "price_asc",
43 + limit: PAGE_SIZE,
44 + offset: (Math.max(1, gn("page") || 1) - 1) * PAGE_SIZE,
45 + };
46 + }, [params]);
47 +
48 + const page = Math.max(1, Number(params.get("page") || 1));
49 + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
50 +
51 + useEffect(() => {
52 + fetchFacets(params.get("make") || undefined).then(setFacets).catch(() => {});
53 + }, [params.get("make")]);
54 +
55 + useEffect(() => {
56 + fetchStats()
57 + .then((s) => setHeroStats({ total: s.total, sources: s.sources, regions: s.regions }))
58 + .catch(() => {});
59 + }, []);
60 +
61 + useEffect(() => {
62 + setLoading(true);
63 + fetchVehicles(query)
64 + .then((r) => { setVehicles(r.vehicles); setTotal(r.total); })
65 + .catch(() => { setVehicles([]); setTotal(0); })
66 + .finally(() => setLoading(false));
67 + }, [query]);
68 +
69 + const setFilter = (key: string, value: string) => {
70 + const next = new URLSearchParams(params);
71 + if (value) next.set(key, value);
72 + else next.delete(key);
73 + if (key !== "page") next.delete("page");
74 + if (key === "make") next.delete("model"); // le modèle dépend de la marque
75 + setParams(next, { replace: false });
76 + };
77 +
78 + const clearAll = () => setParams(new URLSearchParams(), { replace: false });
79 +
80 + const years: number[] = [];
81 + const yb = facets?.years?.[0];
82 + if (yb?.y_min && yb?.y_max) {
83 + for (let y = yb.y_max; y >= yb.y_min; y--) years.push(y);
84 + }
85 +
86 + return (
87 + <div className="container">
88 + <section className="hero">
89 + <span className="kicker">Agrégateur indépendant — direct des concessionnaires</span>
90 + <h1>
91 + Toutes les <em>voitures usagées</em> à vendre au Québec.
92 + Un seul endroit.
93 + </h1>
94 + <p className="sub">
95 + Auto-Ka visite les sites des concessionnaires et marchands d'occasion
96 + de toutes les régions, normalise chaque annonce et détecte les
97 + nouveautés, les ventes et les baisses de prix — automatiquement.
98 + </p>
99 + {heroStats && (
100 + <div className="hero-stats">
101 + <div className="hstat"><b>{heroStats.total.toLocaleString("fr-CA")}</b> véhicules en vente</div>
102 + <div className="hstat"><b>{heroStats.sources}</b> concessionnaires</div>
103 + <div className="hstat"><b>{heroStats.regions}</b> régions couvertes</div>
104 + </div>
105 + )}
106 + </section>
107 +
108 + <section className="filters" aria-label="Filtres de recherche">
109 + <div className="f-field full">
110 + <label htmlFor="f-q">Recherche</label>
111 + <input
112 + id="f-q"
113 + type="search"
114 + placeholder="Marque, modèle, concessionnaire, ville…"
115 + defaultValue={params.get("q") || ""}
116 + onKeyDown={(e) => {
117 + if (e.key === "Enter") setFilter("q", (e.target as HTMLInputElement).value);
118 + }}
119 + />
120 + </div>
121 + <div className="f-field">
122 + <label htmlFor="f-make">Marque</label>
123 + <select id="f-make" value={params.get("make") || ""} onChange={(e) => setFilter("make", e.target.value)}>
124 + <option value="">Toutes</option>
125 + {facets?.makes.map((m) => (
126 + <option key={m.make} value={m.make}>{m.make} ({m.n})</option>
127 + ))}
128 + </select>
129 + </div>
130 + <div className="f-field">
131 + <label htmlFor="f-model">Modèle</label>
132 + <select id="f-model" value={params.get("model") || ""} onChange={(e) => setFilter("model", e.target.value)}>
133 + <option value="">Tous</option>
134 + {facets?.models.map((m) => (
135 + <option key={m.model} value={m.model}>{m.model} ({m.n})</option>
136 + ))}
137 + </select>
138 + </div>
139 + <div className="f-field">
140 + <label htmlFor="f-region">Région</label>
141 + <select id="f-region" value={params.get("region") || ""} onChange={(e) => setFilter("region", e.target.value)}>
142 + <option value="">Toutes</option>
143 + {facets?.regions.map((r) => (
144 + <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
145 + ))}
146 + </select>
147 + </div>
148 + <div className="f-field">
149 + <label htmlFor="f-body">Carrosserie</label>
150 + <select id="f-body" value={params.get("body") || ""} onChange={(e) => setFilter("body", e.target.value)}>
151 + <option value="">Toutes</option>
152 + {facets?.body_types.map((b) => (
153 + <option key={b} value={b}>{b}</option>
154 + ))}
155 + </select>
156 + </div>
157 + <div className="f-field">
158 + <label htmlFor="f-fuel">Carburant</label>
159 + <select id="f-fuel" value={params.get("fuel") || ""} onChange={(e) => setFilter("fuel", e.target.value)}>
160 + <option value="">Tous</option>
161 + {facets?.fuels.map((f) => (
162 + <option key={f} value={f}>{f}</option>
163 + ))}
164 + </select>
165 + </div>
166 + <div className="f-field">
167 + <label htmlFor="f-trans">Transmission</label>
168 + <select id="f-trans" value={params.get("trans") || ""} onChange={(e) => setFilter("trans", e.target.value)}>
169 + <option value="">Toutes</option>
170 + <option value="Automatique">Automatique</option>
171 + <option value="Manuelle">Manuelle</option>
172 + </select>
173 + </div>
174 + <div className="f-field">
175 + <label htmlFor="f-ymin">Année min</label>
176 + <select id="f-ymin" value={params.get("ymin") || ""} onChange={(e) => setFilter("ymin", e.target.value)}>
177 + <option value="">—</option>
178 + {years.map((y) => (
179 + <option key={y} value={y}>{y}</option>
180 + ))}
181 + </select>
182 + </div>
183 + <div className="f-field">
184 + <label htmlFor="f-pmax">Prix max</label>
185 + <select id="f-pmax" value={params.get("pmax") || ""} onChange={(e) => setFilter("pmax", e.target.value)}>
186 + <option value="">—</option>
187 + {PRICE_STEPS.map((p) => (
188 + <option key={p} value={p}>{p.toLocaleString("fr-CA")} $</option>
189 + ))}
190 + </select>
191 + </div>
192 + <div className="f-field">
193 + <label htmlFor="f-kmax">KM max</label>
194 + <select id="f-kmax" value={params.get("kmax") || ""} onChange={(e) => setFilter("kmax", e.target.value)}>
195 + <option value="">—</option>
196 + {KM_STEPS.map((k) => (
197 + <option key={k} value={k}>{k.toLocaleString("fr-CA")} km</option>
198 + ))}
199 + </select>
200 + </div>
201 + <div className="f-actions">
202 + <button className="btn ghost" onClick={clearAll}>Réinitialiser</button>
203 + </div>
204 + </section>
205 +
206 + <div className="result-bar">
207 + <h2>
208 + <span className="count">{total.toLocaleString("fr-CA")}</span>{" "}
209 + véhicule{total !== 1 ? "s" : ""} trouvé{total !== 1 ? "s" : ""}
210 + </h2>
211 + <div className="sort-box">
212 + <label htmlFor="f-sort">Trier</label>
213 + <select id="f-sort" value={params.get("sort") || "price_asc"} onChange={(e) => setFilter("sort", e.target.value)}>
214 + {SORTS.map((s) => (
215 + <option key={s.v} value={s.v}>{s.label}</option>
216 + ))}
217 + </select>
218 + </div>
219 + </div>
220 +
221 + {loading ? (
222 + <div className="vgrid">
223 + {Array.from({ length: 8 }).map((_, i) => (
224 + <div key={i} className="skeleton" style={{ height: 360 }} />
225 + ))}
226 + </div>
227 + ) : vehicles.length === 0 ? (
228 + <div className="notice">
229 + <div className="big">🔍</div>
230 + <h2>Aucun véhicule ne correspond</h2>
231 + <p>Essayez d'élargir vos filtres — l'inventaire évolue chaque jour.</p>
232 + </div>
233 + ) : (
234 + <>
235 + <div className="vgrid">
236 + {vehicles.map((v) => (
237 + <VehicleCard key={v.uid} v={v} />
238 + ))}
239 + </div>
240 + {pages > 1 && (
241 + <div className="pager">
242 + <button
243 + className="btn ghost"
244 + disabled={page <= 1}
245 + onClick={() => { setFilter("page", String(page - 1)); window.scrollTo(0, 0); }}
246 + >
247 + ← Précédent
248 + </button>
249 + <span className="mono">page {page} / {pages}</span>
250 + <button
251 + className="btn ghost"
252 + disabled={page >= pages}
253 + onClick={() => { setFilter("page", String(page + 1)); window.scrollTo(0, 0); }}
254 + >
255 + Suivant →
256 + </button>
257 + </div>
258 + )}
259 + </>
260 + )}
261 + </div>
262 + );
263 +}
added frontend/src/pages/Sources.tsx +66 −0
@@ -0,0 +1,66 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Sources.tsx : registre des concessionnaires connectés
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link } from "react-router-dom";
8 +import { Source, fetchSources, fmtDate } from "../api";
9 +
10 +export default function SourcesPage() {
11 + const [sources, setSources] = useState<Source[]>([]);
12 +
13 + useEffect(() => {
14 + fetchSources().then((r) => setSources(r.sources)).catch(() => {});
15 + }, []);
16 +
17 + const actives = sources.filter((s) => s.active_listings > 0);
18 + const others = sources.filter((s) => s.active_listings === 0);
19 +
20 + return (
21 + <div className="container page">
22 + <span className="kicker">Registre</span>
23 + <h1>Les concessionnaires connectés</h1>
24 + <p className="lead">
25 + Un connecteur dédié par commerce — Auto-Ka lit chaque site à la source
26 + (aucune plateforme d'annonces), normalise l'inventaire et le tient à jour.
27 + </p>
28 + <div className="src-grid">
29 + {actives
30 + .sort((a, b) => b.active_listings - a.active_listings)
31 + .map((s) => (
32 + <div key={s.id} className="src-card">
33 + <h3>{s.name}</h3>
34 + <div className="meta">
35 + {s.city}{s.region ? ` · ${s.region}` : ""}{s.platform ? ` · ${s.platform}` : ""}
36 + </div>
37 + <div className="n">{s.active_listings.toLocaleString("fr-CA")} véhicules</div>
38 + <div className="meta">dernière sync : {fmtDate(s.last_sync)}</div>
39 + <div style={{ display: "flex", gap: 10, marginTop: 6 }}>
40 + <Link className="mono" style={{ fontSize: 12, textDecoration: "underline" }} to={`/?source=${s.id}`}>
41 + voir l'inventaire
42 + </Link>
43 + <a className="mono" style={{ fontSize: 12, textDecoration: "underline" }} href={s.url} target="_blank" rel="noreferrer">
44 + site officiel ↗
45 + </a>
46 + </div>
47 + </div>
48 + ))}
49 + </div>
50 + {others.length > 0 && (
51 + <>
52 + <h2 style={{ margin: "36px 0 14px" }}>En attente de première synchronisation</h2>
53 + <div className="src-grid">
54 + {others.map((s) => (
55 + <div key={s.id} className="src-card" style={{ opacity: 0.65 }}>
56 + <h3>{s.name}</h3>
57 + <div className="meta">{s.city}{s.region ? ` · ${s.region}` : ""}</div>
58 + <div className="meta">{s.status || "connecteur prêt"}</div>
59 + </div>
60 + ))}
61 + </div>
62 + </>
63 + )}
64 + </div>
65 + );
66 +}
added frontend/src/pages/Stats.tsx +113 −0
@@ -0,0 +1,113 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Stats.tsx : le marché de l'occasion en un coup d'œil + baisses de prix
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link } from "react-router-dom";
8 +import { Stats, fetchStats, fmtPrice } from "../api";
9 +
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 + );
23 +}
24 +
25 +export default function StatsPage() {
26 + const [stats, setStats] = useState<Stats | null>(null);
27 +
28 + useEffect(() => {
29 + fetchStats().then(setStats).catch(() => {});
30 + }, []);
31 +
32 + if (!stats)
33 + return (
34 + <div className="container page">
35 + <div className="skeleton" style={{ height: 300 }} />
36 + </div>
37 + );
38 +
39 + return (
40 + <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>
47 +
48 + <div className="stat-grid">
49 + <div className="stat-tile">
50 + <b>{stats.total.toLocaleString("fr-CA")}</b>
51 + <span>véhicules en vente</span>
52 + </div>
53 + <div className="stat-tile">
54 + <b>{stats.avg_price ? fmtPrice(stats.avg_price) : "—"}</b>
55 + <span>prix moyen</span>
56 + </div>
57 + <div className="stat-tile">
58 + <b>{stats.avg_km ? `${Math.round(stats.avg_km / 1000)}k km` : "—"}</b>
59 + <span>kilométrage moyen</span>
60 + </div>
61 + <div className="stat-tile">
62 + <b>{stats.avg_year ? Math.round(stats.avg_year) : "—"}</b>
63 + <span>année moyenne</span>
64 + </div>
65 + </div>
66 +
67 + {stats.price_drops.length > 0 && (
68 + <section style={{ marginBottom: 34 }}>
69 + <h2 style={{ marginBottom: 14 }}>📉 Baisses de prix récentes</h2>
70 + <div className="src-grid">
71 + {stats.price_drops.map((d) => (
72 + <Link key={d.uid} to={`/vehicule/${encodeURIComponent(d.uid)}`} className="src-card">
73 + <h3>{d.title}</h3>
74 + <div className="meta">{d.dealer_name} · {d.city}</div>
75 + <div className="n">
76 + {fmtPrice(d.price)}{" "}
77 + <span style={{ fontSize: 13, color: "var(--ink-3)", textDecoration: "line-through" }}>
78 + {fmtPrice(d.prev_price)}
79 + </span>
80 + </div>
81 + <div className="meta" style={{ color: "var(--good)" }}>
82 + −{fmtPrice(d.prev_price - d.price)}
83 + </div>
84 + </Link>
85 + ))}
86 + </div>
87 + </section>
88 + )}
89 +
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 + <div className="panel">
108 + <h3>Par carrosserie</h3>
109 + <Bars rows={stats.by_body.map((b) => ({ label: b.body_type, n: b.n }))} />
110 + </div>
111 + </div>
112 + );
113 +}
added frontend/src/pages/Vehicle.tsx +187 −0
@@ -0,0 +1,187 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Vehicle.tsx : fiche véhicule — galerie, specs, historique de prix, similaires
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link, useParams } from "react-router-dom";
8 +import { VehicleDetail, fetchVehicle, fmtDate, fmtKm, fmtPrice } from "../api";
9 +import VehicleCard from "../components/VehicleCard";
10 +
11 +export default function VehiclePage() {
12 + const { uid } = useParams<{ uid: string }>();
13 + const [v, setV] = useState<VehicleDetail | null>(null);
14 + const [error, setError] = useState(false);
15 + const [img, setImg] = useState(0);
16 +
17 + useEffect(() => {
18 + setV(null); setError(false); setImg(0);
19 + if (uid) fetchVehicle(uid).then(setV).catch(() => setError(true));
20 + window.scrollTo(0, 0);
21 + }, [uid]);
22 +
23 + if (error)
24 + return (
25 + <div className="notice container">
26 + <div className="big">🚗</div>
27 + <h2>Véhicule introuvable</h2>
28 + <p>Il a peut-être été vendu — l'inventaire évolue chaque jour.</p>
29 + <p><Link to="/" className="btn ghost">← Retour à la recherche</Link></p>
30 + </div>
31 + );
32 +
33 + if (!v)
34 + return (
35 + <div className="container vdetail">
36 + <div className="skeleton" style={{ height: 60, marginBottom: 20 }} />
37 + <div className="vd-cols">
38 + <div className="skeleton" style={{ height: 460 }} />
39 + <div className="skeleton" style={{ height: 460 }} />
40 + </div>
41 + </div>
42 + );
43 +
44 + const prevPrice = v.price_history.find(
45 + (h) => h.price != null && v.price != null && h.price !== v.price
46 + )?.price;
47 +
48 + const specs: [string, string][] = [
49 + ["Année", v.year ? String(v.year) : "—"],
50 + ["Kilométrage", fmtKm(v.mileage_km)],
51 + ["Transmission", v.transmission || "—"],
52 + ["Carburant", v.fuel || "—"],
53 + ["Motricité", v.drivetrain || "—"],
54 + ["Carrosserie", v.body_type || "—"],
55 + ["Couleur ext.", v.exterior_color || "—"],
56 + ["Couleur int.", v.interior_color || "—"],
57 + ["Portes", v.doors ? String(v.doors) : "—"],
58 + ["Places", v.seats ? String(v.seats) : "—"],
59 + ["NIV (VIN)", v.vin || "—"],
60 + ["No de stock", v.stock_number || "—"],
61 + ].filter(([, val]) => val !== "—") as [string, string][];
62 +
63 + return (
64 + <div className="container vdetail">
65 + <p style={{ marginTop: 0 }}>
66 + <Link to="/" className="mono" style={{ fontSize: 12 }}>← Retour à la recherche</Link>
67 + </p>
68 + <div className="vd-head">
69 + <div>
70 + <span className="kicker">{v.dealer_name || v.source} — {v.city}{v.region ? ` · ${v.region}` : ""}</span>
71 + <h1>{v.title}</h1>
72 + </div>
73 + <div className="vd-price">
74 + <div className="p">{fmtPrice(v.price)}</div>
75 + {prevPrice != null && v.price != null && prevPrice > v.price && (
76 + <div className="was">{fmtPrice(prevPrice)}</div>
77 + )}
78 + </div>
79 + </div>
80 +
81 + <div className="vd-cols">
82 + <div>
83 + <div className="gallery">
84 + <div className="main">
85 + {v.images.length > 0 ? (
86 + <a href={v.images[img]} target="_blank" rel="noreferrer">
87 + <img src={v.images[img]} alt={v.title} />
88 + </a>
89 + ) : (
90 + <div className="nopic" style={{ display: "grid", placeItems: "center", height: "100%", fontSize: 60 }}>🚗</div>
91 + )}
92 + </div>
93 + {v.images.length > 1 && (
94 + <div className="thumbs">
95 + {v.images.map((u, i) => (
96 + <img
97 + key={u}
98 + src={u}
99 + alt={`photo ${i + 1}`}
100 + loading="lazy"
101 + className={i === img ? "sel" : ""}
102 + onClick={() => setImg(i)}
103 + />
104 + ))}
105 + </div>
106 + )}
107 + </div>
108 +
109 + {v.description && (
110 + <div className="panel" style={{ marginTop: 18 }}>
111 + <h3>📝 Description du concessionnaire</h3>
112 + <div className="desc">{v.description}</div>
113 + </div>
114 + )}
115 + </div>
116 +
117 + <div>
118 + <div className="panel">
119 + <h3>🔧 Caractéristiques</h3>
120 + <table className="spec-table">
121 + <tbody>
122 + {specs.map(([k, val]) => (
123 + <tr key={k}>
124 + <td>{k}</td>
125 + <td>{val}</td>
126 + </tr>
127 + ))}
128 + </tbody>
129 + </table>
130 + <a className="cta-source" href={v.url} target="_blank" rel="noreferrer">
131 + Voir chez {v.dealer_name || "le concessionnaire"} →
132 + </a>
133 + <div className="cta-note">
134 + annonce originale — prix et disponibilité confirmés à la source
135 + </div>
136 + </div>
137 +
138 + {v.price_history.length > 1 && (
139 + <div className="panel">
140 + <h3>📉 Historique de prix</h3>
141 + <div className="price-history">
142 + {v.price_history.map((h, i) => {
143 + const next = v.price_history[i + 1];
144 + const dir =
145 + next?.price != null && h.price != null
146 + ? h.price < next.price ? "down" : h.price > next.price ? "up" : ""
147 + : "";
148 + return (
149 + <div key={h.ts} className="row">
150 + <span>{fmtDate(h.ts)}</span>
151 + <span className={dir}>{fmtPrice(h.price)}</span>
152 + </div>
153 + );
154 + })}
155 + </div>
156 + </div>
157 + )}
158 +
159 + <div className="panel">
160 + <h3>ℹ️ Suivi Auto-Ka</h3>
161 + <table className="spec-table">
162 + <tbody>
163 + <tr><td>Repéré le</td><td>{fmtDate(v.first_seen)}</td></tr>
164 + <tr><td>Vérifié le</td><td>{fmtDate(v.updated_at)}</td></tr>
165 + <tr><td>Statut</td><td>{v.active ? "En vente" : "Retiré / vendu"}</td></tr>
166 + </tbody>
167 + </table>
168 + </div>
169 + </div>
170 + </div>
171 +
172 + {v.similar.length > 0 && (
173 + <section style={{ marginTop: 40 }}>
174 + <span className="kicker">Comparer</span>
175 + <h2 style={{ margin: "8px 0 18px" }}>
176 + {v.make} {v.model} similaires au Québec
177 + </h2>
178 + <div className="vgrid">
179 + {v.similar.map((s) => (
180 + <VehicleCard key={s.uid} v={s} />
181 + ))}
182 + </div>
183 + </section>
184 + )}
185 + </div>
186 + );
187 +}
added frontend/src/styles.css +346 −0
@@ -0,0 +1,346 @@
1 +/* -----------------------------------------------------------------------------
2 + Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : système de design « éditorial sharp » — thème clair
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Accent orange racing #ff5a2a sur encre charbon
8 +----------------------------------------------------------------------------- */
9 +:root {
10 + --paper: #f4f2ec;
11 + --surface: #ffffff;
12 + --surface-2: #faf8f4;
13 + --ink: #17181c;
14 + --ink-2: #4c4f57;
15 + --ink-3: #8b8e96;
16 + --line: rgba(23, 24, 28, 0.14);
17 + --line-strong: rgba(23, 24, 28, 0.85);
18 + --accent: #ff5a2a;
19 + --accent-deep: #cc3f16;
20 + --accent-soft: #ffe8de;
21 + --good: #1c7a4d;
22 + --good-soft: #e2f4e9;
23 + --amber: #e8a33d;
24 + --amber-soft: #fdf3e2;
25 + --danger: #b3423a;
26 + --r-card: 10px;
27 + --r-ctl: 6px;
28 + --shadow-flat: 0 1px 2px rgba(23, 24, 28, 0.05);
29 + --shadow-off: 6px 6px 0 var(--ink);
30 + --shadow-off-soft: 8px 8px 0 rgba(23, 24, 28, 0.08);
31 + --font-display: "Space Grotesk", system-ui, sans-serif;
32 + --font-body: "Inter", system-ui, sans-serif;
33 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
34 +}
35 +
36 +* { box-sizing: border-box; }
37 +html { scroll-behavior: smooth; }
38 +body {
39 + margin: 0;
40 + background: var(--paper);
41 + color: var(--ink);
42 + font-family: var(--font-body);
43 + font-size: 15px;
44 + line-height: 1.55;
45 + -webkit-font-smoothing: antialiased;
46 + padding-bottom: env(safe-area-inset-bottom);
47 +}
48 +/* grain subtil — texture signature */
49 +body::before {
50 + content: "";
51 + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
52 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
53 +}
54 +#root { position: relative; z-index: 1; }
55 +
56 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
57 +a { color: inherit; text-decoration: none; }
58 +button { font-family: inherit; }
59 +img { display: block; }
60 +::selection { background: var(--accent); color: #fff; }
61 +
62 +.mono { font-family: var(--font-mono); }
63 +.kicker {
64 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
65 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--accent-deep);
66 + display: inline-flex; align-items: center; gap: 8px;
67 +}
68 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--accent); }
69 +
70 +.container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
71 +@media (max-width: 640px) { .container { padding: 0 16px; } }
72 +
73 +/* ================= Header ================= */
74 +.header {
75 + position: sticky; top: 0; z-index: 50;
76 + background: rgba(244, 242, 236, 0.88);
77 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
78 + border-bottom: 2px solid var(--ink);
79 +}
80 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
81 +.brand {
82 + font-family: var(--font-display); font-weight: 700; font-size: 26px;
83 + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;
84 +}
85 +.brand .ka {
86 + background: var(--ink); color: var(--accent); padding: 2px 7px 4px;
87 + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
88 + transition: transform 0.2s ease;
89 +}
90 +.brand:hover .ka { transform: rotate(0deg); }
91 +.brand-tag {
92 + font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
93 + letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px;
94 +}
95 +@media (max-width: 860px) { .brand-tag { display: none; } }
96 +.nav { margin-left: auto; display: flex; gap: 4px; }
97 +.nav a {
98 + padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px;
99 + color: var(--ink-2); border: 1.5px solid transparent;
100 + transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center;
101 +}
102 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
103 +.nav a.active { background: var(--ink); color: var(--accent); }
104 +@media (max-width: 640px) {
105 + .nav a { padding: 8px 11px; font-size: 13px; }
106 +}
107 +
108 +/* ================= Ticker ================= */
109 +.ticker {
110 + background: var(--ink); color: var(--accent); overflow: hidden;
111 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em;
112 + text-transform: uppercase; padding: 7px 0; white-space: nowrap;
113 + border-bottom: 1px solid rgba(255, 90, 42, 0.25);
114 +}
115 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }
116 +.ticker span { padding: 0 26px; position: relative; }
117 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
118 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
119 +@media (prefers-reduced-motion: reduce) {
120 + .ticker-track { animation: none; }
121 + * { transition-duration: 0.01ms !important; }
122 +}
123 +
124 +/* ================= Hero ================= */
125 +.hero { padding: 46px 0 8px; }
126 +.hero h1 {
127 + font-size: clamp(30px, 5vw, 52px); font-weight: 700; line-height: 1.04;
128 + max-width: 820px;
129 +}
130 +.hero h1 em { font-style: normal; color: var(--accent-deep); position: relative; }
131 +.hero p.sub {
132 + margin: 14px 0 0; color: var(--ink-2); font-size: 16.5px; max-width: 640px;
133 +}
134 +.hero-stats { display: flex; gap: 28px; margin-top: 22px; flex-wrap: wrap; }
135 +.hstat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); }
136 +.hstat b { display: block; font-family: var(--font-display); font-size: 26px; color: var(--ink); letter-spacing: -0.02em; }
137 +
138 +/* ================= Filtres ================= */
139 +.filters {
140 + background: var(--surface); border: 2px solid var(--ink);
141 + border-radius: var(--r-card); box-shadow: var(--shadow-off);
142 + padding: 16px; margin: 26px 0 8px;
143 + display: grid; gap: 10px;
144 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
145 +}
146 +.filters .full { grid-column: 1 / -1; }
147 +.f-field { display: flex; flex-direction: column; gap: 4px; }
148 +.f-field label {
149 + font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
150 + letter-spacing: 0.12em; color: var(--ink-3); font-weight: 600;
151 +}
152 +.f-field select, .f-field input {
153 + border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl);
154 + background: var(--surface); font: inherit; font-size: 14px;
155 + padding: 9px 10px; min-height: 40px; color: var(--ink);
156 +}
157 +.f-field select:focus, .f-field input:focus {
158 + outline: 2px solid var(--accent); outline-offset: 1px;
159 +}
160 +.f-actions { display: flex; align-items: flex-end; gap: 8px; }
161 +.btn {
162 + font-family: var(--font-display); font-weight: 600; font-size: 14.5px;
163 + border: 2px solid var(--ink); border-radius: var(--r-ctl);
164 + background: var(--ink); color: #fff; cursor: pointer;
165 + padding: 9px 18px; min-height: 40px;
166 + transition: transform 0.12s ease, box-shadow 0.12s ease;
167 +}
168 +.btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--accent); }
169 +.btn.ghost { background: transparent; color: var(--ink); }
170 +.btn.ghost:hover { box-shadow: 4px 4px 0 var(--ink); }
171 +
172 +.result-bar {
173 + display: flex; align-items: center; gap: 14px; margin: 22px 0 14px; flex-wrap: wrap;
174 +}
175 +.result-bar h2 { font-size: 20px; }
176 +.result-bar .count { color: var(--accent-deep); }
177 +.sort-box { margin-left: auto; display: flex; align-items: center; gap: 8px; }
178 +.sort-box label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; color: var(--ink-3); }
179 +.sort-box select {
180 + border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl);
181 + background: var(--surface); font: inherit; font-size: 13.5px; padding: 7px 9px;
182 +}
183 +
184 +/* ================= Grille véhicules ================= */
185 +.vgrid {
186 + display: grid; gap: 22px;
187 + grid-template-columns: repeat(auto-fill, minmax(285px, 1fr));
188 + padding-bottom: 30px;
189 +}
190 +.vcard {
191 + background: var(--surface); border: 2px solid var(--ink);
192 + border-radius: var(--r-card); overflow: hidden; position: relative;
193 + display: flex; flex-direction: column;
194 + transition: transform 0.15s ease, box-shadow 0.15s ease;
195 + box-shadow: var(--shadow-flat);
196 +}
197 +.vcard:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
198 +.vcard .photo {
199 + aspect-ratio: 4 / 3; background: var(--surface-2); position: relative; overflow: hidden;
200 +}
201 +.vcard .photo img { width: 100%; height: 100%; object-fit: cover; }
202 +.vcard .photo .nopic {
203 + position: absolute; inset: 0; display: grid; place-items: center;
204 + color: var(--ink-3); font-size: 40px;
205 +}
206 +.vcard .year-chip {
207 + position: absolute; top: 10px; left: 10px;
208 + background: var(--ink); color: #fff; font-family: var(--font-mono);
209 + font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
210 +}
211 +.vcard .drop-chip {
212 + position: absolute; top: 10px; right: 10px;
213 + background: var(--good); color: #fff; font-family: var(--font-mono);
214 + font-size: 10.5px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
215 +}
216 +.vcard .body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
217 +.vcard h3 { font-size: 16.5px; line-height: 1.25; }
218 +.vcard .price {
219 + font-family: var(--font-display); font-weight: 700; font-size: 21px;
220 + color: var(--accent-deep); letter-spacing: -0.02em;
221 +}
222 +.vcard .price small { font-size: 12px; color: var(--ink-3); font-weight: 500; }
223 +.vcard .specs {
224 + display: flex; flex-wrap: wrap; gap: 6px;
225 +}
226 +.spec-chip {
227 + font-family: var(--font-mono); font-size: 10.5px;
228 + background: var(--surface-2); border: 1px solid var(--line);
229 + padding: 3px 8px; border-radius: 999px; color: var(--ink-2);
230 +}
231 +.vcard .foot {
232 + margin-top: auto; padding-top: 10px; border-top: 1px dashed var(--line);
233 + font-size: 12px; color: var(--ink-3);
234 + display: flex; justify-content: space-between; gap: 8px;
235 +}
236 +
237 +/* ================= Pagination ================= */
238 +.pager { display: flex; justify-content: center; gap: 10px; padding: 8px 0 46px; align-items: center; }
239 +.pager .mono { font-size: 12px; color: var(--ink-2); }
240 +
241 +/* ================= Fiche véhicule ================= */
242 +.vdetail { padding: 34px 0 60px; }
243 +.vd-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
244 +.vd-head h1 { font-size: clamp(24px, 3.6vw, 38px); line-height: 1.1; max-width: 760px; }
245 +.vd-price {
246 + margin-left: auto; text-align: right;
247 +}
248 +.vd-price .p {
249 + font-family: var(--font-display); font-weight: 700; font-size: 34px;
250 + color: var(--accent-deep); letter-spacing: -0.02em;
251 +}
252 +.vd-price .was { font-size: 14px; color: var(--ink-3); text-decoration: line-through; }
253 +.vd-cols { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 1fr); gap: 28px; }
254 +@media (max-width: 900px) { .vd-cols { grid-template-columns: 1fr; } }
255 +
256 +.gallery { border: 2px solid var(--ink); border-radius: var(--r-card); overflow: hidden; background: var(--surface); }
257 +.gallery .main { aspect-ratio: 4 / 3; background: var(--surface-2); }
258 +.gallery .main img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; }
259 +.gallery .thumbs {
260 + display: flex; gap: 6px; padding: 8px; overflow-x: auto; border-top: 2px solid var(--ink);
261 + background: var(--surface-2);
262 +}
263 +.gallery .thumbs img {
264 + width: 84px; height: 60px; object-fit: cover; border-radius: 4px;
265 + cursor: pointer; border: 2px solid transparent; flex: 0 0 auto;
266 +}
267 +.gallery .thumbs img.sel { border-color: var(--accent); }
268 +
269 +.panel {
270 + background: var(--surface); border: 2px solid var(--ink);
271 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
272 + padding: 18px 20px; margin-bottom: 18px;
273 +}
274 +.panel h3 { font-size: 15px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
275 +.spec-table { width: 100%; border-collapse: collapse; font-size: 14px; }
276 +.spec-table td { padding: 7px 0; border-bottom: 1px dashed var(--line); }
277 +.spec-table td:first-child { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
278 +.spec-table td:last-child { text-align: right; font-weight: 600; }
279 +.spec-table tr:last-child td { border-bottom: none; }
280 +
281 +.cta-source {
282 + display: block; text-align: center; font-family: var(--font-display);
283 + font-weight: 700; font-size: 16px; background: var(--accent); color: #fff;
284 + border: 2px solid var(--ink); border-radius: var(--r-ctl);
285 + padding: 13px 18px; margin-top: 4px;
286 + transition: transform 0.12s ease, box-shadow 0.12s ease;
287 +}
288 +.cta-source:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--ink); }
289 +.cta-note { font-size: 11.5px; color: var(--ink-3); text-align: center; margin-top: 8px; font-family: var(--font-mono); }
290 +
291 +.desc { white-space: pre-line; color: var(--ink-2); font-size: 14.5px; }
292 +.price-history { font-size: 13px; }
293 +.price-history .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--line); }
294 +.price-history .down { color: var(--good); font-weight: 700; }
295 +.price-history .up { color: var(--danger); font-weight: 700; }
296 +
297 +/* ================= Sections / pages annexes ================= */
298 +.page { padding: 40px 0 70px; }
299 +.page h1 { font-size: clamp(26px, 4vw, 40px); margin-bottom: 8px; }
300 +.page .lead { color: var(--ink-2); max-width: 640px; margin-bottom: 28px; }
301 +
302 +.src-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
303 +.src-card {
304 + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card);
305 + padding: 16px 18px; display: flex; flex-direction: column; gap: 6px;
306 + transition: transform 0.14s ease, box-shadow 0.14s ease; box-shadow: var(--shadow-flat);
307 +}
308 +.src-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
309 +.src-card h3 { font-size: 16px; }
310 +.src-card .meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); }
311 +.src-card .n { font-family: var(--font-display); font-weight: 700; font-size: 22px; color: var(--accent-deep); }
312 +
313 +.stat-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 30px; }
314 +.stat-tile {
315 + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card);
316 + padding: 18px; box-shadow: var(--shadow-off-soft);
317 +}
318 +.stat-tile b { display: block; font-family: var(--font-display); font-size: 30px; letter-spacing: -0.02em; }
319 +.stat-tile span { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); }
320 +
321 +.bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; font-size: 13.5px; }
322 +.bar-row .lbl { width: 170px; flex: 0 0 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
323 +.bar-row .bar { height: 16px; background: var(--accent); border: 1.5px solid var(--ink); border-radius: 3px; min-width: 2px; }
324 +.bar-row .val { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-2); }
325 +
326 +.notice { text-align: center; padding: 90px 20px; }
327 +.notice .big { font-size: 52px; margin-bottom: 12px; }
328 +.notice p { color: var(--ink-2); }
329 +
330 +.skeleton {
331 + background: linear-gradient(100deg, var(--surface-2) 40%, #fff 50%, var(--surface-2) 60%);
332 + background-size: 200% 100%; animation: sk 1.4s infinite;
333 + border-radius: var(--r-card); border: 2px solid var(--line);
334 +}
335 +@keyframes sk { from { background-position: 130% 0; } to { background-position: -30% 0; } }
336 +
337 +/* ================= Footer ================= */
338 +.footer {
339 + background: var(--ink); color: #c9cbd1; margin-top: 30px;
340 + padding: 42px 0 30px; border-top: 3px solid var(--accent);
341 +}
342 +.fbrand { font-family: var(--font-display); font-weight: 700; font-size: 24px; color: #fff; margin-bottom: 14px; }
343 +.fbrand .ka { background: var(--accent); color: var(--ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 2px; display: inline-block; transform: rotate(-2deg); }
344 +.frow { font-size: 13.5px; max-width: 560px; line-height: 1.6; }
345 +.fmono { font-family: var(--font-mono); font-size: 11px; color: #82858d; margin-top: 22px; }
346 +.fmono a { text-decoration: underline; }
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
added frontend/tsconfig.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2020",
4 + "useDefineForClassFields": true,
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 + "module": "ESNext",
7 + "skipLibCheck": true,
8 + "moduleResolution": "bundler",
9 + "allowImportingTsExtensions": true,
10 + "resolveJsonModule": true,
11 + "isolatedModules": true,
12 + "noEmit": true,
13 + "jsx": "react-jsx",
14 + "strict": true,
15 + "noUnusedLocals": false,
16 + "noUnusedParameters": false,
17 + "noFallthroughCasesInSwitch": true
18 + },
19 + "include": ["src"]
20 +}
added frontend/vite.config.ts +15 −0
@@ -0,0 +1,15 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// -----------------------------------------------------------------------------
5 +import react from "@vitejs/plugin-react";
6 +import { defineConfig } from "vite";
7 +
8 +export default defineConfig({
9 + plugins: [react()],
10 + server: {
11 + proxy: {
12 + "/api": "http://localhost:8080",
13 + },
14 + },
15 +});
added requirements.txt +6 −0
@@ -0,0 +1,6 @@
1 +# Auto-Ka — dépendances backend
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +fastapi>=0.110
4 +uvicorn>=0.29
5 +requests>=2.31
6 +beautifulsoup4>=4.12
added run.py +47 −0
@@ -0,0 +1,47 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# run.py : point d'entrée — `sync`, `watch`, `serve`
6 +# -----------------------------------------------------------------------------
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise les annonces
9 + python run.py watch [minutes] # synchronise en boucle (défaut 120 min)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 +"""
12 +from __future__ import annotations
13 +
14 +import os
15 +import sys
16 +from pathlib import Path
17 +
18 +# Charger .env (FIRECRAWL_API_KEY, etc.) sans dépendance externe
19 +_env = Path(__file__).parent / ".env"
20 +if _env.exists():
21 + for line in _env.read_text().splitlines():
22 + line = line.strip()
23 + if line and not line.startswith("#") and "=" in line:
24 + k, _, v = line.partition("=")
25 + os.environ.setdefault(k.strip(), v.strip())
26 +
27 +
28 +def main() -> None:
29 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
30 + if cmd == "sync":
31 + from autoka import ingest
32 + ingest.run(sys.argv[2:] or None)
33 + elif cmd == "watch":
34 + from autoka import ingest
35 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 120
36 + ingest.watch(minutes * 60)
37 + elif cmd == "serve":
38 + import uvicorn
39 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
40 + uvicorn.run("autoka.web:app", host="0.0.0.0", port=port)
41 + else:
42 + print(__doc__)
43 + sys.exit(1)
44 +
45 +
46 +if __name__ == "__main__":
47 + main()
48