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>
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 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 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=®ion=&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 | +[](mailto:contact@spboucher.ai) | |
| 193 | +[](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 (é…) -> 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 | ||