Ora-Ka v2 — moteur hybride, SSO KA ID, README complet (badges, métriques, captures)
- Landing « famille Ka × verre » : splash Groupe KA, ticker, une barre - /api/search : score unifié exact ∪ sémantique (cosinus + couverture-jetons + phrase + affinité d'univers), coupure dynamique, diversité garantie - Index sémantique text-embedding-3-small 256d float16, hot-reload npz - SSO KA ID (oraka/auth.py) + favoris hub (hubfav.py) + pages légales - README : badges, métriques prod (400 498 inscriptions), 9 captures Playwright (scripts/screenshots.mjs, régénérables) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 57 changed files with +20,001 and −0
added
.gitignore
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +.env | |
| 2 | +.venv/ | |
| 3 | +__pycache__/ | |
| 4 | +*.pyc | |
| 5 | +node_modules | |
| 6 | +apps/*/data/*.db | |
| 7 | +apps/*/data/*.db-shm | |
| 8 | +apps/*/data/*.db-wal | |
| 9 | +apps/*/frontend/dist/ | |
| 10 | +data/semantic/ | |
| 11 | +.DS_Store | |
added
README.md
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +# Ora·Ka — Cinq agrégateurs. Une barre. | |
| 2 | + | |
| 3 | +**Auteur : Simon-Pierre Boucher — contact@spboucher.ai** | |
| 4 | + | |
| 5 | +[](https://www.ora-ka.com) | |
| 6 | +[](requirements.txt) | |
| 7 | +[](oraka/web.py) | |
| 8 | +[](apps) | |
| 9 | +[](apps) | |
| 10 | +[](oraka/semantic.py) | |
| 11 | +[](https://www.ora-ka.com/api/overview) | |
| 12 | +[](#production-maclustr--nœud-m4m64a-port-8200) | |
| 13 | + | |
| 14 | +Ora-Ka unifie les cinq agrégateurs automatisés de la famille Ka en une seule | |
| 15 | +plateforme : **une barre de recherche**, un moteur hybride exact + sémantique, | |
| 16 | +et les cinq applications d'origine **reproduites fidèlement** sous leur propre | |
| 17 | +préfixe — sans modifier une ligne de leurs backends. | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | +## Métriques (prod, 2026-08-13) | |
| 22 | + | |
| 23 | +| Univers | Préfixe | Contenu | Inscriptions | Sources | Accent | | |
| 24 | +|-----------|-----------|--------------------------------|-------------:|--------:|-----------| | |
| 25 | +| Immo·Ka | `/immo/` | Propriétés à vendre (QC) | 48 487 | 33 | `#e23744` | | |
| 26 | +| Lou·Ka | `/lou/` | Logements à louer | 16 563 | 255 | `#d9f26b` | | |
| 27 | +| Fabri·Ka | `/fabri/` | Produits québécois | 291 132 | 3 224 boutiques | `#c4532e` | | |
| 28 | +| Auto·Ka | `/auto/` | Véhicules d'occasion | 17 997 | 138 | `#ff5a2a` | | |
| 29 | +| Food·Ka | `/food/` | Épicerie comparée | 26 319 | 30 | `#1f7a4d` | | |
| 30 | +| **Total** | | | **400 498** | | | | |
| 31 | + | |
| 32 | +| Dimension | Valeur | | |
| 33 | +|------------------------|--------| | |
| 34 | +| Index sémantique | 362 508 vecteurs · text-embedding-3-small · 256 d float16 · ≈ 164 Mo npz | | |
| 35 | +| Bases SQLite | ≈ 1,4 Go (5 bases + 2 bases quartier) | | |
| 36 | +| Moteur `oraka/` | 2 090 lignes Python (8 modules) | | |
| 37 | +| Backends embarqués | 339 fichiers Python · 65 852 lignes (inchangés) | | |
| 38 | +| Frontends | 5 SPA React 18 + Vite, rebuildées sous préfixe | | |
| 39 | +| Cadence d'ingestion | immo 180 min · lou 60 · auto 120 · fabri/food 360 | | |
| 40 | +| Latence recherche | brute-force numpy sur npz en RAM, hot-reload sur mtime | | |
| 41 | + | |
| 42 | +## La recherche hybride | |
| 43 | + | |
| 44 | +Une seule barre (`/api/search`) interroge les cinq univers avec un **score | |
| 45 | +unifié par candidat** — pas de cascade exact-puis-sémantique : | |
| 46 | + | |
| 47 | +- **Candidats** : correspondance exacte par jetons AND (LIKE/FTS5) ∪ top-k sémantique par univers. | |
| 48 | +- **Score** : cosinus (lookup par uid, même pour les hits exacts) + 0,22·couverture-jetons + 0,10·phrase + 0,05·exact + affinité d'univers data-driven. | |
| 49 | +- **Coupure dynamique** (best − 0,22) + **garantie de diversité** : chaque univers ≥ best − 0,30 est épinglé dans le top 12. | |
| 50 | +- **Affichage** : pertinence relative en % (score/best), jauge, surlignage des jetons (préfixe en début de mot, stopwords exclus). | |
| 51 | + | |
| 52 | +| « condo 2 chambres montréal » | « fromage du québec » | | |
| 53 | +|---|---| | |
| 54 | +|  |  | | |
| 55 | + | |
| 56 | +L'index (`oraka/semantic.py`) vit dans `data/semantic/<univers>.npz` ; le | |
| 57 | +serveur le recharge à chaud sur changement de mtime — un simple rsync des npz | |
| 58 | +suffit, sans redémarrage. Rebuild incrémental : `run.py index` | |
| 59 | +(~10 min, ~0,25 $ ; à relancer après les grosses syncs sinon l'index dérive). | |
| 60 | + | |
| 61 | +## Les cinq univers | |
| 62 | + | |
| 63 | +Chaque app est montée telle quelle sous son préfixe — mêmes fiches, mêmes | |
| 64 | +cartes, mêmes filtres que l'app d'origine : | |
| 65 | + | |
| 66 | +| | | | |
| 67 | +|---|---| | |
| 68 | +|  `/immo/` |  `/lou/` | | |
| 69 | +|  `/fabri/` |  `/auto/` | | |
| 70 | +|  `/food/` |  splash au boot | | |
| 71 | + | |
| 72 | +*Captures régénérables : `node scripts/screenshots.mjs` (playwright requis).* | |
| 73 | + | |
| 74 | +## Architecture | |
| 75 | + | |
| 76 | +``` | |
| 77 | +ora-ka/ | |
| 78 | + run.py # serve [port] | sync [app ...] | watch | index | |
| 79 | + oraka/ | |
| 80 | + web.py # app maîtresse : landing + /api/overview + /healthz + 5 mounts | |
| 81 | + landing.py # landing « famille Ka × verre » (splash, ticker, cartes) | |
| 82 | + search.py # /api/search — score unifié exact ∪ sémantique | |
| 83 | + semantic.py # index npz par univers, hot-reload, OpenAI embeddings | |
| 84 | + auth.py # SSO « KA ID » via le hub groupe-ka.com | |
| 85 | + hubfav.py # favoris synchronisés au hub | |
| 86 | + legal.py # conditions + confidentialité | |
| 87 | + apps/ | |
| 88 | + immo/ { immoka/, data/{immoka.db, quartier.db, sources.json…}, frontend/ } | |
| 89 | + lou/ { louka/, data/{louka.db, quartier.db, sources.json}, frontend/ } | |
| 90 | + fabri/ { fabrika/, data/{fabrika.db, stores.json}, frontend/ } | |
| 91 | + auto/ { autoka/, data/{autoka.db, sources.json}, frontend/ } | |
| 92 | + food/ { foodka/, data/{foodka.db, sources.json}, frontend/ } | |
| 93 | + data/semantic/ # <univers>.npz + .meta.json (non versionnés) | |
| 94 | +``` | |
| 95 | + | |
| 96 | +Chaque app conserve son arborescence d'origine (`<package>/`, `data/`, | |
| 97 | +`frontend/dist/` frères) — ses chemins internes calculés via `__file__` | |
| 98 | +restent valides, **aucun code backend n'a été modifié**. Les frontends | |
| 99 | +React/Vite ont uniquement été rebuildés avec `base: '/<préfixe>/'` + | |
| 100 | +`basename` react-router + `API_BASE = import.meta.env.BASE_URL` | |
| 101 | +(+ cas spéciaux : worker MapLibre de lou, logos de food). | |
| 102 | + | |
| 103 | +Design de la landing : hybride « famille Ka × verre » — papier grainé | |
| 104 | +`#f5f3ee`, encre `#141814`, lime `#d9f26b`, JetBrains Mono uppercase, | |
| 105 | +bordures encre + ombres décalées dures, badge « Ka » incliné −2°, ticker, | |
| 106 | +splash Groupe KA (1,6 s, sessionStorage). | |
| 107 | + | |
| 108 | +## Lancement | |
| 109 | + | |
| 110 | +```bash | |
| 111 | +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt | |
| 112 | +# builds frontend (une fois) : cd apps/<x>/frontend && npm install && npm run build | |
| 113 | +.venv/bin/python run.py serve 8200 # plateforme complète | |
| 114 | +.venv/bin/python run.py sync # ingestion des 5 apps (ou : sync immo lou …) | |
| 115 | +.venv/bin/python run.py watch # boucle permanente, cadence native par app | |
| 116 | +.venv/bin/python run.py index # (re)construit l'index sémantique | |
| 117 | +``` | |
| 118 | + | |
| 119 | +`.env` (non versionné) : `OPENAI_API_KEY` (index sémantique), | |
| 120 | +`FIRECRAWL_API_KEY`, `SCRAPFLY_KEY`, `SCRAPFLY_API_KEY` (ingestion seulement, | |
| 121 | +jamais le serveur) ; `KA_SSO_SECRET`, `KA_HUB_URL` (SSO KA ID). | |
| 122 | + | |
| 123 | +## Production (MacLustr — nœud M4M64a, port 8200) | |
| 124 | + | |
| 125 | +```bash | |
| 126 | +pm2 start .venv/bin/python --name ora-ka-web --interpreter none --cwd ~/apps/ora-ka -- run.py serve 8200 | |
| 127 | +pm2 start .venv/bin/python --name ora-ka-sync --interpreter none --cwd ~/apps/ora-ka -- run.py watch | |
| 128 | +pm2 start /opt/homebrew/bin/ngrok --name ora-ka-ngrok --interpreter none -- http --url=www.ora-ka.com 8200 | |
| 129 | +``` | |
| 130 | + | |
| 131 | +Healthcheck : `GET /healthz` — vérifie la présence des 5 bases SQLite. | |
| 132 | +Stats agrégées en direct : `GET /api/overview`. | |
| 133 | + | |
| 134 | +--- | |
| 135 | + | |
| 136 | +© 2026 Groupe KA — Ora-Ka agrège des données publiques ; chaque inscription | |
| 137 | +reste la propriété de sa source. | |
added
apps/auto/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
apps/auto/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
apps/auto/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
apps/auto/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
apps/auto/autoka/connectors/automobileendirect.py
+246 −0
@@ -0,0 +1,246 @@ | ||
| 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 ET la galerie complète | |
| 19 | +# (photos.all ≈ 25 images vs 4 aperçus dans la liste). | |
| 20 | +# ~80 requêtes de liste pour tout l'inventaire ; les pages détail sont | |
| 21 | +# plafonnées par AUTOKA_MAX_DETAILS (défaut 1200 — API JSON rapide, | |
| 22 | +# ~1 s/requête) et complétées aux syncs suivants via le cache BD. | |
| 23 | +# | |
| 24 | +# URL publique d'une fiche : /auto-usage/<slug>/<vin> (route SPA fr). | |
| 25 | +# AUTOKA_MAX_PAGES (env) limite le nombre de pages de liste (tests). | |
| 26 | +# ----------------------------------------------------------------------------- | |
| 27 | +from __future__ import annotations | |
| 28 | + | |
| 29 | +import os | |
| 30 | +import re | |
| 31 | + | |
| 32 | +from ..schema import Vehicle | |
| 33 | +from .base import BaseConnector | |
| 34 | + | |
| 35 | +API = "https://api.automobileendirect.com" | |
| 36 | +SITE = "https://www.automobileendirect.com" | |
| 37 | + | |
| 38 | +_PER_PAGE = 22 # taille fixe côté serveur | |
| 39 | +_AWD_RE = re.compile(r"\b(awd|4wd|4x4|quattro|xdrive|4matic|4motion)\b", re.I) | |
| 40 | + | |
| 41 | +# clé de cache détail (v2 : ajout de la galerie photos.all au payload) | |
| 42 | +_DETAIL_KEY = "v2" | |
| 43 | + | |
| 44 | + | |
| 45 | +def _fr(value) -> str: | |
| 46 | + """Champ bilingue {'fr':…,'en':…} ou {'id':…,'title':{…}} -> texte fr.""" | |
| 47 | + if isinstance(value, dict): | |
| 48 | + if "title" in value: | |
| 49 | + return _fr(value["title"]) | |
| 50 | + return str(value.get("fr") or value.get("en") or "").strip() | |
| 51 | + return str(value or "").strip() | |
| 52 | + | |
| 53 | + | |
| 54 | +class AutomobileEnDirect(BaseConnector): | |
| 55 | + """Automobile En Direct — inventaire complet via l'API REST de la SPA.""" | |
| 56 | + | |
| 57 | + source_id = "automobileendirect" | |
| 58 | + dealer_name = "Automobile En Direct" | |
| 59 | + request_delay = 1.0 | |
| 60 | + max_pages = 200 # garde-fou dur (~80 pages réelles) | |
| 61 | + max_details = 1200 # plafond de vraies requêtes détail / sync | |
| 62 | + # (API JSON rapide ; inventaire complet | |
| 63 | + # enrichi en ≤ 2 syncs, puis cache BD) | |
| 64 | + | |
| 65 | + def __init__(self) -> None: | |
| 66 | + super().__init__() | |
| 67 | + self.session.headers["accept-language"] = "fr" | |
| 68 | + | |
| 69 | + # -- API ---------------------------------------------------------------- | |
| 70 | + def _list_page(self, page: int) -> dict: | |
| 71 | + return self.get(f"{API}/cars", params={"page": page}).json() | |
| 72 | + | |
| 73 | + def _fetch_detail(self, vin: str) -> dict: | |
| 74 | + data = self.get(f"{API}/cars/detailed/{vin}").json() | |
| 75 | + # ne garder que les champs utiles (cache BD compact) | |
| 76 | + keep = ("traction", "doors", "passengers", "engineDescription", | |
| 77 | + "engineSize", "cylinders", "colors", "description", | |
| 78 | + "options", "previousOwners", "fuelConsumption") | |
| 79 | + out = {k: data[k] for k in keep if data.get(k) is not None} | |
| 80 | + # galerie complète (photos.all) — la liste n'a que 4 aperçus | |
| 81 | + photos = data.get("photos") or {} | |
| 82 | + gallery = [u for u in (photos.get("all") or []) if isinstance(u, str)] | |
| 83 | + if gallery: | |
| 84 | + out["photos"] = gallery[:30] | |
| 85 | + return out | |
| 86 | + | |
| 87 | + # -- contrat -------------------------------------------------------------- | |
| 88 | + def fetch(self) -> list[Vehicle]: | |
| 89 | + page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) | |
| 90 | + detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 91 | + | |
| 92 | + rows: list[dict] = [] | |
| 93 | + seen: set[str] = set() | |
| 94 | + page, total = 1, None | |
| 95 | + while page <= min(page_cap, self.max_pages): | |
| 96 | + data = self._list_page(page) | |
| 97 | + results = data.get("results") or [] | |
| 98 | + if not results: | |
| 99 | + break | |
| 100 | + total = data.get("totalResults") or total | |
| 101 | + for r in results: | |
| 102 | + vin = str(r.get("vin") or "").strip() | |
| 103 | + if vin and vin not in seen: | |
| 104 | + seen.add(vin) | |
| 105 | + rows.append(r) | |
| 106 | + if total is not None and page * _PER_PAGE >= total: | |
| 107 | + break | |
| 108 | + page += 1 | |
| 109 | + | |
| 110 | + vehicles: list[Vehicle] = [] | |
| 111 | + real_fetches = 0 | |
| 112 | + for r in rows: | |
| 113 | + extra: dict = {} | |
| 114 | + vin = str(r["vin"]) | |
| 115 | + if real_fetches >= detail_cap: | |
| 116 | + # budget épuisé : on sert quand même le cache BD existant | |
| 117 | + from .. import db | |
| 118 | + if self._detail_con is None: | |
| 119 | + self._detail_con = db.connect() | |
| 120 | + extra = db.get_cached_detail(self._detail_con, self.source_id, | |
| 121 | + vin, _DETAIL_KEY) or {} | |
| 122 | + else: | |
| 123 | + before = self._last_request | |
| 124 | + # clé statique : le détail (traction, moteur, options…) ne | |
| 125 | + # change pas ; prix/km frais viennent toujours de la liste | |
| 126 | + try: | |
| 127 | + extra = self.detail(vin, _DETAIL_KEY, | |
| 128 | + lambda v=vin: self._fetch_detail(v)) | |
| 129 | + except Exception: | |
| 130 | + extra = {} # détail indisponible : données liste suffisent | |
| 131 | + if self._last_request != before: | |
| 132 | + real_fetches += 1 | |
| 133 | + veh = self._to_vehicle(r, extra) | |
| 134 | + if veh is not None: | |
| 135 | + vehicles.append(veh) | |
| 136 | + return vehicles | |
| 137 | + | |
| 138 | + # -- payload API -> Vehicle ------------------------------------------------- | |
| 139 | + def _to_vehicle(self, r: dict, extra: dict) -> Vehicle | None: | |
| 140 | + if r.get("sold") or r.get("hidden") or r.get("removedFromInventory"): | |
| 141 | + return None | |
| 142 | + vin = str(r.get("vin") or "").strip() | |
| 143 | + slug = str(r.get("slug") or "").strip() | |
| 144 | + if not vin or not slug: | |
| 145 | + return None | |
| 146 | + | |
| 147 | + year = r.get("year") | |
| 148 | + make = str(r.get("make") or "").strip() | |
| 149 | + model = str(r.get("model") or "").strip() | |
| 150 | + trim = str(r.get("trim") or "").strip() | |
| 151 | + | |
| 152 | + prices = r.get("prices") or {} | |
| 153 | + price = prices.get("current") or prices.get("regular") | |
| 154 | + try: | |
| 155 | + price = float(price) if price else None | |
| 156 | + except (TypeError, ValueError): | |
| 157 | + price = None | |
| 158 | + | |
| 159 | + km = r.get("kilometers") | |
| 160 | + try: | |
| 161 | + km = float(km) if km not in (None, "") else None | |
| 162 | + except (TypeError, ValueError): | |
| 163 | + km = None | |
| 164 | + | |
| 165 | + # galerie complète depuis le détail (≈ 25 photos), sinon aperçus liste | |
| 166 | + images = [u for u in (extra.get("photos") or []) if isinstance(u, str)] | |
| 167 | + if not images: | |
| 168 | + photos = r.get("photos") or {} | |
| 169 | + images = [u for u in (photos.get("preview") or []) | |
| 170 | + if isinstance(u, str)] | |
| 171 | + if not images and photos.get("main"): | |
| 172 | + images = [photos["main"]] | |
| 173 | + | |
| 174 | + # traction : page détail si dispo, sinon indice AWD/4x4 dans la version | |
| 175 | + drivetrain = _fr(extra.get("traction")) | |
| 176 | + if not drivetrain and _AWD_RE.search(trim): | |
| 177 | + drivetrain = "Intégrale / 4x4" | |
| 178 | + | |
| 179 | + colors = extra.get("colors") or {} | |
| 180 | + interior = _fr(colors.get("interior")) if isinstance(colors, dict) else "" | |
| 181 | + | |
| 182 | + engine = str(extra.get("engineDescription") or "").strip() | |
| 183 | + if not engine and extra.get("engineSize"): | |
| 184 | + cyl = extra.get("cylinders") | |
| 185 | + engine = f"{extra['engineSize']}L" + (f" {cyl} cyl." if cyl else "") | |
| 186 | + | |
| 187 | + description = _fr(extra.get("description")) or _fr(r.get("punchline")) | |
| 188 | + | |
| 189 | + features: list[str] = [] | |
| 190 | + for opt in extra.get("options") or []: | |
| 191 | + label = _fr(opt.get("title") if isinstance(opt, dict) else opt) \ | |
| 192 | + if isinstance(opt, (dict, str)) else "" | |
| 193 | + if label: | |
| 194 | + features.append(label) | |
| 195 | + | |
| 196 | + details: dict = {} | |
| 197 | + regular = prices.get("regular") | |
| 198 | + if regular and price and regular != price: | |
| 199 | + details["regular_price"] = regular | |
| 200 | + if r.get("electricRange"): | |
| 201 | + details["electric_range_km"] = r["electricRange"] | |
| 202 | + if extra.get("previousOwners") is not None: | |
| 203 | + details["previous_owners"] = extra["previousOwners"] | |
| 204 | + if r.get("carfaxAmount") is not None: | |
| 205 | + details["carfax_reports"] = r["carfaxAmount"] | |
| 206 | + | |
| 207 | + try: | |
| 208 | + doors = int(extra["doors"]) if extra.get("doors") else None | |
| 209 | + except (TypeError, ValueError): | |
| 210 | + doors = None | |
| 211 | + try: | |
| 212 | + seats = int(extra["passengers"]) if extra.get("passengers") else None | |
| 213 | + except (TypeError, ValueError): | |
| 214 | + seats = None | |
| 215 | + | |
| 216 | + return Vehicle( | |
| 217 | + source=self.source_id, | |
| 218 | + external_id=vin, | |
| 219 | + url=f"{SITE}/auto-usage/{slug}/{vin}", | |
| 220 | + title=" ".join(str(x) for x in (year, make, model) if x), | |
| 221 | + make=make, | |
| 222 | + model=model, | |
| 223 | + trim=trim, | |
| 224 | + year=int(year) if year else None, | |
| 225 | + price=price, | |
| 226 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 227 | + mileage_km=km, | |
| 228 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 229 | + transmission=_fr(r.get("transmission")), | |
| 230 | + fuel=_fr(r.get("fuelType")), | |
| 231 | + drivetrain=drivetrain, | |
| 232 | + body_type=_fr(r.get("body")), | |
| 233 | + exterior_color=_fr(r.get("exteriorColor")), | |
| 234 | + interior_color=interior, | |
| 235 | + engine=engine, | |
| 236 | + doors=doors, | |
| 237 | + seats=seats, | |
| 238 | + vin=vin, | |
| 239 | + stock_number=str(r.get("stockNumber") or ""), | |
| 240 | + dealer_name=self.dealer_name, | |
| 241 | + city=_fr(r.get("store")), | |
| 242 | + description=description[:4000], | |
| 243 | + features=features[:80], | |
| 244 | + details=details, | |
| 245 | + images=images[:30], | |
| 246 | + ) | |
added
apps/auto/autoka/connectors/base.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 | +# 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 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 22 | + | |
| 23 | + | |
| 24 | +class BaseConnector: | |
| 25 | + """Un connecteur = un adaptateur propre à un site de concessionnaire. | |
| 26 | + | |
| 27 | + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne | |
| 28 | + la liste complète des véhicules actuellement affichés sur le site. | |
| 29 | + Le pipeline (ingest.py) s'occupe du diff avec la base de données. | |
| 30 | + """ | |
| 31 | + | |
| 32 | + source_id: str = "" | |
| 33 | + request_delay: float = 0.6 # politesse entre requêtes | |
| 34 | + timeout: int = 30 | |
| 35 | + use_detail_cache: bool = True # cache BD des pages détail | |
| 36 | + | |
| 37 | + def __init__(self) -> None: | |
| 38 | + self.session = requests.Session() | |
| 39 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 40 | + self._last_request = 0.0 | |
| 41 | + self._detail_con = None | |
| 42 | + | |
| 43 | + # -- backends ------------------------------------------------------------- | |
| 44 | + def get(self, url: str, **kw) -> requests.Response: | |
| 45 | + """GET direct avec throttling poli. | |
| 46 | + | |
| 47 | + Retry (2×) sur les connexions keep-alive fermées par le serveur — | |
| 48 | + fréquent chez D2C Media / SM360 après une période d'inactivité. | |
| 49 | + """ | |
| 50 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 51 | + if wait > 0: | |
| 52 | + time.sleep(wait) | |
| 53 | + last_exc: Exception | None = None | |
| 54 | + for attempt in range(3): | |
| 55 | + try: | |
| 56 | + resp = self.session.get(url, timeout=self.timeout, **kw) | |
| 57 | + self._last_request = time.time() | |
| 58 | + resp.raise_for_status() | |
| 59 | + return resp | |
| 60 | + except (requests.exceptions.ConnectionError, | |
| 61 | + requests.exceptions.ChunkedEncodingError) as exc: | |
| 62 | + last_exc = exc | |
| 63 | + self.session.close() # repartir sur une connexion neuve | |
| 64 | + time.sleep(1.5 * (attempt + 1)) | |
| 65 | + self._last_request = time.time() | |
| 66 | + raise last_exc # type: ignore[misc] | |
| 67 | + | |
| 68 | + def get_rendered(self, url: str, wait_ms: int = 0) -> str: | |
| 69 | + """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl. | |
| 70 | + | |
| 71 | + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). | |
| 72 | + À utiliser pour les sites SPA ou derrière Cloudflare. | |
| 73 | + """ | |
| 74 | + key = os.environ.get("FIRECRAWL_API_KEY") | |
| 75 | + if not key: | |
| 76 | + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)") | |
| 77 | + payload: dict = {"url": url, "formats": ["html"]} | |
| 78 | + if wait_ms: | |
| 79 | + payload["waitFor"] = wait_ms | |
| 80 | + resp = requests.post( | |
| 81 | + FIRECRAWL_API, | |
| 82 | + json=payload, | |
| 83 | + headers={"Authorization": f"Bearer {key}"}, | |
| 84 | + timeout=120, | |
| 85 | + ) | |
| 86 | + resp.raise_for_status() | |
| 87 | + data = resp.json() | |
| 88 | + return (data.get("data") or {}).get("html", "") | |
| 89 | + | |
| 90 | + def get_scrapfly(self, url: str, render_js: bool = False, | |
| 91 | + asp: bool = True) -> str: | |
| 92 | + """Récupère une page via Scrapfly (anti-bot robuste, rendu JS optionnel). | |
| 93 | + | |
| 94 | + À privilégier sur Firecrawl quand le site bloque, est lent, ou que le | |
| 95 | + rendu Firecrawl perd de l'information. Nécessite SCRAPFLY_API_KEY. | |
| 96 | + - asp=True : contournement anti-bot (Cloudflare, etc.) | |
| 97 | + - render_js=True : exécute le JavaScript (SPA) | |
| 98 | + """ | |
| 99 | + key = os.environ.get("SCRAPFLY_API_KEY") | |
| 100 | + if not key: | |
| 101 | + raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)") | |
| 102 | + params = { | |
| 103 | + "key": key, | |
| 104 | + "url": url, | |
| 105 | + "country": "ca", | |
| 106 | + } | |
| 107 | + if asp: | |
| 108 | + params["asp"] = "true" | |
| 109 | + if render_js: | |
| 110 | + params["render_js"] = "true" | |
| 111 | + resp = requests.get(SCRAPFLY_API, params=params, timeout=120) | |
| 112 | + resp.raise_for_status() | |
| 113 | + data = resp.json() | |
| 114 | + result = data.get("result") or {} | |
| 115 | + return result.get("content", "") | |
| 116 | + | |
| 117 | + def detail(self, external_id: str, key: str, fetch_fn) -> dict: | |
| 118 | + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si | |
| 119 | + l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé. | |
| 120 | + | |
| 121 | + Permet d'extraire les champs riches (description, équipements, VIN…) | |
| 122 | + sans revisiter chaque page détail à chaque synchronisation. | |
| 123 | + `fetch_fn` doit retourner un dict JSON-sérialisable. | |
| 124 | + """ | |
| 125 | + if not self.use_detail_cache: | |
| 126 | + return fetch_fn() or {} | |
| 127 | + from .. import db | |
| 128 | + if self._detail_con is None: | |
| 129 | + self._detail_con = db.connect() | |
| 130 | + cached = db.get_cached_detail(self._detail_con, self.source_id, | |
| 131 | + str(external_id), key) | |
| 132 | + if cached is not None: | |
| 133 | + return cached | |
| 134 | + payload = fetch_fn() or {} | |
| 135 | + db.put_cached_detail(self._detail_con, self.source_id, | |
| 136 | + str(external_id), key, payload) | |
| 137 | + return payload | |
| 138 | + | |
| 139 | + # -- contrat -------------------------------------------------------------- | |
| 140 | + def fetch(self) -> list[Vehicle]: | |
| 141 | + raise NotImplementedError | |
added
apps/auto/autoka/connectors/central_dealers.py
+330 −0
@@ -0,0 +1,330 @@ | ||
| 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" | |
| 306 | + | |
| 307 | + | |
| 308 | +# --------------------------------------------------------------------------- | |
| 309 | +# Expansion 3 — Bas-Saint-Laurent et île de Montréal | |
| 310 | +# --------------------------------------------------------------------------- | |
| 311 | + | |
| 312 | +class AutoBSL(CentralConnector): | |
| 313 | + source_id = "autobsl" | |
| 314 | + base_url = "https://www.autobsl.com" | |
| 315 | + dealer_name = "Auto BSL" | |
| 316 | + city = "Rimouski" | |
| 317 | + | |
| 318 | + | |
| 319 | +class AutoXtreme(CentralConnector): | |
| 320 | + source_id = "autoxtreme" | |
| 321 | + base_url = "https://www.autoxtreme.ca" | |
| 322 | + dealer_name = "Auto Xtrême" | |
| 323 | + city = "Montréal" | |
| 324 | + | |
| 325 | + | |
| 326 | +class DAstousAuto(CentralConnector): | |
| 327 | + source_id = "dastousauto" | |
| 328 | + base_url = "https://www.dastousauto.com" | |
| 329 | + dealer_name = "D'Astous Auto" | |
| 330 | + city = "Montréal" | |
added
apps/auto/autoka/connectors/classeauto.py
+172 −0
@@ -0,0 +1,172 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/classeauto.py : connecteur Classe Auto (classeauto.ca), | |
| 5 | +# marchand d'occasion de l'île de Montréal (boul. Industriel, | |
| 6 | +# Montréal-Nord/Anjou) — ~140 véhicules. | |
| 7 | +# | |
| 8 | +# Plateforme : WordPress (thème Astra) + moteur d'inventaire DriveGood | |
| 9 | +# (cdn.drivegood.com). Le site expose un export JSON STATIQUE de tout | |
| 10 | +# l'inventaire : | |
| 11 | +# /wp-content/themes/astra/car_single_page_data/cars_formatted.json | |
| 12 | +# — une seule requête pour la liste complète, avec TOUS les champs : | |
| 13 | +# prix, km, VIN, stock, transmission, carburant, motricité, carrosserie, | |
| 14 | +# couleurs, portes, passagers, moteur, options, photos (cdn.drivegood.com), | |
| 15 | +# description. Aucune page détail nécessaire. | |
| 16 | +# | |
| 17 | +# Filtres de sécurité : vehicle_type_id == 1 (automobile), post_status | |
| 18 | +# publish/published, condition != NEW, deleted_at vide. | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import re | |
| 23 | + | |
| 24 | +from ..schema import Vehicle | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +_JSON_PATH = "/wp-content/themes/astra/car_single_page_data/cars_formatted.json" | |
| 28 | + | |
| 29 | +# garde-fou non-automobile (le marchand ne vend que des autos) | |
| 30 | +_EXCLUDE_RE = re.compile( | |
| 31 | + r"motoneige|vtt|moto\b|motomarine|bateau|roulotte|remorque|spyder", re.I) | |
| 32 | + | |
| 33 | + | |
| 34 | +def _s(value) -> str: | |
| 35 | + if value is None: | |
| 36 | + return "" | |
| 37 | + return re.sub(r"\s+", " ", str(value)).strip() | |
| 38 | + | |
| 39 | + | |
| 40 | +def _num(value) -> float | None: | |
| 41 | + try: | |
| 42 | + f = float(value) | |
| 43 | + except (TypeError, ValueError): | |
| 44 | + return None | |
| 45 | + return f if f > 0 else None | |
| 46 | + | |
| 47 | + | |
| 48 | +def _int(value) -> int | None: | |
| 49 | + try: | |
| 50 | + return int(str(value)) | |
| 51 | + except (TypeError, ValueError): | |
| 52 | + return None | |
| 53 | + | |
| 54 | + | |
| 55 | +class ClasseAuto(BaseConnector): | |
| 56 | + """Classe Auto (Montréal) — inventaire complet en un JSON statique.""" | |
| 57 | + | |
| 58 | + source_id = "classeauto" | |
| 59 | + base_url = "https://www.classeauto.ca" | |
| 60 | + dealer_name = "Classe Auto" | |
| 61 | + city = "Montréal" | |
| 62 | + request_delay = 1.0 | |
| 63 | + | |
| 64 | + def fetch(self) -> list[Vehicle]: | |
| 65 | + # cache-buster comme le fait le site (nouvelle valeur par minute) | |
| 66 | + data = self.get(self.base_url + _JSON_PATH).json() | |
| 67 | + vehicles: list[Vehicle] = [] | |
| 68 | + for d in data if isinstance(data, list) else []: | |
| 69 | + try: | |
| 70 | + veh = self._to_vehicle(d) | |
| 71 | + except Exception: | |
| 72 | + continue # enregistrement malformé isolé | |
| 73 | + if veh is not None: | |
| 74 | + vehicles.append(veh) | |
| 75 | + return vehicles | |
| 76 | + | |
| 77 | + def _to_vehicle(self, d: dict) -> Vehicle | None: | |
| 78 | + if not isinstance(d, dict): | |
| 79 | + return None | |
| 80 | + if d.get("vehicle_type_id") not in (1, "1", None): | |
| 81 | + return None # 1 = automobile | |
| 82 | + if _s(d.get("post_status")) not in ("publish", "published", ""): | |
| 83 | + return None | |
| 84 | + if d.get("deleted_at"): | |
| 85 | + return None | |
| 86 | + if _s(d.get("condition")).upper() == "NEW": | |
| 87 | + return None # usagé seulement | |
| 88 | + | |
| 89 | + ext_id = _s(d.get("vid")) or _s(d.get("ID")) | |
| 90 | + if not ext_id: | |
| 91 | + return None | |
| 92 | + | |
| 93 | + make = _s(d.get("maker")).title() | |
| 94 | + model = _s(d.get("model")) | |
| 95 | + trim = _s(d.get("car_trim")) or _s(d.get("car_sub_model")) | |
| 96 | + year = _int(d.get("car_year")) | |
| 97 | + body = _s(d.get("car_body")) | |
| 98 | + if _EXCLUDE_RE.search(f"{make} {model} {body}"): | |
| 99 | + return None | |
| 100 | + | |
| 101 | + title = " ".join(x for x in (str(year or ""), make, model, trim) if x) | |
| 102 | + | |
| 103 | + price = _num(d.get("car_price")) | |
| 104 | + km = _num(d.get("car_mileage")) | |
| 105 | + unit = _s(d.get("car_mileage_unit")).upper() | |
| 106 | + if km and unit.startswith("MI"): | |
| 107 | + km = round(km * 1.609344) | |
| 108 | + | |
| 109 | + slug = _s(d.get("slug")) | |
| 110 | + url = (f"{self.base_url}/cars/{slug}/" if slug | |
| 111 | + else _s(d.get("guid")) or self.base_url) | |
| 112 | + | |
| 113 | + images = [u.strip() for u in _s(d.get("photos")).split(",") | |
| 114 | + if u.strip().startswith("http")][:20] | |
| 115 | + | |
| 116 | + engine = "" | |
| 117 | + size = _s(d.get("car_engine_size")) | |
| 118 | + cyl = _s(d.get("car_cylinders")) | |
| 119 | + if size: | |
| 120 | + engine = f"{size}L" + (f" {cyl} cyl." if cyl else "") | |
| 121 | + | |
| 122 | + features = [f.strip().replace("_", " ") | |
| 123 | + for f in _s(d.get("car_options")).split(",") if f.strip()] | |
| 124 | + | |
| 125 | + details: dict = {} | |
| 126 | + if d.get("car_no_accident"): | |
| 127 | + details["no_accident"] = True | |
| 128 | + if d.get("car_one_owner"): | |
| 129 | + details["unique_owner"] = True | |
| 130 | + old_price = _num(d.get("car_old_price")) | |
| 131 | + if old_price and price and old_price > price: | |
| 132 | + details["old_price"] = old_price | |
| 133 | + | |
| 134 | + carfax = _s(d.get("carfax_url")) or _s(d.get("carfax_report")) | |
| 135 | + if not carfax: # parfois rangé dans admin_note | |
| 136 | + m = re.search(r"https?://vhr\.carfax\.ca/\S+", | |
| 137 | + _s(d.get("admin_note"))) | |
| 138 | + if m: | |
| 139 | + carfax = m.group(0) | |
| 140 | + | |
| 141 | + return Vehicle( | |
| 142 | + source=self.source_id, | |
| 143 | + external_id=ext_id, | |
| 144 | + url=url, | |
| 145 | + title=title, | |
| 146 | + make=make, | |
| 147 | + model=model, | |
| 148 | + trim=trim, | |
| 149 | + year=year, | |
| 150 | + price=price, | |
| 151 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 152 | + mileage_km=km, | |
| 153 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 154 | + transmission=_s(d.get("car_transmission")), | |
| 155 | + fuel=_s(d.get("car_fuel_type")), | |
| 156 | + drivetrain=_s(d.get("car_drivetrain")), | |
| 157 | + body_type=body, | |
| 158 | + exterior_color=_s(d.get("car_exterior_color")).title(), | |
| 159 | + interior_color=_s(d.get("car_interrior_color")).title(), | |
| 160 | + engine=engine, | |
| 161 | + doors=_int(d.get("car_doors_count")), | |
| 162 | + seats=_int(d.get("number_of_passengers")), | |
| 163 | + vin=_s(d.get("car_vin")).upper(), | |
| 164 | + stock_number=_s(d.get("stock")), | |
| 165 | + dealer_name=self.dealer_name, | |
| 166 | + city=self.city, | |
| 167 | + description=_s(d.get("post_content"))[:4000], | |
| 168 | + features=features[:60], | |
| 169 | + details=details, | |
| 170 | + images=images, | |
| 171 | + carfax_url=carfax, | |
| 172 | + ) | |
added
apps/auto/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
apps/auto/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
apps/auto/autoka/connectors/d2c_dealers.py
+833 −0
@@ -0,0 +1,833 @@ | ||
| 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 | +# Enrichissement (clé de cache v2) — en plus du JSON-LD (qui ne liste que | |
| 16 | +# 3 photos, parfois avec des ids corrompus — bug D2C) : | |
| 17 | +# - galerie COMPLÈTE : les URLs imagescdn.d2cmedia.ca/<hash>/<dealer>/<id>/ | |
| 18 | +# <n>/… présentes dans le HTML (préférence à la variante « cb » grand | |
| 19 | +# format, sinon « mb »/« s8 »), triées par index de photo ; | |
| 20 | +# - endpoint JSON par véhicule /js/json/<id>.json (celui que le JS du site | |
| 21 | +# appelle pour révéler la section « Options ») : optionsTextFR (liste | |
| 22 | +# d'équipements en français), moteur + cylindre, carprooflink (rapport | |
| 23 | +# Carfax direct quand le concessionnaire l'expose) ; | |
| 24 | +# - repli équipements : titres des topFeatures/safetyFeatures du blob | |
| 25 | +# window.__vdpJSON embarqué dans la page. | |
| 26 | +# | |
| 27 | +# Le lastmod du sitemap est bumpé quotidiennement par D2C (inutilisable | |
| 28 | +# comme clé de cache) — d'où la clé temporelle hebdomadaire. | |
| 29 | +# ----------------------------------------------------------------------------- | |
| 30 | +from __future__ import annotations | |
| 31 | + | |
| 32 | +import datetime | |
| 33 | +import html as htmllib | |
| 34 | +import json | |
| 35 | +import os | |
| 36 | +import re | |
| 37 | + | |
| 38 | +from ..schema import Vehicle | |
| 39 | +from .base import BaseConnector | |
| 40 | + | |
| 41 | +# segments d'URL /occasion/<segment>/ qui ne sont PAS des automobiles | |
| 42 | +_EXCLUDE_SEGMENTS = re.compile( | |
| 43 | + r"motoneige|vtt|bateau|ponton|motomarine|moto(?:s)?\b|roulotte|remorque|" | |
| 44 | + r"\bvr\b|campeur|tracteur|souffleuse|scooter|spyder|side|atv|snow|" | |
| 45 | + r"powersport|nautique|habitation", re.I) | |
| 46 | + | |
| 47 | +_VEH_URL_RE = re.compile(r"-id(\d+)\.html?$") | |
| 48 | + | |
| 49 | +_DRIVE_MAP = { | |
| 50 | + "FrontWheelDriveConfiguration": "Traction", | |
| 51 | + "RearWheelDriveConfiguration": "Propulsion", | |
| 52 | + "AllWheelDriveConfiguration": "Intégrale / 4x4", | |
| 53 | + "FourWheelDriveConfiguration": "Intégrale / 4x4", | |
| 54 | +} | |
| 55 | + | |
| 56 | +_LD_RE = re.compile( | |
| 57 | + r'<script type=["\']application/ld\+json["\']>(.*?)</script>', re.S) | |
| 58 | + | |
| 59 | +# galerie CDN D2C : /<variante(2)+hash>/<dealer>/<idVéhicule>/<indexPhoto>/… | |
| 60 | +# (le nom de fichier peut contenir des espaces : « Silverado 1500-2023.jpg ») | |
| 61 | +_GALLERY_RE = re.compile( | |
| 62 | + r'(https://imagescdn\.d2cmedia\.ca/' | |
| 63 | + r'([a-z0-9]{2})[a-f0-9]+/\d+/(\d+)/(\d+)/[^"\'<>]+?\.(?:jpe?g|png|webp))', | |
| 64 | + re.I) | |
| 65 | + | |
| 66 | +# variante d'image préférée : cb = grand format (1440px), mb = moyen, s8 = mini | |
| 67 | +_IMG_VARIANT_RANK = {"cb": 0, "mb": 1, "s8": 2} | |
| 68 | + | |
| 69 | +_VDP_JSON_RE = re.compile(r'window\.__vdpJSON\s*=\s*') | |
| 70 | + | |
| 71 | +MAX_IMAGES = 25 | |
| 72 | + | |
| 73 | + | |
| 74 | +def _extract_gallery(html: str, ext_id: str) -> list[str]: | |
| 75 | + """Galerie complète depuis le HTML, dédupliquée par index de photo, | |
| 76 | + dans l'ordre de la galerie (index croissant), meilleure variante d'abord. | |
| 77 | + """ | |
| 78 | + best: dict[int, tuple[int, str]] = {} # index photo -> (rang, url) | |
| 79 | + for url, variant, veh_id, idx in _GALLERY_RE.findall(html): | |
| 80 | + if veh_id != ext_id: # vignettes d'autres véhicules | |
| 81 | + continue | |
| 82 | + try: | |
| 83 | + idx = int(idx) | |
| 84 | + except ValueError: | |
| 85 | + continue | |
| 86 | + rank = _IMG_VARIANT_RANK.get(variant.lower(), 3) | |
| 87 | + if idx not in best or rank < best[idx][0]: | |
| 88 | + best[idx] = (rank, url.replace(" ", "%20")) | |
| 89 | + return [best[i][1] for i in sorted(best)][:MAX_IMAGES] | |
| 90 | + | |
| 91 | + | |
| 92 | +def _extract_vdp_json(html: str) -> dict: | |
| 93 | + """Blob `window.__vdpJSON = {...}` embarqué dans la page (si présent).""" | |
| 94 | + m = _VDP_JSON_RE.search(html) | |
| 95 | + if not m: | |
| 96 | + return {} | |
| 97 | + try: | |
| 98 | + obj, _ = json.JSONDecoder().raw_decode(html, m.end()) | |
| 99 | + except ValueError: | |
| 100 | + return {} | |
| 101 | + return obj if isinstance(obj, dict) else {} | |
| 102 | + | |
| 103 | + | |
| 104 | +def _clean(text: str | None) -> str: | |
| 105 | + """Désencode les entités HTML (souvent doublement encodées chez D2C).""" | |
| 106 | + if not text: | |
| 107 | + return "" | |
| 108 | + out = htmllib.unescape(htmllib.unescape(str(text))) | |
| 109 | + out = re.sub(r"<[^>]+>", " ", out) | |
| 110 | + return re.sub(r"\s{2,}", " ", out).strip() | |
| 111 | + | |
| 112 | + | |
| 113 | +class D2CConnector(BaseConnector): | |
| 114 | + """Base commune aux sites D2C Media. Sous-classes : base_url + ville.""" | |
| 115 | + | |
| 116 | + base_url: str = "" | |
| 117 | + dealer_name: str = "" | |
| 118 | + city: str = "" | |
| 119 | + request_delay: float = 1.0 # robots.txt D2C demande de la politesse | |
| 120 | + use_firecrawl: bool = False # sites derrière Cloudflare | |
| 121 | + | |
| 122 | + # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit | |
| 123 | + # pour les tests) — le reste sera complété aux syncs suivants | |
| 124 | + max_details = 600 | |
| 125 | + | |
| 126 | + def _get_text(self, url: str) -> str: | |
| 127 | + if self.use_firecrawl: | |
| 128 | + return self.get_rendered(url) | |
| 129 | + return self.get(url).text | |
| 130 | + | |
| 131 | + # -- sitemap --------------------------------------------------------------- | |
| 132 | + def _vehicle_urls(self) -> list[str]: | |
| 133 | + xml = self._get_text(f"{self.base_url}/fr/sitemap.xml") | |
| 134 | + urls = [] | |
| 135 | + for loc in re.findall(r"<loc>([^<]+)</loc>", xml): | |
| 136 | + loc = loc.strip() | |
| 137 | + if "/occasion/" not in loc or not _VEH_URL_RE.search(loc): | |
| 138 | + continue | |
| 139 | + # catégorie intermédiaire ? (/occasion/motoneiges/…) | |
| 140 | + path = loc.split("/occasion/", 1)[1] | |
| 141 | + segments = path.split("/")[:-1] | |
| 142 | + if any(_EXCLUDE_SEGMENTS.search(s) for s in segments): | |
| 143 | + continue | |
| 144 | + urls.append(loc) | |
| 145 | + return urls | |
| 146 | + | |
| 147 | + # -- page détail -> payload JSON-LD enrichi -------------------------------- | |
| 148 | + def _fetch_detail(self, url: str) -> dict: | |
| 149 | + html = self._get_text(url) | |
| 150 | + data: dict = {} | |
| 151 | + for block in _LD_RE.findall(html): | |
| 152 | + try: | |
| 153 | + cand = json.loads(block.strip()) | |
| 154 | + except ValueError: | |
| 155 | + continue | |
| 156 | + types = cand.get("@type") | |
| 157 | + types = types if isinstance(types, list) else [types] | |
| 158 | + if "Vehicle" in types or "Car" in types: | |
| 159 | + data = cand | |
| 160 | + break | |
| 161 | + if not data: | |
| 162 | + return {} | |
| 163 | + | |
| 164 | + # --- enrichissements (tous optionnels : jamais bloquants) ------------- | |
| 165 | + m = _VEH_URL_RE.search(url) | |
| 166 | + ext_id = m.group(1) if m else "" | |
| 167 | + | |
| 168 | + try: | |
| 169 | + gallery = _extract_gallery(html, ext_id) | |
| 170 | + except Exception: | |
| 171 | + gallery = [] | |
| 172 | + if gallery: | |
| 173 | + data["_gallery"] = gallery | |
| 174 | + | |
| 175 | + vdp = {} | |
| 176 | + try: | |
| 177 | + vdp = _extract_vdp_json(html) | |
| 178 | + except Exception: | |
| 179 | + pass | |
| 180 | + | |
| 181 | + # endpoint JSON par véhicule : équipements, moteur, lien Carfax | |
| 182 | + extra: dict = {} | |
| 183 | + if ext_id: | |
| 184 | + try: | |
| 185 | + extra = self.get(f"{self.base_url}/js/json/{ext_id}.json").json() | |
| 186 | + if not isinstance(extra, dict): | |
| 187 | + extra = {} | |
| 188 | + except Exception: | |
| 189 | + extra = {} | |
| 190 | + | |
| 191 | + features = [_clean(t) for t in (extra.get("optionsTextFR") or []) | |
| 192 | + if isinstance(t, str) and _clean(t)] | |
| 193 | + if not features: # repli : blob __vdpJSON de la page | |
| 194 | + for group in (vdp.get("topFeatures"), vdp.get("safetyFeatures")): | |
| 195 | + for item in group or []: | |
| 196 | + if isinstance(item, dict): | |
| 197 | + t = _clean(item.get("title")) | |
| 198 | + if t and t not in features: | |
| 199 | + features.append(t) | |
| 200 | + if features: | |
| 201 | + data["_features"] = features | |
| 202 | + | |
| 203 | + engine = _clean(str(extra.get("moteur") or vdp.get("engine") or "")) | |
| 204 | + # D2C renvoie « N.D. » ou « . L » (véhicules électriques) : rejeter | |
| 205 | + # toute cylindrée sans chiffre | |
| 206 | + if engine.upper() in ("N.D.", "N/A", "ND") or not re.search(r"\d", engine): | |
| 207 | + engine = "" | |
| 208 | + cyl = _clean(str(extra.get("cylindre") or vdp.get("cylinder") or "")) | |
| 209 | + if cyl and cyl.upper() not in ("N.D.", "N/A", "ND", "0"): | |
| 210 | + if cyl.isdigit(): | |
| 211 | + # nombre nu (parfois erroné pour les VÉ) : seulement en | |
| 212 | + # complément d'une cylindrée valide, jamais seul | |
| 213 | + if engine: | |
| 214 | + engine = f"{engine} {cyl} cyl." | |
| 215 | + elif cyl.lower() not in engine.lower(): | |
| 216 | + engine = f"{engine} {cyl}".strip() | |
| 217 | + if engine: | |
| 218 | + data["_engine"] = engine | |
| 219 | + | |
| 220 | + carfax = str(extra.get("carprooflink") or "").strip() | |
| 221 | + if not carfax: | |
| 222 | + cp = vdp.get("carproof") | |
| 223 | + if isinstance(cp, dict): | |
| 224 | + carfax = str(cp.get("link") or "").strip() | |
| 225 | + if carfax.startswith("http"): | |
| 226 | + data["_carfax_url"] = carfax | |
| 227 | + | |
| 228 | + return data | |
| 229 | + | |
| 230 | + # -- contrat --------------------------------------------------------------- | |
| 231 | + def fetch(self) -> list[Vehicle]: | |
| 232 | + urls = self._vehicle_urls() | |
| 233 | + week = datetime.date.today().isocalendar() | |
| 234 | + # v2 : payloads enrichis (galerie complète, équipements, moteur, | |
| 235 | + # carfax) — le bump force la re-crawl progressive des pages en cache | |
| 236 | + cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire | |
| 237 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 238 | + | |
| 239 | + vehicles: list[Vehicle] = [] | |
| 240 | + real_fetches = 0 | |
| 241 | + for url in urls: | |
| 242 | + ext_id = _VEH_URL_RE.search(url).group(1) | |
| 243 | + | |
| 244 | + def _fetch(u=url): | |
| 245 | + return self._fetch_detail(u) | |
| 246 | + | |
| 247 | + # plafond atteint : ne servir que le cache (fetch réel interdit) | |
| 248 | + if real_fetches >= cap: | |
| 249 | + from .. import db | |
| 250 | + if self._detail_con is None: | |
| 251 | + self._detail_con = db.connect() | |
| 252 | + data = db.get_cached_detail(self._detail_con, self.source_id, | |
| 253 | + ext_id, cache_key) | |
| 254 | + if data is None: | |
| 255 | + continue | |
| 256 | + else: | |
| 257 | + before = self._last_request | |
| 258 | + try: | |
| 259 | + data = self.detail(ext_id, cache_key, _fetch) | |
| 260 | + except Exception: | |
| 261 | + # page détail disparue (vendu, sitemap pas encore purgé) | |
| 262 | + # ou erreur ponctuelle : ne jamais bloquer la source | |
| 263 | + if self._last_request != before: | |
| 264 | + real_fetches += 1 | |
| 265 | + continue | |
| 266 | + if self._last_request != before: # une vraie requête a eu lieu | |
| 267 | + real_fetches += 1 | |
| 268 | + if not data: | |
| 269 | + continue | |
| 270 | + veh = self._to_vehicle(ext_id, url, data) | |
| 271 | + if veh is not None: | |
| 272 | + vehicles.append(veh) | |
| 273 | + return vehicles | |
| 274 | + | |
| 275 | + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: | |
| 276 | + offers = d.get("offers") or {} | |
| 277 | + price = offers.get("price") | |
| 278 | + try: | |
| 279 | + price = float(price) if price not in (None, "", 0, "0") else None | |
| 280 | + except (TypeError, ValueError): | |
| 281 | + price = None | |
| 282 | + | |
| 283 | + odo = d.get("mileageFromOdometer") or {} | |
| 284 | + km = odo.get("value") if isinstance(odo, dict) else odo | |
| 285 | + try: | |
| 286 | + km = float(km) if km not in (None, "") else None | |
| 287 | + except (TypeError, ValueError): | |
| 288 | + km = None | |
| 289 | + | |
| 290 | + brand = d.get("brand") or {} | |
| 291 | + make = brand.get("name") if isinstance(brand, dict) else str(brand) | |
| 292 | + | |
| 293 | + drive_raw = str(d.get("driveWheelConfiguration") or "") | |
| 294 | + drivetrain = "" | |
| 295 | + for key, canon in _DRIVE_MAP.items(): | |
| 296 | + if key in drive_raw: | |
| 297 | + drivetrain = canon | |
| 298 | + break | |
| 299 | + | |
| 300 | + year = None | |
| 301 | + if d.get("vehicleModelDate"): | |
| 302 | + try: | |
| 303 | + year = int(str(d["vehicleModelDate"])[:4]) | |
| 304 | + except ValueError: | |
| 305 | + pass | |
| 306 | + | |
| 307 | + # galerie complète extraite du HTML (payloads v2) de préférence au | |
| 308 | + # JSON-LD (limité à 3 photos, ids parfois corrompus — bug D2C) | |
| 309 | + images = d.get("_gallery") or d.get("image") or [] | |
| 310 | + if isinstance(images, str): | |
| 311 | + images = [images] | |
| 312 | + | |
| 313 | + title = _clean(d.get("name")) | |
| 314 | + if not title: | |
| 315 | + return None | |
| 316 | + | |
| 317 | + return Vehicle( | |
| 318 | + source=self.source_id, | |
| 319 | + external_id=ext_id, | |
| 320 | + url=url, | |
| 321 | + title=title, | |
| 322 | + make=_clean(make), | |
| 323 | + model=_clean(d.get("model")), | |
| 324 | + trim=_clean(d.get("vehicleConfiguration")), | |
| 325 | + year=year, | |
| 326 | + price=price, | |
| 327 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 328 | + mileage_km=km, | |
| 329 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 330 | + transmission=_clean(d.get("vehicleTransmission")), | |
| 331 | + fuel=_clean(d.get("fuelType")), | |
| 332 | + drivetrain=drivetrain, | |
| 333 | + body_type=_clean(d.get("bodyType")), | |
| 334 | + exterior_color=_clean(d.get("color")), | |
| 335 | + interior_color=_clean(d.get("vehicleInteriorColor")), | |
| 336 | + doors=d.get("numberOfDoors"), | |
| 337 | + seats=d.get("vehicleSeatingCapacity"), | |
| 338 | + vin=_clean(d.get("vehicleIdentificationNumber")), | |
| 339 | + stock_number=str(d.get("sku") or ""), | |
| 340 | + engine=_clean(d.get("_engine")), | |
| 341 | + dealer_name=self.dealer_name, | |
| 342 | + city=self.city, | |
| 343 | + description=_clean(d.get("description"))[:4000], | |
| 344 | + features=[f for f in (d.get("_features") or []) | |
| 345 | + if isinstance(f, str) and f], | |
| 346 | + images=[u for u in images if isinstance(u, str)][:MAX_IMAGES], | |
| 347 | + carfax_url=str(d.get("_carfax_url") or ""), | |
| 348 | + ) | |
| 349 | + | |
| 350 | + | |
| 351 | +# --------------------------------------------------------------------------- | |
| 352 | +# Concessionnaires (1 sous-classe = 1 source) | |
| 353 | +# --------------------------------------------------------------------------- | |
| 354 | + | |
| 355 | +class GirardAutos(D2CConnector): | |
| 356 | + source_id = "girardautos" | |
| 357 | + base_url = "https://www.girardautos.ca" | |
| 358 | + dealer_name = "Girard Autos" | |
| 359 | + city = "Trois-Rivières" | |
| 360 | + | |
| 361 | + | |
| 362 | +class ForceOccasion(D2CConnector): | |
| 363 | + source_id = "forceoccasion" | |
| 364 | + base_url = "https://www.forceoccasion.ca" | |
| 365 | + dealer_name = "Force Occasion" | |
| 366 | + city = "Québec" | |
| 367 | + | |
| 368 | + | |
| 369 | +class GervaisAuto(D2CConnector): | |
| 370 | + source_id = "gervaisauto" | |
| 371 | + base_url = "https://www.gervaisauto.com" | |
| 372 | + dealer_name = "Gervais Auto" | |
| 373 | + city = "Shawinigan" | |
| 374 | + | |
| 375 | + | |
| 376 | +class EquipeFormule(D2CConnector): | |
| 377 | + source_id = "equipeformule" | |
| 378 | + base_url = "https://www.equipeformule.com" | |
| 379 | + dealer_name = "Équipe Formule" | |
| 380 | + city = "Rimouski" | |
| 381 | + | |
| 382 | + | |
| 383 | +class AutosEvasion(D2CConnector): | |
| 384 | + source_id = "autosevasion" | |
| 385 | + base_url = "https://www.autosevasion.com" | |
| 386 | + dealer_name = "Autos Évasion" | |
| 387 | + city = "Sherbrooke" | |
| 388 | + | |
| 389 | + | |
| 390 | +class MFerlandAuto(D2CConnector): | |
| 391 | + source_id = "mferlandauto" | |
| 392 | + base_url = "https://www.mferlandauto.com" | |
| 393 | + dealer_name = "M. Ferland Auto" | |
| 394 | + city = "Sherbrooke" | |
| 395 | + | |
| 396 | + | |
| 397 | +class AutosOccasionLD(D2CConnector): | |
| 398 | + source_id = "autosoccasionld" | |
| 399 | + base_url = "https://www.autosoccasions.ca" | |
| 400 | + dealer_name = "Autos Occasion LD" | |
| 401 | + city = "Lévis" | |
| 402 | + | |
| 403 | + | |
| 404 | +class LevisAutos(D2CConnector): | |
| 405 | + source_id = "levisautos" | |
| 406 | + base_url = "https://www.levisautos.com" | |
| 407 | + dealer_name = "Lévis Autos" | |
| 408 | + city = "Lévis" | |
| 409 | + | |
| 410 | + | |
| 411 | +class AutoQuebecLevis(D2CConnector): | |
| 412 | + source_id = "autoquebeclevis" | |
| 413 | + base_url = "https://www.autoquebecoccasionlevis.com" | |
| 414 | + dealer_name = "AutoQuébec Occasion Lévis" | |
| 415 | + city = "Lévis" | |
| 416 | + | |
| 417 | + | |
| 418 | +class AutoQuebecThetford(D2CConnector): | |
| 419 | + source_id = "autoquebectm" | |
| 420 | + base_url = "https://www.autoquebectm.com" | |
| 421 | + dealer_name = "AutoQuébec Occasion Thetford Mines" | |
| 422 | + city = "Thetford Mines" | |
| 423 | + | |
| 424 | + | |
| 425 | +class AbitibiAutoCredit(D2CConnector): | |
| 426 | + source_id = "abitibiautocredit" | |
| 427 | + base_url = "https://www.abitibiautocredit.ca" | |
| 428 | + dealer_name = "Abitibi Auto Crédit" | |
| 429 | + city = "Rouyn-Noranda" | |
| 430 | + | |
| 431 | + | |
| 432 | +class NicolOccasion(D2CConnector): | |
| 433 | + source_id = "nicoloccasion" | |
| 434 | + base_url = "https://www.nicoloccasion.com" | |
| 435 | + dealer_name = "Nicol Occasion" | |
| 436 | + city = "La Sarre" | |
| 437 | + | |
| 438 | + | |
| 439 | +class PoirierOccasion(D2CConnector): | |
| 440 | + source_id = "poirieroccasion" | |
| 441 | + base_url = "https://www.poirieroccasion.com" | |
| 442 | + dealer_name = "Poirier Occasion" | |
| 443 | + city = "Val-d'Or" | |
| 444 | + | |
| 445 | + | |
| 446 | +class AutosStHubert(D2CConnector): | |
| 447 | + source_id = "autosthubert" | |
| 448 | + base_url = "https://www.autosthubert.com" | |
| 449 | + dealer_name = "Auto St-Hubert" | |
| 450 | + city = "Saint-Hubert" | |
| 451 | + | |
| 452 | + | |
| 453 | +class SherbrookeAutoOccasion(D2CConnector): | |
| 454 | + source_id = "sherbrookeautooccasion" | |
| 455 | + base_url = "https://www.sherbrookeautooccasion.com" | |
| 456 | + dealer_name = "Sherbrooke Auto Occasion" | |
| 457 | + city = "Sherbrooke" | |
| 458 | + | |
| 459 | + | |
| 460 | +# --------------------------------------------------------------------------- | |
| 461 | +# Expansion 2 — concessionnaires de marque et groupes sur D2C Media | |
| 462 | +# (inventaire OCCASION seulement : le sitemap /fr/sitemap.xml ne référence | |
| 463 | +# que les pages /occasion/…-idNNN.html ; le neuf vit dans un sitemap séparé) | |
| 464 | +# --------------------------------------------------------------------------- | |
| 465 | + | |
| 466 | +class BernierCrepeau(D2CConnector): | |
| 467 | + source_id = "berniercrepeau" | |
| 468 | + base_url = "https://www.berniercrepeau.com" | |
| 469 | + dealer_name = "Bernier Crépeau" | |
| 470 | + city = "Trois-Rivières" | |
| 471 | + | |
| 472 | + | |
| 473 | +class ChomedeyToyota(D2CConnector): | |
| 474 | + source_id = "chomedeytoyota" | |
| 475 | + base_url = "https://www.chomedeytoyotalaval.ca" | |
| 476 | + dealer_name = "Chomedey Toyota Laval" | |
| 477 | + city = "Laval" | |
| 478 | + | |
| 479 | + | |
| 480 | +class DrummondvilleFord(D2CConnector): | |
| 481 | + source_id = "drummondvilleford" | |
| 482 | + base_url = "https://www.drummondvilleford.com" | |
| 483 | + dealer_name = "Drummondville Ford" | |
| 484 | + city = "Drummondville" | |
| 485 | + | |
| 486 | + | |
| 487 | +class DrummondvilleVW(D2CConnector): | |
| 488 | + source_id = "drummondvillevw" | |
| 489 | + base_url = "https://www.drummondvillevolkswagen.ca" | |
| 490 | + dealer_name = "Drummondville Volkswagen" | |
| 491 | + city = "Drummondville" | |
| 492 | + | |
| 493 | + | |
| 494 | +class DubeChrysler(D2CConnector): | |
| 495 | + source_id = "dubechrysler" | |
| 496 | + base_url = "https://www.dubechrysler.com" | |
| 497 | + dealer_name = "Dubé Chrysler" | |
| 498 | + city = "Rivière-du-Loup" | |
| 499 | + | |
| 500 | + | |
| 501 | +class DuclosLongueuil(D2CConnector): | |
| 502 | + source_id = "ducloslongueuil" | |
| 503 | + base_url = "https://www.ducloschryslerlongueuil.com" | |
| 504 | + dealer_name = "Duclos Longueuil Chrysler" | |
| 505 | + city = "Longueuil" | |
| 506 | + | |
| 507 | + | |
| 508 | +class DynastieHyundai(D2CConnector): | |
| 509 | + source_id = "dynastiehyundai" | |
| 510 | + base_url = "https://www.dynastiehyundai.com" | |
| 511 | + dealer_name = "Dynastie Hyundai" | |
| 512 | + city = "Rouyn-Noranda" | |
| 513 | + | |
| 514 | + | |
| 515 | +class FichaultKia(D2CConnector): | |
| 516 | + source_id = "fichaultkia" | |
| 517 | + base_url = "https://www.fichaultkia.com" | |
| 518 | + dealer_name = "Fichault Kia" | |
| 519 | + city = "Châteauguay" | |
| 520 | + | |
| 521 | + | |
| 522 | +class FordThetford(D2CConnector): | |
| 523 | + source_id = "fordthetford" | |
| 524 | + base_url = "https://www.fordthetford.com" | |
| 525 | + dealer_name = "Ford Thetford" | |
| 526 | + city = "Thetford Mines" | |
| 527 | + | |
| 528 | + | |
| 529 | +class GaspeToyota(D2CConnector): | |
| 530 | + source_id = "gaspetoyota" | |
| 531 | + base_url = "https://www.gaspe-toyota.com" | |
| 532 | + dealer_name = "Gaspé Toyota" | |
| 533 | + city = "Gaspé" | |
| 534 | + | |
| 535 | + | |
| 536 | +class GaspesieHyundai(D2CConnector): | |
| 537 | + source_id = "gaspesiehyundai" | |
| 538 | + base_url = "https://www.gaspesiehyundai.com" | |
| 539 | + dealer_name = "Gaspésie Hyundai" | |
| 540 | + city = "Bonaventure" | |
| 541 | + | |
| 542 | + | |
| 543 | +class GranbyToyota(D2CConnector): | |
| 544 | + source_id = "granbytoyota" | |
| 545 | + base_url = "https://www.granbytoyota.ca" | |
| 546 | + dealer_name = "Granby Toyota" | |
| 547 | + city = "Granby" | |
| 548 | + | |
| 549 | + | |
| 550 | +class GrandPortage(D2CConnector): | |
| 551 | + source_id = "grandportage" | |
| 552 | + base_url = "https://www.grandportageauto.com" | |
| 553 | + dealer_name = "Groupe Grand-Portage" | |
| 554 | + city = "Rivière-du-Loup" | |
| 555 | + | |
| 556 | + | |
| 557 | +class GroupeMotion(D2CConnector): | |
| 558 | + source_id = "groupemotion" | |
| 559 | + base_url = "https://www.groupemotion.com" | |
| 560 | + dealer_name = "Groupe Motion" | |
| 561 | + city = "Longueuil" | |
| 562 | + | |
| 563 | + | |
| 564 | +class GroupePaquin(D2CConnector): | |
| 565 | + source_id = "groupepaquin" | |
| 566 | + base_url = "https://www.groupepaquin.com" | |
| 567 | + dealer_name = "Groupe Paquin" | |
| 568 | + city = "Rouyn-Noranda" | |
| 569 | + | |
| 570 | + | |
| 571 | +class HullHyundai(D2CConnector): | |
| 572 | + source_id = "hullhyundai" | |
| 573 | + base_url = "https://www.hullhyundai.com" | |
| 574 | + dealer_name = "Hull Hyundai" | |
| 575 | + city = "Gatineau" | |
| 576 | + | |
| 577 | + | |
| 578 | +class HyundaiBeauce(D2CConnector): | |
| 579 | + source_id = "hyundaibeauce" | |
| 580 | + base_url = "https://www.hyundaibeauce.com" | |
| 581 | + dealer_name = "Hyundai Beauce" | |
| 582 | + city = "Saint-Georges" | |
| 583 | + | |
| 584 | + | |
| 585 | +class HyundaiDrummondville(D2CConnector): | |
| 586 | + source_id = "hyundaidrummondville" | |
| 587 | + base_url = "https://www.hyundaidrummondville.com" | |
| 588 | + dealer_name = "Hyundai Drummondville" | |
| 589 | + city = "Drummondville" | |
| 590 | + | |
| 591 | + | |
| 592 | +class HyundaiLanaudiere(D2CConnector): | |
| 593 | + source_id = "hyundailanaudiere" | |
| 594 | + base_url = "https://www.hyundailanaudiere.com" | |
| 595 | + dealer_name = "Hyundai Lanaudière" | |
| 596 | + city = "Joliette" | |
| 597 | + | |
| 598 | + | |
| 599 | +class HyundaiLongueuil(D2CConnector): | |
| 600 | + source_id = "hyundailongueuil" | |
| 601 | + base_url = "https://www.hyundailongueuil.com" | |
| 602 | + dealer_name = "Hyundai Longueuil" | |
| 603 | + city = "Longueuil" | |
| 604 | + | |
| 605 | + | |
| 606 | +class HyundaiStJerome(D2CConnector): | |
| 607 | + source_id = "hyundaistjerome" | |
| 608 | + base_url = "https://www.hyundaistjerome.com" | |
| 609 | + dealer_name = "Hyundai St-Jérôme" | |
| 610 | + city = "Mirabel" | |
| 611 | + | |
| 612 | + | |
| 613 | +class JeanDumas(D2CConnector): | |
| 614 | + source_id = "jeandumas" | |
| 615 | + base_url = "https://www.jeandumas.com" | |
| 616 | + dealer_name = "Jean Dumas Multiconcessionnaire" | |
| 617 | + city = "Alma" | |
| 618 | + | |
| 619 | + | |
| 620 | +class JeanDumasFordBC(D2CConnector): | |
| 621 | + source_id = "jeandumasfordbc" | |
| 622 | + base_url = "https://www.jeandumasfordbaiecomeau.com" | |
| 623 | + dealer_name = "Jean Dumas Ford Baie-Comeau" | |
| 624 | + city = "Baie-Comeau" | |
| 625 | + | |
| 626 | + | |
| 627 | +class JeanDumasHondaBC(D2CConnector): | |
| 628 | + source_id = "jeandumashondabc" | |
| 629 | + base_url = "https://www.jeandumashondabaiecomeau.com" | |
| 630 | + dealer_name = "Jean Dumas Honda Baie-Comeau" | |
| 631 | + city = "Baie-Comeau" | |
| 632 | + | |
| 633 | + | |
| 634 | +class JolietteMitsubishi(D2CConnector): | |
| 635 | + source_id = "joliettemitsubishi" | |
| 636 | + base_url = "https://www.joliettemitsubishi.com" | |
| 637 | + dealer_name = "Joliette Mitsubishi" | |
| 638 | + city = "Joliette" | |
| 639 | + | |
| 640 | + | |
| 641 | +class KiaJoliette(D2CConnector): | |
| 642 | + source_id = "kiajoliette" | |
| 643 | + base_url = "https://www.kiajoliette.com" | |
| 644 | + dealer_name = "Kia Joliette" | |
| 645 | + city = "Joliette" | |
| 646 | + | |
| 647 | + | |
| 648 | +class LAmiHonda(D2CConnector): | |
| 649 | + source_id = "lamihonda" | |
| 650 | + base_url = "https://www.lamihonda.com" | |
| 651 | + dealer_name = "L'Ami Honda" | |
| 652 | + city = "Rouyn-Noranda" | |
| 653 | + | |
| 654 | + | |
| 655 | +class LeoAutomobile(D2CConnector): | |
| 656 | + source_id = "leoautomobile" | |
| 657 | + base_url = "https://www.leoautomobile.com" | |
| 658 | + dealer_name = "Léo Automobile" | |
| 659 | + city = "Chicoutimi" | |
| 660 | + | |
| 661 | + | |
| 662 | +class LevisToyota(D2CConnector): | |
| 663 | + source_id = "levistoyota" | |
| 664 | + base_url = "https://www.levistoyota.ca" | |
| 665 | + dealer_name = "Lévis Toyota" | |
| 666 | + city = "Lévis" | |
| 667 | + | |
| 668 | + | |
| 669 | +class LongueuilHonda(D2CConnector): | |
| 670 | + source_id = "longueuilhonda" | |
| 671 | + base_url = "https://www.longueuilhonda.com" | |
| 672 | + dealer_name = "Longueuil Honda" | |
| 673 | + city = "Longueuil" | |
| 674 | + | |
| 675 | + | |
| 676 | +class OlivierChryslerBC(D2CConnector): | |
| 677 | + source_id = "olivierchryslerbc" | |
| 678 | + base_url = "https://www.olivierchryslerbaiecomeau.com" | |
| 679 | + dealer_name = "Olivier Chrysler Baie-Comeau" | |
| 680 | + city = "Baie-Comeau" | |
| 681 | + | |
| 682 | + | |
| 683 | +class StGeorgesGM(D2CConnector): | |
| 684 | + source_id = "stgeorgesgm" | |
| 685 | + base_url = "https://www.stgeorgesgm.ca" | |
| 686 | + dealer_name = "St-Georges Chevrolet Buick GMC" | |
| 687 | + city = "Saint-Georges" | |
| 688 | + | |
| 689 | + | |
| 690 | +class StJeromeMitsubishi(D2CConnector): | |
| 691 | + source_id = "stjeromemitsubishi" | |
| 692 | + base_url = "https://www.stjeromemitsubishi.ca" | |
| 693 | + dealer_name = "St-Jérôme Mitsubishi" | |
| 694 | + city = "Saint-Jérôme" | |
| 695 | + | |
| 696 | + | |
| 697 | +class SubaruVictoriaville(D2CConnector): | |
| 698 | + source_id = "subaruvictoriaville" | |
| 699 | + base_url = "https://www.subaruvictoriaville.ca" | |
| 700 | + dealer_name = "Subaru Victoriaville" | |
| 701 | + city = "Victoriaville" | |
| 702 | + | |
| 703 | + | |
| 704 | +class ToyotaBaieDesChaleurs(D2CConnector): | |
| 705 | + source_id = "toyotabdc" | |
| 706 | + base_url = "https://www.toyotabaiedeschaleurs.com" | |
| 707 | + dealer_name = "Toyota Baie des Chaleurs" | |
| 708 | + city = "Caplan" | |
| 709 | + | |
| 710 | + | |
| 711 | +class TroisRivieresHonda(D2CConnector): | |
| 712 | + source_id = "troisriviereshonda" | |
| 713 | + base_url = "https://www.troisriviereshonda.com" | |
| 714 | + dealer_name = "Trois-Rivières Honda" | |
| 715 | + city = "Trois-Rivières" | |
| 716 | + | |
| 717 | + | |
| 718 | +class TRToyota(D2CConnector): | |
| 719 | + source_id = "trtoyota" | |
| 720 | + base_url = "https://www.trtoyota.ca" | |
| 721 | + dealer_name = "Trois-Rivières Toyota" | |
| 722 | + city = "Trois-Rivières" | |
| 723 | + | |
| 724 | + | |
| 725 | +class VaudreuilVW(D2CConnector): | |
| 726 | + source_id = "vaudreuilvw" | |
| 727 | + base_url = "https://www.vaudreuilvolkswagen.com" | |
| 728 | + dealer_name = "Vaudreuil Volkswagen" | |
| 729 | + city = "Vaudreuil-Dorion" | |
| 730 | + | |
| 731 | + | |
| 732 | +class VWSherbrooke(D2CConnector): | |
| 733 | + source_id = "vwsherbrooke" | |
| 734 | + base_url = "https://www.vwsherbrooke.com" | |
| 735 | + dealer_name = "VW Sherbrooke" | |
| 736 | + city = "Sherbrooke" | |
| 737 | + | |
| 738 | + | |
| 739 | +# --------------------------------------------------------------------------- | |
| 740 | +# Expansion 3 — régions sous-couvertes (Québec métro, Charlevoix, Saguenay– | |
| 741 | +# Lac-Saint-Jean, Côte-Nord, Bas-Saint-Laurent, Gaspésie) | |
| 742 | +# | |
| 743 | +# Groupe Olivier : les quatre sites Olivier de Sept-Îles (Ford / Nissan / | |
| 744 | +# Chrysler / Occasion) publient EXACTEMENT le même inventaire d'occasion | |
| 745 | +# (mêmes ids D2C) — on ne connecte que olivieroccasionseptiles.com. Aucun | |
| 746 | +# recoupement avec Olivier Chrysler Baie-Comeau (déjà connecté) ni avec | |
| 747 | +# Olivier Hyundai Baie-Comeau. | |
| 748 | +# --------------------------------------------------------------------------- | |
| 749 | + | |
| 750 | +class DupontAutomobile(D2CConnector): | |
| 751 | + source_id = "dupontautomobile" | |
| 752 | + base_url = "https://www.dupontautomobile.com" | |
| 753 | + dealer_name = "Dupont Automobile" | |
| 754 | + city = "Alma" | |
| 755 | + | |
| 756 | + | |
| 757 | +class AlmaHonda(D2CConnector): | |
| 758 | + source_id = "almahonda" | |
| 759 | + base_url = "https://www.almahonda.com" | |
| 760 | + dealer_name = "Alma Honda" | |
| 761 | + city = "Alma" | |
| 762 | + | |
| 763 | + | |
| 764 | +class HondaNewRichmond(D2CConnector): | |
| 765 | + source_id = "hondanewrichmond" | |
| 766 | + base_url = "https://www.hondanewrichmond.com" | |
| 767 | + dealer_name = "Honda New Richmond" | |
| 768 | + city = "New Richmond" | |
| 769 | + | |
| 770 | + | |
| 771 | +class OlivierHyundaiBaieComeau(D2CConnector): | |
| 772 | + source_id = "olivierhyundaibc" | |
| 773 | + base_url = "https://www.olivierhyundaibaiecomeau.com" | |
| 774 | + dealer_name = "Olivier Hyundai Baie-Comeau" | |
| 775 | + city = "Baie-Comeau" | |
| 776 | + | |
| 777 | + | |
| 778 | +class OlivierOccasionSeptIles(D2CConnector): | |
| 779 | + # inventaire commun aux 4 sites Olivier de Sept-Îles (voir note ci-dessus) | |
| 780 | + source_id = "olivieroccasionseptiles" | |
| 781 | + base_url = "https://www.olivieroccasionseptiles.com" | |
| 782 | + dealer_name = "Olivier Occasion Sept-Îles" | |
| 783 | + city = "Sept-Îles" | |
| 784 | + | |
| 785 | + | |
| 786 | +class CharlesbourgToyota(D2CConnector): | |
| 787 | + source_id = "charlesbourgtoyota" | |
| 788 | + base_url = "https://www.charlesbourgtoyota.com" | |
| 789 | + dealer_name = "Charlesbourg Toyota" | |
| 790 | + city = "Québec" | |
| 791 | + | |
| 792 | + | |
| 793 | +class HondaCharlesbourg(D2CConnector): | |
| 794 | + source_id = "hondacharlesbourg" | |
| 795 | + base_url = "https://www.hondacharlesbourg.com" | |
| 796 | + dealer_name = "Honda Charlesbourg" | |
| 797 | + city = "Québec" | |
| 798 | + | |
| 799 | + | |
| 800 | +class KiaBeauport(D2CConnector): | |
| 801 | + source_id = "kiabeauport" | |
| 802 | + base_url = "https://www.kiabeauport.com" | |
| 803 | + dealer_name = "Kia Beauport" | |
| 804 | + city = "Québec" | |
| 805 | + | |
| 806 | + | |
| 807 | +class HondaCharlevoix(D2CConnector): | |
| 808 | + source_id = "hondacharlevoix" | |
| 809 | + base_url = "https://www.hondacharlevoix.com" | |
| 810 | + dealer_name = "Honda Charlevoix" | |
| 811 | + city = "La Malbaie" | |
| 812 | + | |
| 813 | + | |
| 814 | +class HyundaiCharlevoix(D2CConnector): | |
| 815 | + source_id = "hyundaicharlevoix" | |
| 816 | + base_url = "https://www.hyundaicharlevoix.com" | |
| 817 | + dealer_name = "Hyundai Charlevoix" | |
| 818 | + city = "Baie-Saint-Paul" | |
| 819 | + | |
| 820 | + | |
| 821 | +class CharlevoixToyota(D2CConnector): | |
| 822 | + source_id = "charlevoixtoyota" | |
| 823 | + base_url = "https://www.charlevoixtoyota.com" | |
| 824 | + dealer_name = "Charlevoix Toyota" | |
| 825 | + city = "La Malbaie" | |
| 826 | + | |
| 827 | + | |
| 828 | +class AutoVilleneuve(D2CConnector): | |
| 829 | + # concessionnaire Ford d'Amqui (vallée de la Matapédia) — petit inventaire | |
| 830 | + source_id = "autovilleneuve" | |
| 831 | + base_url = "https://www.autovilleneuve.com" | |
| 832 | + dealer_name = "Automobile Villeneuve Amqui" | |
| 833 | + city = "Amqui" | |
added
apps/auto/autoka/connectors/desmeules.py
+252 −0
@@ -0,0 +1,252 @@ | ||
| 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. Elle porte aussi | |
| 16 | +# la liste d'équipements AutoJini (« Équipement standard », divs .column : | |
| 17 | +# h3 catégorie + ul d'items) dont la section « Transmission & Moteur » | |
| 18 | +# contient le carburant (« Essence », « Plug-in électric / gas »…) et la | |
| 19 | +# motricité (« Traction Intégrale », « Quatre roues motrices »…), ainsi | |
| 20 | +# qu'une boîte « Catégorie » (Car/VUS/SUV/Truck) pour la carrosserie. | |
| 21 | +# Ces pages sont mises en cache BD à vie (clé stable — les specs ne | |
| 22 | +# changent pas ; le prix frais vient du RSS). AUTOKA_MAX_DETAILS (env) | |
| 23 | +# plafonne les vraies requêtes détail par synchronisation. | |
| 24 | +# ----------------------------------------------------------------------------- | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import html as htmllib | |
| 28 | +import os | |
| 29 | +import re | |
| 30 | + | |
| 31 | +from ..schema import (Vehicle, normalize_body, normalize_drivetrain, | |
| 32 | + normalize_fuel, strip_accents) | |
| 33 | +from .base import BaseConnector | |
| 34 | + | |
| 35 | +_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S) | |
| 36 | +_TITLE_RE = re.compile(r"<title>\s*<!\[CDATA\[(.*?)\]\]>", re.S) | |
| 37 | +_LINK_RE = re.compile(r'href="(https?://[^"]+-v(\d+)\.html)"') | |
| 38 | +_STOCK_RE = re.compile(r"Stock #:\s*([^<]+)</div>") | |
| 39 | +_VIN_RE = re.compile(r"VIN #:\s*([^<]+)</div>") | |
| 40 | +_IMG_RE = re.compile(r'img[^>]*src="([^"]+)"') | |
| 41 | +_PRICE_RE = re.compile(r"-\s*\$([\d,]+)\s*$") | |
| 42 | + | |
| 43 | +# page détail (la transmission/couleur vient de la meta description : | |
| 44 | +# « … - Engine: V8 4.0L Cylinder Engine - Color : . - Transmission: Automatique ») | |
| 45 | +_KM_RE = re.compile(r'detailsMileageData">\s*([\d,\s]+)\s*km', re.I) | |
| 46 | +_ENGINE_RE = re.compile(r"Moteur:\s*</div>\s*<div[^>]*>\s*([^<]+)", re.S) | |
| 47 | +_TRANS_RE = re.compile(r"Transmission:\s*([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]*)") | |
| 48 | +_DRIVE_RE = re.compile(r"Entra[îi]nement\s*:\s*<span>\s*([^<]+)", re.I) | |
| 49 | +_COLOR_RE = re.compile(r"Color\s*:\s*([A-Za-zÀ-ÿ][^.<\"]*)[.<\"]") | |
| 50 | +_GALLERY_RE = re.compile( | |
| 51 | + r'https?://images\.autojini\.[a-z]+/[^"\s\\<>]+?\.jpg(?!/)', re.I) | |
| 52 | + | |
| 53 | +# liste d'équipements AutoJini : divs .column (h3 catégorie + ul d'items) | |
| 54 | +_FEATURE_SECTION_RE = re.compile( | |
| 55 | + r'<div class="column[^"]*">\s*<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>', re.S) | |
| 56 | +_LI_RE = re.compile(r"<li>(.*?)</li>", re.S) | |
| 57 | +# boîte « Catégorie » (Car / VUS / SUV / Truck…) -> carrosserie | |
| 58 | +_CATEGORY_RE = re.compile( | |
| 59 | + r'colorBoxHead">\s*Cat(?:é|é)gorie\s*</div>' | |
| 60 | + r'.*?colorBoxText">(.*?)</div>', re.S) | |
| 61 | + | |
| 62 | +_EXCLUDE_RE = re.compile( | |
| 63 | + r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|" | |
| 64 | + r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I) | |
| 65 | + | |
| 66 | + | |
| 67 | +def _clean(text) -> str: | |
| 68 | + if not text: | |
| 69 | + return "" | |
| 70 | + out = htmllib.unescape(str(text)) | |
| 71 | + out = re.sub(r"<[^>]+>", " ", out) | |
| 72 | + return re.sub(r"\s+", " ", out).strip() | |
| 73 | + | |
| 74 | + | |
| 75 | +# clé de cache détail stable (bump si le parsing change) | |
| 76 | +# v3 : + équipements, carburant/motricité (Transmission & Moteur), carrosserie | |
| 77 | +_DETAIL_KEY = "v3" | |
| 78 | + | |
| 79 | + | |
| 80 | +class DesmeulesChrysler(BaseConnector): | |
| 81 | + """Desmeules Chrysler — flux RSS inventaire + pages détail (km, specs).""" | |
| 82 | + | |
| 83 | + source_id = "desmeules" | |
| 84 | + base_url = "https://www.desmeules.ca" | |
| 85 | + dealer_name = "Desmeules Chrysler" | |
| 86 | + city = "Laval" | |
| 87 | + request_delay = 1.0 | |
| 88 | + max_details = 300 # plafond de vraies requêtes par sync | |
| 89 | + | |
| 90 | + # -- flux RSS ---------------------------------------------------------------- | |
| 91 | + def _rss_items(self) -> list[dict]: | |
| 92 | + xml = self.get( | |
| 93 | + f"{self.base_url}/fr/vehicules-doccasion/" | |
| 94 | + "?fuseaction=inventory.feedRSS&ajaxCall=1").text | |
| 95 | + items: list[dict] = [] | |
| 96 | + for chunk in _ITEM_RE.findall(xml): | |
| 97 | + link = _LINK_RE.search(chunk) | |
| 98 | + if not link: | |
| 99 | + continue | |
| 100 | + entry: dict = {"url": link.group(1).replace("http://", "https://"), | |
| 101 | + "id": link.group(2)} | |
| 102 | + m = _TITLE_RE.search(chunk) | |
| 103 | + title = _clean(m.group(1)) if m else "" | |
| 104 | + m = _PRICE_RE.search(title) | |
| 105 | + if m: | |
| 106 | + entry["price"] = float(m.group(1).replace(",", "")) | |
| 107 | + title = title[:m.start()].strip() | |
| 108 | + title = re.sub(r"^For Sale\s+", "", title, flags=re.I) | |
| 109 | + entry["title"] = title | |
| 110 | + m = _STOCK_RE.search(chunk) | |
| 111 | + if m: | |
| 112 | + entry["stock"] = _clean(m.group(1)) | |
| 113 | + m = _VIN_RE.search(chunk) | |
| 114 | + if m: | |
| 115 | + entry["vin"] = _clean(m.group(1)) | |
| 116 | + m = _IMG_RE.search(chunk) | |
| 117 | + if m: | |
| 118 | + entry["photo"] = m.group(1).split("/Resize")[0] | |
| 119 | + # description longue : le 2e bloc CDATA de la description | |
| 120 | + blocks = re.findall(r"<!\[CDATA\[(.*?)\]\]>", chunk, re.S) | |
| 121 | + if len(blocks) >= 3: | |
| 122 | + entry["description"] = _clean(blocks[2])[:4000] | |
| 123 | + items.append(entry) | |
| 124 | + return items | |
| 125 | + | |
| 126 | + # -- page détail -> payload --------------------------------------------------- | |
| 127 | + def _fetch_detail(self, url: str) -> dict: | |
| 128 | + html = self.get(url).text | |
| 129 | + payload: dict = {} | |
| 130 | + m = _KM_RE.search(html) | |
| 131 | + if m: | |
| 132 | + payload["km"] = float(re.sub(r"[^\d]", "", m.group(1))) | |
| 133 | + m = _ENGINE_RE.search(html) | |
| 134 | + if m: | |
| 135 | + payload["engine"] = _clean(m.group(1)) | |
| 136 | + m = _TRANS_RE.search(html) | |
| 137 | + if m: | |
| 138 | + payload["transmission"] = _clean(m.group(1)) | |
| 139 | + m = _DRIVE_RE.search(html) | |
| 140 | + if m: | |
| 141 | + payload["drivetrain"] = _clean(m.group(1)) | |
| 142 | + m = _COLOR_RE.search(html) | |
| 143 | + if m: | |
| 144 | + payload["color"] = _clean(m.group(1)) | |
| 145 | + | |
| 146 | + # boîte « Catégorie » -> carrosserie canonique (Car reste ambigu) | |
| 147 | + m = _CATEGORY_RE.search(html) | |
| 148 | + if m: | |
| 149 | + body = normalize_body(_clean(m.group(1))) | |
| 150 | + if body: | |
| 151 | + payload["body"] = body | |
| 152 | + | |
| 153 | + # équipements par catégorie ; « Transmission & Moteur » porte le | |
| 154 | + # carburant et la motricité, absents partout ailleurs | |
| 155 | + features: list[str] = [] | |
| 156 | + for head, ul in _FEATURE_SECTION_RE.findall(html): | |
| 157 | + items = [_clean(li) for li in _LI_RE.findall(ul)] | |
| 158 | + items = [it for it in items if it] | |
| 159 | + features.extend(items) | |
| 160 | + if not strip_accents(head).lower().startswith("transmission"): | |
| 161 | + continue | |
| 162 | + for it in items: | |
| 163 | + if "fuel" not in payload: | |
| 164 | + fuel = normalize_fuel(it) | |
| 165 | + if fuel: | |
| 166 | + payload["fuel"] = fuel | |
| 167 | + if "drivetrain" not in payload: | |
| 168 | + low = strip_accents(it).lower() | |
| 169 | + if "quatre roues" in low or "4 roues" in low: | |
| 170 | + payload["drivetrain"] = "Intégrale / 4x4" | |
| 171 | + elif "controle" not in low: # « Contrôle de traction » | |
| 172 | + drive = normalize_drivetrain(it) | |
| 173 | + if drive: | |
| 174 | + payload["drivetrain"] = drive | |
| 175 | + if features: | |
| 176 | + payload["features"] = features[:60] | |
| 177 | + | |
| 178 | + images, seen = [], set() | |
| 179 | + for u in _GALLERY_RE.findall(html): | |
| 180 | + base = u.replace("_x.jpg", ".jpg") | |
| 181 | + if base not in seen: | |
| 182 | + seen.add(base) | |
| 183 | + images.append(base) | |
| 184 | + payload["images"] = images[:20] | |
| 185 | + return payload | |
| 186 | + | |
| 187 | + # -- contrat --------------------------------------------------------------- | |
| 188 | + def fetch(self) -> list[Vehicle]: | |
| 189 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 190 | + vehicles: list[Vehicle] = [] | |
| 191 | + real_fetches = 0 | |
| 192 | + for item in self._rss_items(): | |
| 193 | + if _EXCLUDE_RE.search(item.get("title", "")): | |
| 194 | + continue | |
| 195 | + detail: dict = {} | |
| 196 | + if real_fetches >= cap: # plafond : cache seulement | |
| 197 | + from .. import db | |
| 198 | + if self._detail_con is None: | |
| 199 | + self._detail_con = db.connect() | |
| 200 | + detail = db.get_cached_detail(self._detail_con, self.source_id, | |
| 201 | + item["id"], _DETAIL_KEY) or {} | |
| 202 | + else: | |
| 203 | + def _fetch(u=item["url"]): | |
| 204 | + return self._fetch_detail(u) | |
| 205 | + before = self._last_request | |
| 206 | + try: | |
| 207 | + detail = self.detail(item["id"], _DETAIL_KEY, _fetch) | |
| 208 | + except Exception: | |
| 209 | + detail = {} # page disparue : données RSS | |
| 210 | + if self._last_request != before: | |
| 211 | + real_fetches += 1 | |
| 212 | + veh = self._to_vehicle(item, detail or {}) | |
| 213 | + if veh is not None: | |
| 214 | + vehicles.append(veh) | |
| 215 | + return vehicles | |
| 216 | + | |
| 217 | + # -- item RSS + détail -> Vehicle ---------------------------------------------- | |
| 218 | + def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None: | |
| 219 | + title = item.get("title") or "" | |
| 220 | + if not title: | |
| 221 | + return None | |
| 222 | + | |
| 223 | + price = item.get("price") | |
| 224 | + km = detail.get("km") | |
| 225 | + | |
| 226 | + images = list(detail.get("images") or []) | |
| 227 | + if not images and item.get("photo"): | |
| 228 | + images = [item["photo"]] | |
| 229 | + | |
| 230 | + return Vehicle( | |
| 231 | + source=self.source_id, | |
| 232 | + external_id=item["id"], | |
| 233 | + url=item["url"], | |
| 234 | + title=title, # « 2021 Aston Martin DBX … » | |
| 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=detail.get("transmission", ""), | |
| 240 | + fuel=detail.get("fuel", ""), | |
| 241 | + drivetrain=detail.get("drivetrain", ""), | |
| 242 | + body_type=detail.get("body", ""), | |
| 243 | + exterior_color=detail.get("color", ""), | |
| 244 | + engine=detail.get("engine", ""), | |
| 245 | + vin=item.get("vin", ""), | |
| 246 | + stock_number=item.get("stock", ""), | |
| 247 | + dealer_name=self.dealer_name, | |
| 248 | + city=self.city, | |
| 249 | + description=item.get("description", ""), | |
| 250 | + features=list(detail.get("features") or []), | |
| 251 | + images=images, | |
| 252 | + ) | |
added
apps/auto/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
apps/auto/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
apps/auto/autoka/connectors/hgregoire.py
+325 −0
@@ -0,0 +1,325 @@ | ||
| 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 | +# ENRICHISSEMENT DÉTAIL (galerie + équipements) : la carte liste n'a qu'UNE | |
| 26 | +# photo et quelques équipements ; la page détail expose la galerie complète | |
| 27 | +# (~25 photos) et l'onglet « Équipements » (~40 items). Avec Crawl-delay: 10 | |
| 28 | +# un crawl direct des ~1 700 fiches prendrait ~4,7 h → les pages détail | |
| 29 | +# passent par SCRAPFLY (requêtes gérées par leur infra, non soumises au | |
| 30 | +# crawl-delay de notre IP). Cache BD à vie (clé stable « v1 » — la galerie | |
| 31 | +# et les équipements ne changent pas), plafond AUTOKA_MAX_DETAILS (défaut | |
| 32 | +# 300/sync) : l'inventaire complet s'enrichit en ~6 synchronisations. | |
| 33 | +# Le véhicule est toujours émis avec les données liste même sans détail. | |
| 34 | +# ----------------------------------------------------------------------------- | |
| 35 | +from __future__ import annotations | |
| 36 | + | |
| 37 | +import json | |
| 38 | +import os | |
| 39 | +import re | |
| 40 | + | |
| 41 | +from ..normalize import MAKES | |
| 42 | +from ..schema import Vehicle, strip_accents | |
| 43 | +from .base import BaseConnector | |
| 44 | + | |
| 45 | +_MAKE_WORDS = {strip_accents(m).lower() for m in MAKES} | |
| 46 | + | |
| 47 | +_BLOB_RE = re.compile( | |
| 48 | + r'<script type="application/json" data-class="Vehicle"[^>]*>' | |
| 49 | + r'/\*<!\[CDATA\[\*/(.*?)/\*\]\]>\*/</script>', re.S) | |
| 50 | +_PAGE_RE = re.compile(r'data-page="(\d+)"') | |
| 51 | +_DOORS_RE = re.compile(r"(\d+)\s*portes?", re.I) | |
| 52 | +# photo de carte 448x252 -> version plus grande servie par le même CDN | |
| 53 | +_IMG_SIZE_RE = re.compile(r"/by-size/([^/]+)/\d+x\d+/") | |
| 54 | + | |
| 55 | +# page détail : galerie (chaque photo répétée en plusieurs tailles), | |
| 56 | +# onglet « Équipements » et lien Carfax éventuel | |
| 57 | +_GALLERY_RE = re.compile( # album parfois alphanumérique (ex. KA8107) | |
| 58 | + r"https://[^\"'\s\\<>]*/photos/by-size/([\w-]+)/\d+x\d+/(\w+)\.(\w+)") | |
| 59 | +_EQUIP_TAB_RE = re.compile( | |
| 60 | + r'id="vdptab-equipment"(.*?)(?:show-all-equipment|</section>)', re.S) | |
| 61 | +_EQUIP_ITEM_RE = re.compile(r"<span>\s*([^<]+?)\s*</span>") | |
| 62 | +_CARFAX_RE = re.compile( | |
| 63 | + r'https?://[^"\'\s<>]*carfax[^"\'\s<>]*', re.I) | |
| 64 | + | |
| 65 | +# clé de cache détail stable (bump si le parsing change) | |
| 66 | +_DETAIL_KEY = "v1" | |
| 67 | + | |
| 68 | +MILE_KM = 1.609344 | |
| 69 | + | |
| 70 | + | |
| 71 | +# bannières du groupe qui ne sont pas des noms de ville | |
| 72 | +_BANNER_CITY = { | |
| 73 | + "ineos grenadier": "Montréal", # INEOS Grenadier Montréal | |
| 74 | + "le prix du gros": "Trois-Rivières", # Méga centre d'occasion Le Prix du Gros | |
| 75 | +} | |
| 76 | + | |
| 77 | + | |
| 78 | +def _clean_city(raw: str) -> str: | |
| 79 | + """Succursale -> ville : « Kia Vaudreuil » -> « Vaudreuil », | |
| 80 | + « Nissan St-Eustache » -> « St-Eustache » (certaines succursales du | |
| 81 | + groupe sont des bannières de marque ; on retire le préfixe de marque).""" | |
| 82 | + banner = _BANNER_CITY.get(strip_accents(raw or "").lower().strip()) | |
| 83 | + if banner: | |
| 84 | + return banner | |
| 85 | + words = (raw or "").split() | |
| 86 | + while len(words) > 1 and strip_accents(words[0]).lower() in _MAKE_WORDS: | |
| 87 | + words = words[1:] | |
| 88 | + return " ".join(words) | |
| 89 | + | |
| 90 | + | |
| 91 | +class HGregoire(BaseConnector): | |
| 92 | + """HGrégoire — inventaire usagé complet via les pages liste (SRP).""" | |
| 93 | + | |
| 94 | + source_id = "hgregoire" | |
| 95 | + base_url = "https://www.hgregoire.com" | |
| 96 | + dealer_name = "HGrégoire" | |
| 97 | + request_delay = 10.0 # robots.txt : Crawl-delay: 10 | |
| 98 | + max_pages = 200 # garde-fou dur (~74 pages réelles) | |
| 99 | + max_details = 300 # plafond de requêtes Scrapfly / sync | |
| 100 | + | |
| 101 | + # -- pages liste ------------------------------------------------------ | |
| 102 | + def _list_html(self, page: int) -> str: | |
| 103 | + url = f"{self.base_url}/auto-usage" | |
| 104 | + if page > 1: | |
| 105 | + url += f"?page={page}" | |
| 106 | + return self.get(url).text | |
| 107 | + | |
| 108 | + def _parse_blobs(self, html: str) -> list[dict]: | |
| 109 | + out = [] | |
| 110 | + for raw in _BLOB_RE.findall(html): | |
| 111 | + try: | |
| 112 | + out.append(json.loads(raw)) | |
| 113 | + except ValueError: | |
| 114 | + continue | |
| 115 | + return out | |
| 116 | + | |
| 117 | + # -- page détail (via Scrapfly) -> payload ----------------------------- | |
| 118 | + def _fetch_detail(self, url: str) -> dict: | |
| 119 | + """Galerie complète + équipements + carfax d'une fiche véhicule. | |
| 120 | + | |
| 121 | + Passe par Scrapfly (pas de crawl-delay pour notre IP). Lève une | |
| 122 | + exception si la page ne ressemble pas à une fiche véhicule, pour ne | |
| 123 | + jamais mettre en cache un payload vide issu d'une page d'erreur. | |
| 124 | + """ | |
| 125 | + html = self.get_scrapfly(url) | |
| 126 | + payload: dict = {"images": [], "features": []} | |
| 127 | + | |
| 128 | + seen: set[str] = set() | |
| 129 | + for album, pid, ext in _GALLERY_RE.findall(html): | |
| 130 | + if pid not in seen: # chaque photo répétée en N tailles | |
| 131 | + seen.add(pid) | |
| 132 | + payload["images"].append( | |
| 133 | + f"https://cfwww.hgregoire.com/photos/by-size/" | |
| 134 | + f"{album}/1010x568/{pid}.{ext}") | |
| 135 | + | |
| 136 | + m = _EQUIP_TAB_RE.search(html) | |
| 137 | + if m: | |
| 138 | + feats: list[str] = [] | |
| 139 | + for f in _EQUIP_ITEM_RE.findall(m.group(1)): | |
| 140 | + f = " ".join(f.split()) | |
| 141 | + if f and f not in feats: | |
| 142 | + feats.append(f) | |
| 143 | + payload["features"] = feats | |
| 144 | + | |
| 145 | + m = _CARFAX_RE.search(html) | |
| 146 | + if m: | |
| 147 | + payload["carfax_url"] = m.group(0) | |
| 148 | + | |
| 149 | + if not payload["images"] and 'data-class="Vehicle"' not in html: | |
| 150 | + raise RuntimeError(f"page détail HGrégoire illisible : {url}") | |
| 151 | + return payload | |
| 152 | + | |
| 153 | + # -- contrat ---------------------------------------------------------- | |
| 154 | + def fetch(self) -> list[Vehicle]: | |
| 155 | + cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) | |
| 156 | + detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 157 | + vehicles: list[Vehicle] = [] | |
| 158 | + seen: set[str] = set() | |
| 159 | + | |
| 160 | + html = self._list_html(1) | |
| 161 | + pages = [int(p) for p in _PAGE_RE.findall(html)] | |
| 162 | + last_page = min(max(pages) if pages else 1, cap, self.max_pages) | |
| 163 | + | |
| 164 | + page = 1 | |
| 165 | + while True: | |
| 166 | + blobs = self._parse_blobs(html) | |
| 167 | + if not blobs: | |
| 168 | + break | |
| 169 | + new = 0 | |
| 170 | + for d in blobs: | |
| 171 | + veh = self._to_vehicle(d) | |
| 172 | + if veh is not None and veh.external_id not in seen: | |
| 173 | + seen.add(veh.external_id) | |
| 174 | + vehicles.append(veh) | |
| 175 | + new += 1 | |
| 176 | + page += 1 | |
| 177 | + if page > last_page or new == 0: | |
| 178 | + break | |
| 179 | + html = self._list_html(page) | |
| 180 | + | |
| 181 | + # enrichissement détail (Scrapfly) — cache BD, budget par sync ; | |
| 182 | + # le véhicule reste émis avec les données liste même sans détail | |
| 183 | + real_fetches = 0 | |
| 184 | + for veh in vehicles: | |
| 185 | + detail: dict = {} | |
| 186 | + if real_fetches >= detail_cap: | |
| 187 | + from .. import db | |
| 188 | + if self._detail_con is None: | |
| 189 | + self._detail_con = db.connect() | |
| 190 | + detail = db.get_cached_detail(self._detail_con, self.source_id, | |
| 191 | + veh.external_id, | |
| 192 | + _DETAIL_KEY) or {} | |
| 193 | + else: | |
| 194 | + did_fetch = False | |
| 195 | + | |
| 196 | + def _fetch(u=veh.url): | |
| 197 | + nonlocal did_fetch | |
| 198 | + did_fetch = True | |
| 199 | + return self._fetch_detail(u) | |
| 200 | + | |
| 201 | + try: | |
| 202 | + detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch) | |
| 203 | + except Exception: | |
| 204 | + detail = {} # détail indisponible : liste suffisante | |
| 205 | + if did_fetch: | |
| 206 | + real_fetches += 1 | |
| 207 | + self._apply_detail(veh, detail or {}) | |
| 208 | + return vehicles | |
| 209 | + | |
| 210 | + @staticmethod | |
| 211 | + def _apply_detail(veh: Vehicle, detail: dict) -> None: | |
| 212 | + images = [u for u in detail.get("images") or [] if isinstance(u, str)] | |
| 213 | + if images: | |
| 214 | + veh.images = images[:30] | |
| 215 | + feats = [f for f in detail.get("features") or [] if isinstance(f, str)] | |
| 216 | + if feats: # union liste + détail, ordre préservé | |
| 217 | + merged = list(veh.features) | |
| 218 | + merged += [f for f in feats if f not in merged] | |
| 219 | + veh.features = merged[:80] | |
| 220 | + if detail.get("carfax_url"): | |
| 221 | + veh.carfax_url = str(detail["carfax_url"]) | |
| 222 | + | |
| 223 | + # -- carte JSON -> Vehicle ---------------------------------------------- | |
| 224 | + def _to_vehicle(self, d: dict) -> Vehicle | None: | |
| 225 | + if d.get("Sold") or d.get("Deleted") or d.get("IsForSale") is False: | |
| 226 | + return None | |
| 227 | + if str(d.get("Type") or "Used") != "Used": # usagés seulement | |
| 228 | + return None | |
| 229 | + | |
| 230 | + loc = d.get("FullLocation") or {} | |
| 231 | + province = (loc.get("province") or "").strip().upper() | |
| 232 | + if province and province != "QC": # succursales US exclues | |
| 233 | + return None | |
| 234 | + | |
| 235 | + ext_id = str(d.get("Id") or "").strip() | |
| 236 | + url = d.get("Permalink") or "" | |
| 237 | + if not ext_id or not url: | |
| 238 | + return None | |
| 239 | + | |
| 240 | + year = d.get("Year") | |
| 241 | + make = str(d.get("Make") or "").strip() | |
| 242 | + model = str(d.get("Model") or "").strip() | |
| 243 | + title = (d.get("ShortTitle") | |
| 244 | + or " ".join(str(x) for x in (year, make, model) if x)).strip() | |
| 245 | + | |
| 246 | + price = d.get("Price") | |
| 247 | + try: | |
| 248 | + price = float(price) if price else None | |
| 249 | + except (TypeError, ValueError): | |
| 250 | + price = None | |
| 251 | + | |
| 252 | + km = d.get("Odometer") | |
| 253 | + try: | |
| 254 | + km = float(km) if km not in (None, "") else None | |
| 255 | + except (TypeError, ValueError): | |
| 256 | + km = None | |
| 257 | + if km is not None and str(d.get("OdoUnitCode") or "").upper() == "SMI": | |
| 258 | + km = round(km * MILE_KM) # milles -> km | |
| 259 | + | |
| 260 | + # portes / passagers / équipements depuis la liste CarDetails | |
| 261 | + doors = seats = None | |
| 262 | + features: list[str] = [] | |
| 263 | + for item in d.get("CarDetails") or []: | |
| 264 | + name = str(item.get("name") or "").strip() | |
| 265 | + value = str(item.get("value") or "").strip() | |
| 266 | + if not value: # équipement pur | |
| 267 | + m = _DOORS_RE.search(name) | |
| 268 | + if m: | |
| 269 | + doors = int(m.group(1)) | |
| 270 | + elif name and "Stock" not in name: | |
| 271 | + features.append(name.rstrip(" :")) | |
| 272 | + elif "passagers" in name.lower(): | |
| 273 | + try: | |
| 274 | + seats = int(value) | |
| 275 | + except ValueError: | |
| 276 | + pass | |
| 277 | + | |
| 278 | + image = d.get("MainImage") or "" | |
| 279 | + if image: # carte 448x252 -> grand | |
| 280 | + image = _IMG_SIZE_RE.sub(r"/by-size/\1/1010x568/", image) | |
| 281 | + | |
| 282 | + post = d.get("post") or {} | |
| 283 | + description = str(post.get("post_content") or "")[:4000] | |
| 284 | + | |
| 285 | + details: dict = {} | |
| 286 | + if loc.get("city_slug"): | |
| 287 | + details["branch_slug"] = loc["city_slug"] | |
| 288 | + if d.get("Categories"): | |
| 289 | + details["categories"] = d["Categories"] | |
| 290 | + if d.get("HwyMPG"): | |
| 291 | + details["consumption_hwy_l_100km"] = d["HwyMPG"] | |
| 292 | + if d.get("OldPrice"): | |
| 293 | + details["old_price"] = d["OldPrice"] | |
| 294 | + | |
| 295 | + return Vehicle( | |
| 296 | + source=self.source_id, | |
| 297 | + external_id=ext_id, | |
| 298 | + url=url, | |
| 299 | + title=title, | |
| 300 | + make=make, | |
| 301 | + model=model, | |
| 302 | + trim=str(d.get("Trim") or "").strip(), | |
| 303 | + year=int(year) if year else None, | |
| 304 | + price=price, | |
| 305 | + price_label=str(d.get("FormattedPrice") or ""), | |
| 306 | + mileage_km=km, | |
| 307 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 308 | + transmission=str(d.get("Transmission") or ""), | |
| 309 | + fuel=str(d.get("Fuel") or ""), | |
| 310 | + drivetrain=str(d.get("DrivetrainType") or ""), | |
| 311 | + body_type=str(d.get("BodyStyle") or ""), | |
| 312 | + exterior_color=str(d.get("ExteriorColor") or ""), | |
| 313 | + interior_color=str(d.get("InteriorColor") or ""), | |
| 314 | + engine=str(d.get("Engine") or ""), | |
| 315 | + doors=doors, | |
| 316 | + seats=seats, | |
| 317 | + vin=str(d.get("VIN") or ""), | |
| 318 | + stock_number=str(d.get("Stock") or ""), | |
| 319 | + dealer_name=self.dealer_name, | |
| 320 | + city=_clean_city(str(loc.get("city") or "").strip()), | |
| 321 | + description=description, | |
| 322 | + features=features, | |
| 323 | + details=details, | |
| 324 | + images=[image] if image else [], | |
| 325 | + ) | |
added
apps/auto/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
apps/auto/autoka/connectors/leprixdugros.py
+292 −0
@@ -0,0 +1,292 @@ | ||
| 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 | +# NOTE : les pages détail n'ont NI description NI liste d'équipements par | |
| 23 | +# véhicule (les blocs « cards » sont du boilerplate : inspection 150 points, | |
| 24 | +# reconditionnement) — rien à extraire de plus que les specs/ville/photos. | |
| 25 | +# Le site est parfois lent (timeouts ponctuels) : chaque page détail tente | |
| 26 | +# d'abord un GET direct, puis bascule sur Scrapfly en cas d'échec ; le | |
| 27 | +# try/except par annonce garantit l'émission avec les données liste. | |
| 28 | +# Carburant : la source ne l'affiche jamais ; en plus des jetons explicites | |
| 29 | +# (hybride/EV/diesel), un moteur « N cylindres X.XL » sans indice électrifié | |
| 30 | +# => Essence (sauf modèles hybrides « sans badge » connus, ex. Prius). | |
| 31 | +# ----------------------------------------------------------------------------- | |
| 32 | +from __future__ import annotations | |
| 33 | + | |
| 34 | +import html as htmllib | |
| 35 | +import os | |
| 36 | +import re | |
| 37 | + | |
| 38 | +from ..schema import Vehicle | |
| 39 | +from .base import BaseConnector | |
| 40 | + | |
| 41 | +_SPEC_RE = re.compile( | |
| 42 | + r'c-single-car-features__list__text">\s*<strong>([^<]+)</strong><br>\s*' | |
| 43 | + r"<span>([^<]*)</span>", re.S) | |
| 44 | +_LOC_NAME_RE = re.compile( | |
| 45 | + r'c-single-car-map-location__name">\s*([^<]+?)\s*</span>', re.S) | |
| 46 | +_LOC_ADDR_RE = re.compile( | |
| 47 | + r'c-single-car-map-location__adresse">(.*?)</span>', re.S) | |
| 48 | +_CITY_RE = re.compile(r"([A-Za-zÀ-ÿ'’ .-]+?)\s*,\s*(?:QC|Québec)\b") | |
| 49 | +_IMG_RE = re.compile(r'https://images\.leprixdugros\.com/[^"\s\\<>]+') | |
| 50 | + | |
| 51 | +_FUEL_HINT_RE = re.compile( | |
| 52 | + r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|electrique|" | |
| 53 | + r"électrique|electric|\bev\b|diesel|tdi)", re.I) | |
| 54 | + | |
| 55 | +# moteur thermique explicite (« 4 cylindres 2.00L ») -> Essence par défaut | |
| 56 | +_CYL_ENGINE_RE = re.compile(r"\d+\s*cylindres?", re.I) | |
| 57 | +# modèles électrifiés « sans badge » : ne jamais leur inférer Essence | |
| 58 | +_ELECTRIFIED_MODELS = { | |
| 59 | + "prius", "prius prime", "prius c", "prius v", "ioniq", "niro", | |
| 60 | + "insight", "clarity", "volt", "bolt", "bolt euv", "leaf", "c-max", | |
| 61 | + "cr-z", "ct 200h", "sienna", "venza", "crown", "sonata hybrid", | |
| 62 | +} | |
| 63 | +# camions HD & fourgons souvent diesel SANS mention explicite (ex. Ram 3500 | |
| 64 | +# « 6 cylindres 6.70L » = Cummins diesel) : ne pas inférer Essence | |
| 65 | +_DIESEL_PRONE_RE = re.compile( | |
| 66 | + r"\b(2500|3500|4500|5500|f-?[2-5]50|sprinter|promaster)\b", re.I) | |
| 67 | + | |
| 68 | + | |
| 69 | +def _clean(text: str | None) -> str: | |
| 70 | + if not text: | |
| 71 | + return "" | |
| 72 | + out = htmllib.unescape(str(text)) | |
| 73 | + out = re.sub(r"<[^>]+>", " ", out) | |
| 74 | + return re.sub(r"\s{2,}", " ", out).strip() | |
| 75 | + | |
| 76 | + | |
| 77 | +# clé de cache détail stable (bump si le parsing change) | |
| 78 | +_DETAIL_KEY = "v2" | |
| 79 | + | |
| 80 | + | |
| 81 | +class LePrixDuGros(BaseConnector): | |
| 82 | + """Le Prix du Gros — JSON liste paginé + pages détail (specs, ville).""" | |
| 83 | + | |
| 84 | + source_id = "leprixdugros" | |
| 85 | + base_url = "https://www.leprixdugros.com" | |
| 86 | + dealer_name = "Le Prix du Gros" | |
| 87 | + request_delay = 1.0 # >= 1 s : le site tolère mal davantage | |
| 88 | + max_pages = 80 # garde-fou dur (~37 pages réelles) | |
| 89 | + max_details = 800 # plafond de vraies requêtes détail / sync | |
| 90 | + # (couvre les ~690 véhicules en 1 sync ; | |
| 91 | + # cache BD ensuite) | |
| 92 | + | |
| 93 | + # -- liste JSON ---------------------------------------------------------- | |
| 94 | + def _list_page(self, page: int) -> dict: | |
| 95 | + url = (f"{self.base_url}/inventaire/occasion/" | |
| 96 | + f"?output=json&pg={page}") | |
| 97 | + try: | |
| 98 | + return self.get(url).json() | |
| 99 | + except ValueError: | |
| 100 | + return {} | |
| 101 | + | |
| 102 | + # -- page détail -> payload (specs, concession, photos) ------------------- | |
| 103 | + def _fetch_detail(self, url: str) -> dict: | |
| 104 | + try: | |
| 105 | + html = self.get(url).text | |
| 106 | + except Exception: | |
| 107 | + # site lent / timeout ponctuel : repli Scrapfly (infra distincte) | |
| 108 | + html = self.get_scrapfly(url) | |
| 109 | + if not html: | |
| 110 | + raise | |
| 111 | + payload: dict = {"specs": {}, "images": []} | |
| 112 | + for name, value in _SPEC_RE.findall(html): | |
| 113 | + payload["specs"][_clean(name)] = _clean(value) | |
| 114 | + m = _LOC_NAME_RE.search(html) | |
| 115 | + if m: | |
| 116 | + payload["dealer"] = _clean(m.group(1)) | |
| 117 | + m = _LOC_ADDR_RE.search(html) | |
| 118 | + if m: | |
| 119 | + # l'adresse est multi-lignes (<br>) : « 141 Rue Commerciale », | |
| 120 | + # « Donnacona, QC, G3M 1W2 », téléphone — la ville est le début | |
| 121 | + # de LA ligne contenant « , QC » | |
| 122 | + lines = [_clean(l) for l in re.split(r"<br\s*/?>", m.group(1))] | |
| 123 | + lines = [l for l in lines if l] | |
| 124 | + payload["address"] = ", ".join(lines[:2]) | |
| 125 | + for line in lines: | |
| 126 | + c = _CITY_RE.match(line) | |
| 127 | + if c: | |
| 128 | + payload["city"] = c.group(1).strip() | |
| 129 | + break | |
| 130 | + seen: set[str] = set() | |
| 131 | + for u in _IMG_RE.findall(html): | |
| 132 | + if "_1000x750" in u and u not in seen: | |
| 133 | + seen.add(u) | |
| 134 | + payload["images"].append(u) | |
| 135 | + if not payload["specs"] and not payload["images"]: | |
| 136 | + # page d'erreur / interstitiel : ne pas mettre en cache du vide | |
| 137 | + raise RuntimeError(f"page détail LPDG illisible : {url}") | |
| 138 | + return payload | |
| 139 | + | |
| 140 | + # -- contrat --------------------------------------------------------------- | |
| 141 | + def fetch(self) -> list[Vehicle]: | |
| 142 | + page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) | |
| 143 | + detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 144 | + | |
| 145 | + items: list[dict] = [] | |
| 146 | + seen_ids: set[str] = set() | |
| 147 | + page, last_page = 1, 1 | |
| 148 | + while page <= min(last_page, page_cap, self.max_pages): | |
| 149 | + data = self._list_page(page) | |
| 150 | + batch = data.get("items") or [] | |
| 151 | + if not batch: | |
| 152 | + break | |
| 153 | + per_page = int(data.get("perPage") or len(batch) or 36) | |
| 154 | + total = int(data.get("total") or 0) | |
| 155 | + last_page = max(1, -(-total // max(per_page, 1))) # ceil | |
| 156 | + new = 0 | |
| 157 | + for item in batch: | |
| 158 | + ext_id = str(item.get("id") or item.get("stocknumber") or "") | |
| 159 | + if ext_id and ext_id not in seen_ids: | |
| 160 | + seen_ids.add(ext_id) | |
| 161 | + items.append(item) | |
| 162 | + new += 1 | |
| 163 | + if new == 0: | |
| 164 | + break | |
| 165 | + page += 1 | |
| 166 | + | |
| 167 | + vehicles: list[Vehicle] = [] | |
| 168 | + real_fetches = 0 | |
| 169 | + for item in items: | |
| 170 | + detail: dict = {} | |
| 171 | + url = str(item.get("permalink") or "") | |
| 172 | + ext_id = str(item.get("id") or item.get("stocknumber") or "") | |
| 173 | + if url: | |
| 174 | + def _fetch(u=url): | |
| 175 | + return self._fetch_detail(u) | |
| 176 | + | |
| 177 | + # clé stable : specs figées ; prix/km frais via la liste | |
| 178 | + if real_fetches >= detail_cap: | |
| 179 | + from .. import db | |
| 180 | + if self._detail_con is None: | |
| 181 | + self._detail_con = db.connect() | |
| 182 | + detail = db.get_cached_detail(self._detail_con, | |
| 183 | + self.source_id, | |
| 184 | + ext_id, _DETAIL_KEY) or {} | |
| 185 | + else: | |
| 186 | + before = self._last_request | |
| 187 | + try: | |
| 188 | + detail = self.detail(ext_id, _DETAIL_KEY, _fetch) | |
| 189 | + except Exception: | |
| 190 | + # page détail disparue ou timeout ponctuel : le | |
| 191 | + # véhicule est quand même émis avec les données liste | |
| 192 | + detail = {} | |
| 193 | + if self._last_request != before: | |
| 194 | + real_fetches += 1 | |
| 195 | + veh = self._to_vehicle(item, detail or {}) | |
| 196 | + if veh is not None: | |
| 197 | + vehicles.append(veh) | |
| 198 | + return vehicles | |
| 199 | + | |
| 200 | + # -- item liste + détail -> Vehicle ------------------------------------------ | |
| 201 | + def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None: | |
| 202 | + inventory = str(item.get("inventory") or "used").lower() | |
| 203 | + if inventory == "new": # occasion seulement | |
| 204 | + return None | |
| 205 | + | |
| 206 | + ext_id = str(item.get("id") or item.get("stocknumber") or "") | |
| 207 | + url = str(item.get("permalink") or "") | |
| 208 | + if not ext_id or not url: | |
| 209 | + return None | |
| 210 | + | |
| 211 | + make = str(item.get("make") or "").strip() | |
| 212 | + model = str(item.get("model") or "").strip() | |
| 213 | + trim = _clean(item.get("trim")) | |
| 214 | + year = item.get("year") | |
| 215 | + title = _clean(item.get("title")) | |
| 216 | + title = re.sub(r"\s+d[’']occasion à vendre\s*$", "", title, flags=re.I) \ | |
| 217 | + or " ".join(str(x) for x in (make, model, trim, year or "") if x) | |
| 218 | + | |
| 219 | + price = item.get("price") | |
| 220 | + try: | |
| 221 | + price = float(price) if price else None | |
| 222 | + except (TypeError, ValueError): | |
| 223 | + price = None | |
| 224 | + | |
| 225 | + km = item.get("kms") | |
| 226 | + try: | |
| 227 | + km = float(km) if km not in (None, "") else None | |
| 228 | + except (TypeError, ValueError): | |
| 229 | + km = None | |
| 230 | + | |
| 231 | + specs = detail.get("specs") or {} | |
| 232 | + engine = specs.get("Moteur", "") | |
| 233 | + seats = None | |
| 234 | + m = re.search(r"\d+", specs.get("Passagers", "")) | |
| 235 | + if m: | |
| 236 | + seats = int(m.group(0)) | |
| 237 | + | |
| 238 | + # carburant : déduit du titre/version/moteur (non affiché ailleurs) | |
| 239 | + fuel = "" | |
| 240 | + m = _FUEL_HINT_RE.search(f"{title} {trim} {engine}") | |
| 241 | + if m: | |
| 242 | + fuel = m.group(1) | |
| 243 | + elif (_CYL_ENGINE_RE.search(engine) | |
| 244 | + and model.lower() not in _ELECTRIFIED_MODELS | |
| 245 | + and not _DIESEL_PRONE_RE.search(f"{title} {model} {trim}")): | |
| 246 | + # moteur « N cylindres X.XL » sans aucun indice électrifié/diesel | |
| 247 | + fuel = "Essence" | |
| 248 | + | |
| 249 | + images = list(detail.get("images") or []) | |
| 250 | + if not images and item.get("photo"): | |
| 251 | + images = [str(item["photo"])] | |
| 252 | + | |
| 253 | + details: dict = {"inventory_type": inventory} | |
| 254 | + if item.get("rebate"): | |
| 255 | + details["rebate"] = item["rebate"] | |
| 256 | + if str(item.get("is_certified") or "0") not in ("0", "", "None"): | |
| 257 | + details["certified"] = True | |
| 258 | + if specs.get("Cabine"): | |
| 259 | + details["cab"] = specs["Cabine"] | |
| 260 | + if detail.get("address"): | |
| 261 | + details["branch_address"] = detail["address"] | |
| 262 | + if detail.get("dealer"): | |
| 263 | + details["branch"] = detail["dealer"] | |
| 264 | + | |
| 265 | + return Vehicle( | |
| 266 | + source=self.source_id, | |
| 267 | + external_id=ext_id, | |
| 268 | + url=url, | |
| 269 | + title=title, | |
| 270 | + make=make, | |
| 271 | + model=model, | |
| 272 | + trim=trim, | |
| 273 | + year=int(year) if year else None, | |
| 274 | + price=price, | |
| 275 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 276 | + mileage_km=km, | |
| 277 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 278 | + transmission=specs.get("Transmission", ""), | |
| 279 | + fuel=fuel, | |
| 280 | + drivetrain=specs.get("Rouage", ""), | |
| 281 | + body_type="", # non exposé par la source | |
| 282 | + exterior_color=specs.get("Couleur extérieure", ""), | |
| 283 | + interior_color=specs.get("Couleur intérieure", ""), | |
| 284 | + engine=engine, | |
| 285 | + seats=seats, | |
| 286 | + vin=str(item.get("vin") or ""), | |
| 287 | + stock_number=str(item.get("stocknumber") or ""), | |
| 288 | + dealer_name=detail.get("dealer") or self.dealer_name, | |
| 289 | + city=detail.get("city", ""), | |
| 290 | + details=details, | |
| 291 | + images=images[:20], | |
| 292 | + ) | |
added
apps/auto/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
apps/auto/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
apps/auto/autoka/connectors/montjolichrysler.py
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/montjolichrysler.py : connecteur Chrysler Mont-Joli | |
| 5 | +# (montjolichrysler.com), concessionnaire Chrysler/Dodge/Jeep/RAM de | |
| 6 | +# Mont-Joli (Bas-Saint-Laurent, près de Rimouski et Matane). | |
| 7 | +# | |
| 8 | +# Plateforme : Next.js App Router (autoroot.io), inventaire EvalAuto — | |
| 9 | +# même moteur qu'Occasion Beaucage / Ste-Foy Chrysler : les pages liste | |
| 10 | +# /auto-usage/<n>/ embarquent dans leur flux RSC (self.__next_f.push) | |
| 11 | +# l'objet {"vehicles":[...],"count":N} complet (prix, km, VIN, stock, | |
| 12 | +# transmission, carburant, carrosserie, couleurs, photos, | |
| 13 | +# display_dealer_name / display_city). On réutilise OccasionBeaucage, | |
| 14 | +# seuls l'URL et les libellés changent. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +from .occasionbeaucage import OccasionBeaucage | |
| 19 | + | |
| 20 | + | |
| 21 | +class MontJoliChrysler(OccasionBeaucage): | |
| 22 | + """Chrysler Mont-Joli — pages liste Next.js RSC (pattern Occasion Beaucage).""" | |
| 23 | + | |
| 24 | + source_id = "montjolichrysler" | |
| 25 | + base_url = "https://www.montjolichrysler.com" | |
| 26 | + dealer_name = "Chrysler Mont-Joli" | |
| 27 | + city = "Mont-Joli" | |
| 28 | + max_pages = 30 | |
| 29 | + | |
| 30 | + def _to_vehicle(self, d, blob): | |
| 31 | + veh = super()._to_vehicle(d, blob) | |
| 32 | + if veh is not None and not veh.city: | |
| 33 | + veh.city = self.city | |
| 34 | + return veh | |
added
apps/auto/autoka/connectors/moto_dealers.py
+600 −0
@@ -0,0 +1,600 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de véhicules usagés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/moto_dealers.py : verticale MOTOS & SCOOTERS usagés — connecteurs | |
| 5 | +# des concessionnaires de motocyclettes/scooters du Québec. | |
| 6 | +# | |
| 7 | +# Chaque véhicule émis porte kind="moto" ou kind="scooter" (jamais "auto"). | |
| 8 | +# Les motoneiges, VTT, côte-à-côte, motomarines, bateaux, VR et vélos sont | |
| 9 | +# systématiquement EXCLUS. Les marques moto (Harley-Davidson, Indian, KTM, | |
| 10 | +# Vespa…) ne sont pas dans normalize.MAKES : make est TOUJOURS rempli | |
| 11 | +# explicitement depuis la source (JSON inventaire ou brand JSON-LD), on ne | |
| 12 | +# compte jamais sur split_title(). | |
| 13 | +# | |
| 14 | +# Plateformes rencontrées dans cette verticale : | |
| 15 | +# | |
| 16 | +# 1. PowerGo (powergo.ca) — de très loin la plus répandue chez les | |
| 17 | +# concessionnaires de sports motorisés au Québec (l'équivalent moto de | |
| 18 | +# D2C Media côté autos). Sites Next.js ; l'inventaire COMPLET (neuf + | |
| 19 | +# usagé, tous types) est publié dans un JSON public : | |
| 20 | +# <base>/data/vehicles-inventory-fr-items.json | |
| 21 | +# → type, category, conditionCode (new/used), make, model, year, prix, | |
| 22 | +# usage (km), stock, URL de la page détail, 1re photo (gabarit CDN). | |
| 23 | +# Chaque page détail expose en plus un JSON-LD schema.org @type Vehicle | |
| 24 | +# (parfois dans un @graph) : galerie complète, description, couleur, | |
| 25 | +# kilométrage… Pages détail en cache BD, clé = prix+km du listing (donc | |
| 26 | +# revalidation automatique quand le prix ou le km change). | |
| 27 | +# NB : les sites PowerGo « ancien gabarit » (premonthd.com, lavalmoto.com, | |
| 28 | +# motojmf.com, motopro.ca…) n'exposent PAS ce JSON (listing 100 % JS). | |
| 29 | +# | |
| 30 | +# 2. Magento/Traction (Moto Ducharme) — listing HTML server-rendered | |
| 31 | +# par catégorie (/fr/vehicules-d-occasion/motocyclettes), pages détail | |
| 32 | +# avec JSON-LD @type Product + table de spécifications (Kilométrage, | |
| 33 | +# Couleur…) + galerie JSON Magento. | |
| 34 | +# | |
| 35 | +# 3. D2C Media — aucun des concessionnaires moto candidats sondés n'y est | |
| 36 | +# (PowerGo domine cette verticale), mais la base D2CMotoConnector est | |
| 37 | +# prête : mêmes mécanismes que D2CConnector (sitemap /fr/sitemap.xml + | |
| 38 | +# JSON-LD des pages /occasion/…-idNNN.html), avec un filtre de segments | |
| 39 | +# INVERSÉ (inclut motos/scooters, exclut le reste) et kind rempli. | |
| 40 | +# ----------------------------------------------------------------------------- | |
| 41 | +from __future__ import annotations | |
| 42 | + | |
| 43 | +import datetime | |
| 44 | +import json | |
| 45 | +import os | |
| 46 | +import re | |
| 47 | + | |
| 48 | +from ..schema import Vehicle | |
| 49 | +from .base import BaseConnector | |
| 50 | +from .d2c_dealers import D2CConnector, _LD_RE, _VEH_URL_RE, _clean | |
| 51 | + | |
| 52 | +# --- détection moto vs scooter ------------------------------------------------ | |
| 53 | +# scooter si la catégorie/le segment d'URL/le titre évoque un scooter ou un | |
| 54 | +# cyclomoteur (Vespa, Burgman, BWS, Kymco…) — sinon moto | |
| 55 | +_SCOOTER_RE = re.compile( | |
| 56 | + r"scooter|cyclomoteur|moped|vespa|piaggio\b|burgman|bws\b|kymco|" | |
| 57 | + r"primavera|typhoon\b|ruckus|metropolitan\b|elite\s*125|majesty|" | |
| 58 | + r"xmax|x-max|nmax|address\b|like\s*200|agility\b|super\s*8\b", re.I) | |
| 59 | + | |
| 60 | + | |
| 61 | +def moto_kind(*texts: str) -> str: | |
| 62 | + """kind Auto-Ka ("moto" | "scooter") d'après des indices textuels.""" | |
| 63 | + blob = " ".join(t for t in texts if t) | |
| 64 | + return "scooter" if _SCOOTER_RE.search(blob) else "moto" | |
| 65 | + | |
| 66 | + | |
| 67 | +# ============================================================================== | |
| 68 | +# 1. D2C Media — base moto/scooter (aucun site connecté pour l'instant ; voir | |
| 69 | +# l'en-tête — prête pour le premier concessionnaire moto rencontré sur D2C) | |
| 70 | +# ============================================================================== | |
| 71 | + | |
| 72 | +class D2CMotoConnector(D2CConnector): | |
| 73 | + """Base D2C Media pour les concessionnaires de MOTOS/SCOOTERS. | |
| 74 | + | |
| 75 | + Même stratégie que D2CConnector (sitemap + JSON-LD des pages détail), | |
| 76 | + mais le filtre de segments /occasion/<segment>/ est inversé : on ne | |
| 77 | + garde QUE les catégories moto/scooter, et on exclut explicitement les | |
| 78 | + motoneiges, VTT, côte-à-côte, motomarines, VR, etc. | |
| 79 | + """ | |
| 80 | + | |
| 81 | + # catégories INCLUSES (\b après moto : « motoneige »/« motomarine » ne | |
| 82 | + # matchent pas) | |
| 83 | + _MOTO_SEGMENTS = re.compile( | |
| 84 | + r"scooter|cyclomoteur|moto(?:s)?\b|motocyclette", re.I) | |
| 85 | + # catégories toujours EXCLUES, même si un segment voisin est moto | |
| 86 | + _NON_MOTO_SEGMENTS = re.compile( | |
| 87 | + r"motoneige|vtt|c[oô]te[s]?-?[aà]-?c[oô]te|motomarine|spyder|ryker|" | |
| 88 | + r"side|atv|snow|bateau|ponton|nautique|roulotte|remorque|\bvr\b|" | |
| 89 | + r"campeur|tracteur|souffleuse|v[ée]lo|auto", re.I) | |
| 90 | + | |
| 91 | + def _vehicle_urls(self) -> list[str]: | |
| 92 | + xml = self._get_text(f"{self.base_url}/fr/sitemap.xml") | |
| 93 | + urls: list[str] = [] | |
| 94 | + for loc in re.findall(r"<loc>([^<]+)</loc>", xml): | |
| 95 | + loc = loc.strip() | |
| 96 | + if "/occasion/" not in loc or not _VEH_URL_RE.search(loc): | |
| 97 | + continue | |
| 98 | + segments = loc.split("/occasion/", 1)[1].split("/")[:-1] | |
| 99 | + if any(self._NON_MOTO_SEGMENTS.search(s) for s in segments): | |
| 100 | + continue | |
| 101 | + if not any(self._MOTO_SEGMENTS.search(s) for s in segments): | |
| 102 | + continue # autos et catégories inconnues | |
| 103 | + urls.append(loc) | |
| 104 | + return urls | |
| 105 | + | |
| 106 | + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: | |
| 107 | + veh = super()._to_vehicle(ext_id, url, d) | |
| 108 | + if veh is None: | |
| 109 | + return None | |
| 110 | + path = url.split("/occasion/", 1)[-1] | |
| 111 | + veh.kind = moto_kind(path, str(d.get("bodyType") or ""), veh.title) | |
| 112 | + return veh | |
| 113 | + | |
| 114 | + | |
| 115 | +# ============================================================================== | |
| 116 | +# 2. PowerGo — base commune (JSON inventaire + JSON-LD des pages détail) | |
| 117 | +# ============================================================================== | |
| 118 | + | |
| 119 | +# gabarit CDN de la 1re photo du listing : {imgWidth} → largeur désirée | |
| 120 | +_PG_IMG_WIDTH_RE = re.compile(r"\{imgWidth\}") | |
| 121 | + | |
| 122 | +MAX_IMAGES = 25 | |
| 123 | + | |
| 124 | + | |
| 125 | +def _pg_extract_ld_vehicle(html: str) -> dict: | |
| 126 | + """JSON-LD @type Vehicle/Car/Motorcycle d'une page détail PowerGo. | |
| 127 | + | |
| 128 | + Selon le gabarit, le nœud est au premier niveau, dans une liste, ou dans | |
| 129 | + un @graph (ex. sites Harley-Davidson Gabriel/St-Jérôme). | |
| 130 | + """ | |
| 131 | + for block in _LD_RE.findall(html): | |
| 132 | + try: | |
| 133 | + cand = json.loads(block.strip()) | |
| 134 | + except ValueError: | |
| 135 | + continue | |
| 136 | + stack = cand if isinstance(cand, list) else [cand] | |
| 137 | + for item in stack: | |
| 138 | + if not isinstance(item, dict): | |
| 139 | + continue | |
| 140 | + nodes = item.get("@graph") if isinstance(item.get("@graph"), list) \ | |
| 141 | + else [item] | |
| 142 | + for node in nodes: | |
| 143 | + if not isinstance(node, dict): | |
| 144 | + continue | |
| 145 | + types = node.get("@type") | |
| 146 | + types = types if isinstance(types, list) else [types] | |
| 147 | + if any(t in ("Vehicle", "Car", "Motorcycle") for t in types): | |
| 148 | + return node | |
| 149 | + return {} | |
| 150 | + | |
| 151 | + | |
| 152 | +class PowerGoMotoConnector(BaseConnector): | |
| 153 | + """Base commune aux sites PowerGo. Sous-classes : base_url + ville. | |
| 154 | + | |
| 155 | + fetch() : JSON inventaire complet → filtre usagé + motos/scooters → | |
| 156 | + enrichissement JSON-LD par page détail (cache BD, clé prix+km : seuls les | |
| 157 | + nouveaux véhicules et les changements de prix/km génèrent des requêtes). | |
| 158 | + Au-delà du plafond de requêtes détail, le véhicule est quand même émis | |
| 159 | + avec les champs (déjà riches) du listing JSON. | |
| 160 | + """ | |
| 161 | + | |
| 162 | + base_url: str = "" | |
| 163 | + dealer_name: str = "" | |
| 164 | + city: str = "" | |
| 165 | + request_delay: float = 1.0 | |
| 166 | + max_details = 400 | |
| 167 | + | |
| 168 | + # unaccentedType inclus (préfixes, insensible au singulier/pluriel) : | |
| 169 | + # motocyclette(s) et scooters ; « motos trois roues » (Spyder/Ryker, | |
| 170 | + # immatriculées moto au Québec) incluses aussi. Tout le reste (motoneige, | |
| 171 | + # vtt, côte-à-côte, motomarine, bateau, vélo, remorque…) est exclu. | |
| 172 | + include_types = ("motocyclette", "motos trois roues", "scooter") | |
| 173 | + | |
| 174 | + def _kind(self, item: dict) -> str: | |
| 175 | + return moto_kind(str(item.get("unaccentedCategory") or ""), | |
| 176 | + str(item.get("unaccentedType") or ""), | |
| 177 | + str(item.get("label") or "")) | |
| 178 | + | |
| 179 | + # -- page détail -> JSON-LD -------------------------------------------------- | |
| 180 | + def _get_utf8(self, url: str) -> str: | |
| 181 | + """GET dont le texte est décodé en UTF-8. | |
| 182 | + | |
| 183 | + Les serveurs PowerGo n'envoient pas de charset ; requests retombe | |
| 184 | + alors sur ISO-8859-1 et les accents deviennent du mojibake | |
| 185 | + (« Numéro » au lieu de « Numéro »). | |
| 186 | + """ | |
| 187 | + resp = self.get(url) | |
| 188 | + if not resp.encoding or resp.encoding.lower() in ("iso-8859-1", "latin-1"): | |
| 189 | + resp.encoding = "utf-8" | |
| 190 | + return resp.text | |
| 191 | + | |
| 192 | + def _fetch_detail(self, url: str) -> dict: | |
| 193 | + return _pg_extract_ld_vehicle(self._get_utf8(url)) | |
| 194 | + | |
| 195 | + # -- contrat ------------------------------------------------------------- | |
| 196 | + def fetch(self) -> list[Vehicle]: | |
| 197 | + items = self.get( | |
| 198 | + f"{self.base_url}/data/vehicles-inventory-fr-items.json").json() | |
| 199 | + if not isinstance(items, list): | |
| 200 | + return [] | |
| 201 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 202 | + | |
| 203 | + vehicles: list[Vehicle] = [] | |
| 204 | + real_fetches = 0 | |
| 205 | + for it in items: | |
| 206 | + if not isinstance(it, dict): | |
| 207 | + continue | |
| 208 | + if it.get("conditionCode") != "used" or it.get("isSold"): | |
| 209 | + continue | |
| 210 | + utype = str(it.get("unaccentedType") or "").lower().strip() | |
| 211 | + if not utype.startswith(self.include_types): | |
| 212 | + continue | |
| 213 | + page = (it.get("page") or {}).get("url") or "" | |
| 214 | + if not page: | |
| 215 | + continue | |
| 216 | + url = self.base_url + page | |
| 217 | + ext_id = str(it.get("vehicle_id") or it.get("stockNumber") or page) | |
| 218 | + # clé de cache = prix + km : revalidation automatique au changement | |
| 219 | + key = f"v1:{it.get('salePriceValue')}:{it.get('usageValue')}" | |
| 220 | + | |
| 221 | + ld: dict = {} | |
| 222 | + if real_fetches < cap: | |
| 223 | + before = self._last_request | |
| 224 | + try: | |
| 225 | + ld = self.detail(ext_id, key, | |
| 226 | + lambda u=url: self._fetch_detail(u)) | |
| 227 | + except Exception: | |
| 228 | + ld = {} # page détail disparue : listing seul | |
| 229 | + if self._last_request != before: | |
| 230 | + real_fetches += 1 | |
| 231 | + else: # plafond atteint : cache BD sinon listing seul | |
| 232 | + from .. import db | |
| 233 | + if self._detail_con is None: | |
| 234 | + self._detail_con = db.connect() | |
| 235 | + ld = db.get_cached_detail(self._detail_con, self.source_id, | |
| 236 | + ext_id, key) or {} | |
| 237 | + try: | |
| 238 | + veh = self._to_vehicle(ext_id, url, it, ld or {}) | |
| 239 | + except Exception: | |
| 240 | + continue | |
| 241 | + if veh is not None: | |
| 242 | + vehicles.append(veh) | |
| 243 | + return vehicles | |
| 244 | + | |
| 245 | + def _to_vehicle(self, ext_id: str, url: str, it: dict, | |
| 246 | + ld: dict) -> Vehicle | None: | |
| 247 | + title = _clean(it.get("label")) or _clean(ld.get("name")) | |
| 248 | + if not title: | |
| 249 | + return None | |
| 250 | + | |
| 251 | + price = it.get("salePriceValue") or it.get("basePriceValue") | |
| 252 | + if price in (None, "", 0, "0") and isinstance(ld.get("offers"), dict): | |
| 253 | + price = ld["offers"].get("price") | |
| 254 | + try: | |
| 255 | + price = float(price) if price not in (None, "", 0, "0") else None | |
| 256 | + except (TypeError, ValueError): | |
| 257 | + price = None | |
| 258 | + | |
| 259 | + # usage : PowerGo mélange km et heures (motocross) — km seulement si | |
| 260 | + # l'étiquette le confirme ; sinon repli JSON-LD (unitCode KMT) | |
| 261 | + km = None | |
| 262 | + usage_label = _clean(it.get("usageLabel")) | |
| 263 | + if it.get("usageValue") and "km" in usage_label.lower(): | |
| 264 | + try: | |
| 265 | + km = float(it["usageValue"]) | |
| 266 | + except (TypeError, ValueError): | |
| 267 | + km = None | |
| 268 | + if km is None: | |
| 269 | + odo = ld.get("mileageFromOdometer") | |
| 270 | + if isinstance(odo, dict) and odo.get("unitCode") in ("KMT", None): | |
| 271 | + try: | |
| 272 | + km = float(odo.get("value")) | |
| 273 | + except (TypeError, ValueError): | |
| 274 | + km = None | |
| 275 | + | |
| 276 | + year = it.get("year") | |
| 277 | + if not year and ld.get("vehicleModelDate"): | |
| 278 | + year = ld["vehicleModelDate"] | |
| 279 | + try: | |
| 280 | + year = int(str(year)[:4]) if year else None | |
| 281 | + except ValueError: | |
| 282 | + year = None | |
| 283 | + | |
| 284 | + make = _clean(it.get("make")) | |
| 285 | + if not make: | |
| 286 | + brand = ld.get("brand") or {} | |
| 287 | + make = _clean(brand.get("name") if isinstance(brand, dict) | |
| 288 | + else str(brand)) or _clean(ld.get("manufacturer")) | |
| 289 | + | |
| 290 | + images = ld.get("image") or [] | |
| 291 | + if isinstance(images, str): | |
| 292 | + images = [images] | |
| 293 | + images = [u for u in images if isinstance(u, str) and u.startswith("http")] | |
| 294 | + if not images: # repli : 1re photo du listing | |
| 295 | + img = it.get("image") or {} | |
| 296 | + src = str(img.get("source") or "") | |
| 297 | + dims = [d for d in (img.get("dimensions") or []) if isinstance(d, int)] | |
| 298 | + if src: | |
| 299 | + images = [_PG_IMG_WIDTH_RE.sub(str(max(dims) if dims else 1000), | |
| 300 | + src)] | |
| 301 | + | |
| 302 | + details = {k: v for k, v in (("type", _clean(it.get("type"))), | |
| 303 | + ("category", _clean(it.get("category")))) | |
| 304 | + if v} | |
| 305 | + | |
| 306 | + return Vehicle( | |
| 307 | + source=self.source_id, | |
| 308 | + external_id=ext_id, | |
| 309 | + url=url, | |
| 310 | + kind=self._kind(it), | |
| 311 | + title=title, | |
| 312 | + make=make, # explicite : marques moto absentes | |
| 313 | + model=_clean(it.get("model")) or _clean(ld.get("model")), | |
| 314 | + year=year, | |
| 315 | + price=price, | |
| 316 | + price_label=_clean(it.get("salePriceLabel") | |
| 317 | + or it.get("basePriceLabel")), | |
| 318 | + mileage_km=km, | |
| 319 | + mileage_label=usage_label, | |
| 320 | + exterior_color=_clean(ld.get("color")), | |
| 321 | + stock_number=_clean(it.get("stockNumber")) or _clean(ld.get("sku")), | |
| 322 | + dealer_name=self.dealer_name, | |
| 323 | + # groupes multi-succursales (Contant, Imperium…) : la succursale | |
| 324 | + # du véhicule est dans le listing JSON | |
| 325 | + city=_clean(it.get("location")) or self.city, | |
| 326 | + description=_clean(ld.get("description"))[:4000], | |
| 327 | + details=details, | |
| 328 | + images=images[:MAX_IMAGES], | |
| 329 | + ) | |
| 330 | + | |
| 331 | + | |
| 332 | +# ------------------------------------------------------------------------------ | |
| 333 | +# Concessionnaires PowerGo (1 sous-classe = 1 source) | |
| 334 | +# ------------------------------------------------------------------------------ | |
| 335 | + | |
| 336 | +class MathiasSports(PowerGoMotoConnector): | |
| 337 | + source_id = "mathiassports" | |
| 338 | + base_url = "https://mathiassports.com" | |
| 339 | + dealer_name = "Mathias Sports" | |
| 340 | + city = "Saint-Mathias-sur-Richelieu" | |
| 341 | + | |
| 342 | + | |
| 343 | +class SMSport(PowerGoMotoConnector): | |
| 344 | + source_id = "smsport" | |
| 345 | + base_url = "https://smsport.ca" | |
| 346 | + dealer_name = "SM Sport" | |
| 347 | + city = "Québec" | |
| 348 | + | |
| 349 | + | |
| 350 | +class MotosIllimitees(PowerGoMotoConnector): | |
| 351 | + source_id = "motosillimitees" | |
| 352 | + base_url = "https://www.motosillimitees.com" | |
| 353 | + dealer_name = "Motos Illimitées" | |
| 354 | + city = "Terrebonne" | |
| 355 | + | |
| 356 | + | |
| 357 | +class RMMotosport(PowerGoMotoConnector): | |
| 358 | + source_id = "rmmotosport" | |
| 359 | + base_url = "https://www.rmmotosport.com" | |
| 360 | + dealer_name = "RM Motosport" | |
| 361 | + city = "Victoriaville" | |
| 362 | + | |
| 363 | + | |
| 364 | +class GLSport(PowerGoMotoConnector): | |
| 365 | + source_id = "glsport" | |
| 366 | + base_url = "https://www.glsport.ca" | |
| 367 | + dealer_name = "GL Sport" | |
| 368 | + city = "Saint-Gervais" | |
| 369 | + | |
| 370 | + | |
| 371 | +class ExcelMoto(PowerGoMotoConnector): | |
| 372 | + source_id = "excelmoto" | |
| 373 | + base_url = "https://www.excelmoto.com" | |
| 374 | + dealer_name = "Excel Moto" | |
| 375 | + city = "Montréal" | |
| 376 | + | |
| 377 | + | |
| 378 | +class GroupeContant(PowerGoMotoConnector): | |
| 379 | + # groupe multi-succursales : Mirabel, Repentigny, Vaudreuil, Beloeil, | |
| 380 | + # Ste-Agathe — la ville de chaque unité vient du listing (location) | |
| 381 | + source_id = "groupecontant" | |
| 382 | + base_url = "https://www.contant.ca" | |
| 383 | + dealer_name = "Groupe Contant" | |
| 384 | + city = "Repentigny" | |
| 385 | + | |
| 386 | + | |
| 387 | +class GabrielHarleyDavidson(PowerGoMotoConnector): | |
| 388 | + source_id = "gabrielharleydavidson" | |
| 389 | + base_url = "https://gabrielharleydavidsonmtl.com" | |
| 390 | + dealer_name = "Gabriel Harley-Davidson Montréal" | |
| 391 | + city = "Montréal" | |
| 392 | + | |
| 393 | + | |
| 394 | +class StJeromeHarleyDavidson(PowerGoMotoConnector): | |
| 395 | + source_id = "stjeromeharleydavidson" | |
| 396 | + base_url = "https://stjeromeharleydavidson.com" | |
| 397 | + dealer_name = "St-Jérôme Harley-Davidson" | |
| 398 | + city = "Saint-Jérôme" | |
| 399 | + | |
| 400 | + | |
| 401 | +class Mecamoto(PowerGoMotoConnector): | |
| 402 | + # spécialiste Vespa / Piaggio / Moto Guzzi — la plupart des scooters | |
| 403 | + # usagés arrivent en catégorie « Cyclomoteurs » | |
| 404 | + source_id = "mecamoto" | |
| 405 | + base_url = "https://www.mecamoto.ca" | |
| 406 | + dealer_name = "Mecamoto" | |
| 407 | + city = "Montréal" | |
| 408 | + | |
| 409 | + | |
| 410 | +class MotosThibaultTR(PowerGoMotoConnector): | |
| 411 | + # concessionnaire Vespa / Aprilia / Suzuki de la Mauricie | |
| 412 | + source_id = "motosthibaulttr" | |
| 413 | + base_url = "https://www.motosthibault.ca" | |
| 414 | + dealer_name = "Motos Thibault Trois-Rivières" | |
| 415 | + city = "Trois-Rivières" | |
| 416 | + | |
| 417 | + | |
| 418 | +class GroupeImperium(PowerGoMotoConnector): | |
| 419 | + # groupe multi-succursales : Chicoutimi, Dolbeau-Mistassini, Chibougamau, | |
| 420 | + # Rouyn-Noranda — succursale par véhicule via location | |
| 421 | + source_id = "groupeimperium" | |
| 422 | + base_url = "https://www.groupeimperium.ca" | |
| 423 | + dealer_name = "Groupe Imperium" | |
| 424 | + city = "Chicoutimi" | |
| 425 | + | |
| 426 | + | |
| 427 | +# ============================================================================== | |
| 428 | +# 3. Moto Ducharme (Joliette) — Magento (thème Traction) | |
| 429 | +# Listing par catégorie server-rendered ; détail : JSON-LD @type Product + | |
| 430 | +# table de spécifications (Kilométrage, Couleur…) + galerie JSON Magento. | |
| 431 | +# ============================================================================== | |
| 432 | + | |
| 433 | +_MD_DETAIL_RE = re.compile( | |
| 434 | + r'href="(https://www\.motoducharme\.com/fr/vehicules-d-occasion/' | |
| 435 | + r'motocyclettes/[a-z0-9-]+-cs-(\d+))"') | |
| 436 | +_MD_SPEC_RE = re.compile( | |
| 437 | + r'<th class="col label"[^>]*>\s*([^<]+?)\s*</th>\s*' | |
| 438 | + r'<td class="col data"[^>]*>\s*([^<]+?)\s*</td>', re.S) | |
| 439 | +_MD_GALLERY_RE = re.compile(r'"data":\s*(\[\{"thumb".*?\}\])') | |
| 440 | +_MD_YEAR_RE = re.compile(r"\b(19[89]\d|20[0-2]\d)\b") | |
| 441 | + | |
| 442 | + | |
| 443 | +class MotoDucharme(BaseConnector): | |
| 444 | + source_id = "motoducharme" | |
| 445 | + base_url = "https://www.motoducharme.com" | |
| 446 | + listing_url = "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes" | |
| 447 | + dealer_name = "Moto Ducharme" | |
| 448 | + city = "Joliette" | |
| 449 | + request_delay = 1.0 | |
| 450 | + max_details = 150 | |
| 451 | + max_pages = 5 # ~15 motos affichées, marge pour grandir | |
| 452 | + | |
| 453 | + def _listing_urls(self) -> list[tuple[str, str]]: | |
| 454 | + """[(url, ext_id)] des motos usagées, pages ?p=N de Magento.""" | |
| 455 | + seen: dict[str, str] = {} | |
| 456 | + max_pages = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) | |
| 457 | + for p in range(1, max_pages + 1): | |
| 458 | + url = self.listing_url if p == 1 else f"{self.listing_url}?p={p}" | |
| 459 | + try: | |
| 460 | + html = self.get(url).text | |
| 461 | + except Exception: | |
| 462 | + break | |
| 463 | + before = len(seen) | |
| 464 | + for detail_url, ext_id in _MD_DETAIL_RE.findall(html): | |
| 465 | + seen.setdefault(ext_id, detail_url) | |
| 466 | + if len(seen) == before: # plus rien de neuf : fin | |
| 467 | + break | |
| 468 | + return [(u, i) for i, u in seen.items()] | |
| 469 | + | |
| 470 | + def _fetch_detail(self, url: str) -> dict: | |
| 471 | + html = self.get(url).text | |
| 472 | + data: dict = {} | |
| 473 | + for block in _LD_RE.findall(html): | |
| 474 | + try: | |
| 475 | + cand = json.loads(block.strip()) | |
| 476 | + except ValueError: | |
| 477 | + continue | |
| 478 | + if isinstance(cand, dict) and cand.get("@type") == "Product": | |
| 479 | + data = cand | |
| 480 | + break | |
| 481 | + if not data: | |
| 482 | + return {} | |
| 483 | + specs = {_clean(k): _clean(v) for k, v in _MD_SPEC_RE.findall(html)} | |
| 484 | + if specs: | |
| 485 | + data["_specs"] = specs | |
| 486 | + m = _MD_GALLERY_RE.search(html) | |
| 487 | + if m: | |
| 488 | + try: | |
| 489 | + gallery = json.loads(m.group(1)) | |
| 490 | + data["_gallery"] = [g.get("full") or g.get("img") | |
| 491 | + for g in gallery | |
| 492 | + if isinstance(g, dict) | |
| 493 | + and (g.get("full") or g.get("img"))] | |
| 494 | + except ValueError: | |
| 495 | + pass | |
| 496 | + return data | |
| 497 | + | |
| 498 | + def fetch(self) -> list[Vehicle]: | |
| 499 | + week = datetime.date.today().isocalendar() | |
| 500 | + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdo | |
| 501 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 502 | + | |
| 503 | + vehicles: list[Vehicle] = [] | |
| 504 | + real_fetches = 0 | |
| 505 | + for url, ext_id in self._listing_urls(): | |
| 506 | + if real_fetches >= cap: | |
| 507 | + from .. import db | |
| 508 | + if self._detail_con is None: | |
| 509 | + self._detail_con = db.connect() | |
| 510 | + data = db.get_cached_detail(self._detail_con, self.source_id, | |
| 511 | + ext_id, cache_key) | |
| 512 | + if data is None: | |
| 513 | + continue | |
| 514 | + else: | |
| 515 | + before = self._last_request | |
| 516 | + try: | |
| 517 | + data = self.detail(ext_id, cache_key, | |
| 518 | + lambda u=url: self._fetch_detail(u)) | |
| 519 | + except Exception: | |
| 520 | + if self._last_request != before: | |
| 521 | + real_fetches += 1 | |
| 522 | + continue | |
| 523 | + if self._last_request != before: | |
| 524 | + real_fetches += 1 | |
| 525 | + if not data: | |
| 526 | + continue | |
| 527 | + try: | |
| 528 | + veh = self._to_vehicle(ext_id, url, data) | |
| 529 | + except Exception: | |
| 530 | + continue | |
| 531 | + if veh is not None: | |
| 532 | + vehicles.append(veh) | |
| 533 | + return vehicles | |
| 534 | + | |
| 535 | + def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: | |
| 536 | + title = _clean(d.get("name")) | |
| 537 | + if not title: | |
| 538 | + return None | |
| 539 | + specs = d.get("_specs") or {} | |
| 540 | + | |
| 541 | + offers = d.get("offers") or {} | |
| 542 | + try: | |
| 543 | + price = float(offers.get("price")) | |
| 544 | + if price <= 0: | |
| 545 | + price = None | |
| 546 | + except (TypeError, ValueError): | |
| 547 | + price = None | |
| 548 | + | |
| 549 | + km = None | |
| 550 | + for label, value in specs.items(): | |
| 551 | + if "kilom" in label.lower(): | |
| 552 | + digits = re.sub(r"[^\d]", "", value) | |
| 553 | + if digits: | |
| 554 | + km = float(digits) | |
| 555 | + break | |
| 556 | + | |
| 557 | + years = _MD_YEAR_RE.findall(title) | |
| 558 | + year = int(years[-1]) if years else None | |
| 559 | + | |
| 560 | + brand = d.get("brand") or {} | |
| 561 | + make = _clean(brand.get("name") if isinstance(brand, dict) | |
| 562 | + else str(brand)) | |
| 563 | + if make.isupper(): # « KAWASAKI » → « Kawasaki » | |
| 564 | + make = make.title() | |
| 565 | + # modèle = titre sans la marque (préfixe, parfois doublée) ni l'année | |
| 566 | + model = title | |
| 567 | + if make: | |
| 568 | + model = re.sub(rf"^(?:{re.escape(make)}\s+)+", "", model, | |
| 569 | + flags=re.I) | |
| 570 | + model = _MD_YEAR_RE.sub("", model).strip(" -") | |
| 571 | + | |
| 572 | + images = d.get("_gallery") or [] | |
| 573 | + if not images and d.get("image"): | |
| 574 | + images = [d["image"]] if isinstance(d["image"], str) else d["image"] | |
| 575 | + | |
| 576 | + color = next((v for k, v in specs.items() | |
| 577 | + if "couleur" in k.lower()), "") | |
| 578 | + | |
| 579 | + return Vehicle( | |
| 580 | + source=self.source_id, | |
| 581 | + external_id=ext_id, | |
| 582 | + url=url, | |
| 583 | + kind=moto_kind(url, title), | |
| 584 | + title=title, | |
| 585 | + make=make, | |
| 586 | + model=" ".join(model.split()), | |
| 587 | + year=year, | |
| 588 | + price=price, | |
| 589 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 590 | + mileage_km=km, | |
| 591 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 592 | + exterior_color=color, | |
| 593 | + stock_number=str(d.get("sku") or ""), | |
| 594 | + dealer_name=self.dealer_name, | |
| 595 | + city=self.city, | |
| 596 | + description=_clean(d.get("description"))[:4000], | |
| 597 | + details={k: v for k, v in specs.items() | |
| 598 | + if v and v.upper() not in ("N.D.", "N/A")}, | |
| 599 | + images=[u for u in images if isinstance(u, str)][:MAX_IMAGES], | |
| 600 | + ) | |
added
apps/auto/autoka/connectors/occasionbeaucage.py
+361 −0
@@ -0,0 +1,361 @@ | ||
| 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 | +# Enrichissement détail : la description complète et la liste d'équipements | |
| 23 | +# ne vivent PAS dans le flux liste — seulement dans le flux RSC de la page | |
| 24 | +# détail /auto-usage/<slug>/, sous forme de refs ($21…) vers des lignes | |
| 25 | +# longues «T<hex>,» (longueur en OCTETS UTF-8) : description = HTML brut, | |
| 26 | +# options = JSON {"fr": "équip1|équip2|…"}. Ces payloads sont mis en cache | |
| 27 | +# BD à clé stable (les équipements ne changent pas ; le prix frais vient de | |
| 28 | +# la liste) et plafonnés par AUTOKA_MAX_DETAILS / max_details par sync — | |
| 29 | +# l'inventaire complet se couvre en quelques synchronisations. | |
| 30 | +# ----------------------------------------------------------------------------- | |
| 31 | +from __future__ import annotations | |
| 32 | + | |
| 33 | +import html as htmllib | |
| 34 | +import json | |
| 35 | +import os | |
| 36 | +import re | |
| 37 | + | |
| 38 | +from ..schema import Vehicle | |
| 39 | +from .base import BaseConnector | |
| 40 | + | |
| 41 | +# chunks du flux RSC : self.__next_f.push([1,"<chaîne JS>"]) | |
| 42 | +_FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,("(?:\\.|[^"\\])*")\]\)') | |
| 43 | + | |
| 44 | +# non-automobiles éventuels (garde-fou ; le groupe ne vend que des autos) | |
| 45 | +_EXCLUDE_RE = re.compile( | |
| 46 | + r"can-?am|harley|ski-?doo|sea-?doo|spyder|ryker|motoneige|vtt|" | |
| 47 | + r"motomarine|roulotte|remorque", re.I) | |
| 48 | + | |
| 49 | + | |
| 50 | +def _js_str(literal: str) -> str: | |
| 51 | + """Chaîne littérale JS (avec guillemets) -> texte. Les chunks RSC sont | |
| 52 | + encodés comme du JSON ; repli tolérant si un escape non-JSON traîne.""" | |
| 53 | + try: | |
| 54 | + return json.loads(literal) | |
| 55 | + except ValueError: | |
| 56 | + out = literal[1:-1].encode("utf-8").decode("unicode_escape") | |
| 57 | + try: # unicode_escape mange l'UTF-8 -> réparer | |
| 58 | + return out.encode("latin-1").decode("utf-8") | |
| 59 | + except (UnicodeEncodeError, UnicodeDecodeError): | |
| 60 | + return out | |
| 61 | + | |
| 62 | + | |
| 63 | +def _s(value) -> str: | |
| 64 | + """Scalaire du flux RSC -> str propre ('$undefined', '$D...' -> '').""" | |
| 65 | + if value is None: | |
| 66 | + return "" | |
| 67 | + text = str(value).strip() | |
| 68 | + return "" if text.startswith("$") else text | |
| 69 | + | |
| 70 | + | |
| 71 | +def _strip_html(text: str | None) -> str: | |
| 72 | + """HTML (avec entités) -> texte brut compact.""" | |
| 73 | + if not text: | |
| 74 | + return "" | |
| 75 | + out = htmllib.unescape(str(text)) | |
| 76 | + out = re.sub(r"<[^>]+>", " ", out) | |
| 77 | + return re.sub(r"\s{2,}", " ", out).strip() | |
| 78 | + | |
| 79 | + | |
| 80 | +# clé de cache détail stable : description/équipements ne changent pas pour | |
| 81 | +# une annonce donnée (bump en cas de changement de parsing) | |
| 82 | +_DETAIL_KEY = "v1" | |
| 83 | + | |
| 84 | + | |
| 85 | +class OccasionBeaucage(BaseConnector): | |
| 86 | + """Occasion Beaucage — inventaire complet via les pages liste (RSC).""" | |
| 87 | + | |
| 88 | + source_id = "occasionbeaucage" | |
| 89 | + base_url = "https://www.occasionbeaucage.com" | |
| 90 | + dealer_name = "Occasion Beaucage" | |
| 91 | + request_delay = 1.0 | |
| 92 | + max_pages = 120 # garde-fou dur (~52 pages réelles) | |
| 93 | + max_details = 200 # plafond de vraies requêtes détail/sync | |
| 94 | + | |
| 95 | + # -- flux RSC ---------------------------------------------------------- | |
| 96 | + def _flight_blob(self, html: str) -> str: | |
| 97 | + return "".join(_js_str(m) for m in _FLIGHT_RE.findall(html)) | |
| 98 | + | |
| 99 | + def _page_payload(self, page: int) -> dict: | |
| 100 | + """{"vehicles": [...], "count": N, "page": n} de la page liste.""" | |
| 101 | + url = f"{self.base_url}/auto-usage/" | |
| 102 | + if page > 1: | |
| 103 | + url += f"{page}/" | |
| 104 | + blob = self._flight_blob(self.get(url).text) | |
| 105 | + decoder = json.JSONDecoder() | |
| 106 | + for m in re.finditer(r'"vehicles":\[', blob): | |
| 107 | + start = blob.rfind("{", 0, m.start()) | |
| 108 | + if start < 0: | |
| 109 | + continue | |
| 110 | + try: | |
| 111 | + obj, _ = decoder.raw_decode(blob, start) | |
| 112 | + except ValueError: | |
| 113 | + continue | |
| 114 | + if isinstance(obj, dict) and "count" in obj \ | |
| 115 | + and isinstance(obj.get("vehicles"), list): | |
| 116 | + obj["_blob"] = blob # pour résoudre les refs $ | |
| 117 | + return obj | |
| 118 | + return {} | |
| 119 | + | |
| 120 | + @staticmethod | |
| 121 | + def _resolve_ref(blob: str, ref: str): | |
| 122 | + """«$313» -> payload JSON de la ligne RSC 313 (ex. liste de photos). | |
| 123 | + | |
| 124 | + Les lignes à préfixe de longueur (T<hex>,) ne sont pas suivies d'un | |
| 125 | + saut de ligne — on repère donc «<id>:» n'importe où et on valide en | |
| 126 | + décodant le JSON qui suit (raw_decode s'arrête à la fin du tableau). | |
| 127 | + """ | |
| 128 | + decoder = json.JSONDecoder() | |
| 129 | + pattern = re.compile(re.escape(ref[1:]) + r':(?:T[0-9a-f]+,)?(?=[\["])') | |
| 130 | + for row in pattern.finditer(blob): | |
| 131 | + try: | |
| 132 | + value, _ = decoder.raw_decode(blob, row.end()) | |
| 133 | + except ValueError: | |
| 134 | + continue | |
| 135 | + if isinstance(value, (list, str)): | |
| 136 | + return value | |
| 137 | + return None | |
| 138 | + | |
| 139 | + @staticmethod | |
| 140 | + def _flight_row(blob: str, ref: str): | |
| 141 | + """«$21» -> contenu de la ligne RSC 21, y compris les lignes longues | |
| 142 | + «21:T6bc,<texte>» dont la longueur T<hex> est en OCTETS UTF-8 et dont | |
| 143 | + le contenu est du texte brut (HTML ou JSON sérialisé), sans saut de | |
| 144 | + ligne final. Les lignes JSON classiques sont décodées directement.""" | |
| 145 | + rid = ref[1:] | |
| 146 | + if not rid or not re.fullmatch(r"[0-9a-f]+", rid): | |
| 147 | + return None # $undefined, $D…, etc. | |
| 148 | + decoder = json.JSONDecoder() | |
| 149 | + pattern = re.compile( | |
| 150 | + r"(?<![0-9a-zA-Z_$])" + re.escape(rid) + r":(?:T([0-9a-f]+),)?") | |
| 151 | + for m in pattern.finditer(blob): | |
| 152 | + if m.group(1): # ligne longue à préfixe T<hex> | |
| 153 | + length = int(m.group(1), 16) | |
| 154 | + raw = blob[m.end():].encode("utf-8")[:length] | |
| 155 | + return raw.decode("utf-8", errors="ignore") | |
| 156 | + if blob[m.end():m.end() + 1] not in '["{': | |
| 157 | + continue | |
| 158 | + try: | |
| 159 | + value, _ = decoder.raw_decode(blob, m.end()) | |
| 160 | + except ValueError: | |
| 161 | + continue | |
| 162 | + return value | |
| 163 | + return None | |
| 164 | + | |
| 165 | + # -- page détail -> payload (description complète + équipements) -------- | |
| 166 | + def _fetch_detail(self, slug: str) -> dict: | |
| 167 | + blob = self._flight_blob( | |
| 168 | + self.get(f"{self.base_url}/auto-usage/{slug}/").text) | |
| 169 | + decoder = json.JSONDecoder() | |
| 170 | + veh: dict | None = None | |
| 171 | + for m in re.finditer(r'"vehicle":\{', blob): | |
| 172 | + try: | |
| 173 | + obj, _ = decoder.raw_decode(blob, m.start() + len('"vehicle":')) | |
| 174 | + except ValueError: | |
| 175 | + continue | |
| 176 | + if isinstance(obj, dict) and obj.get("slug"): | |
| 177 | + veh = obj | |
| 178 | + break | |
| 179 | + if not veh: | |
| 180 | + return {} | |
| 181 | + payload: dict = {} | |
| 182 | + | |
| 183 | + desc = veh.get("description") | |
| 184 | + if isinstance(desc, str) and desc.startswith("$"): | |
| 185 | + desc = self._flight_row(blob, desc) | |
| 186 | + desc = _strip_html(desc if isinstance(desc, str) else "") | |
| 187 | + if desc: | |
| 188 | + payload["description"] = desc[:4000] | |
| 189 | + | |
| 190 | + opts = veh.get("options") | |
| 191 | + if isinstance(opts, str) and opts.startswith("$"): | |
| 192 | + opts = self._flight_row(blob, opts) | |
| 193 | + if isinstance(opts, str) and opts.lstrip().startswith("{"): | |
| 194 | + try: # ligne longue = JSON sérialisé | |
| 195 | + opts = json.loads(opts) | |
| 196 | + except ValueError: | |
| 197 | + opts = None | |
| 198 | + if isinstance(opts, dict): | |
| 199 | + opts = opts.get("fr") or opts.get("en") or "" | |
| 200 | + if isinstance(opts, str) and opts: | |
| 201 | + features = [f.strip() for f in opts.split("|") if f.strip()] | |
| 202 | + if features: | |
| 203 | + payload["features"] = features[:60] | |
| 204 | + return payload | |
| 205 | + | |
| 206 | + # -- contrat ----------------------------------------------------------- | |
| 207 | + def fetch(self) -> list[Vehicle]: | |
| 208 | + cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) | |
| 209 | + vehicles: list[Vehicle] = [] | |
| 210 | + seen: set[str] = set() | |
| 211 | + | |
| 212 | + page, last_page = 1, 1 | |
| 213 | + while page <= min(last_page, cap, self.max_pages): | |
| 214 | + payload = self._page_payload(page) | |
| 215 | + batch = payload.get("vehicles") or [] | |
| 216 | + if not batch: | |
| 217 | + break | |
| 218 | + per_page = max(len(batch), 1) | |
| 219 | + count = int(payload.get("count") or 0) | |
| 220 | + last_page = max(1, -(-count // per_page)) # ceil | |
| 221 | + new = 0 | |
| 222 | + for d in batch: | |
| 223 | + veh = self._to_vehicle(d, payload.get("_blob", "")) | |
| 224 | + if veh is not None and veh.external_id not in seen: | |
| 225 | + seen.add(veh.external_id) | |
| 226 | + vehicles.append(veh) | |
| 227 | + new += 1 | |
| 228 | + if new == 0: # page recyclée | |
| 229 | + break | |
| 230 | + page += 1 | |
| 231 | + | |
| 232 | + self._enrich_details(vehicles) | |
| 233 | + return vehicles | |
| 234 | + | |
| 235 | + # -- enrichissement détail plafonné (cache stable) ----------------------- | |
| 236 | + def _enrich_details(self, vehicles: list[Vehicle]) -> None: | |
| 237 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 238 | + real_fetches = 0 | |
| 239 | + for veh in vehicles: | |
| 240 | + slug = veh.url.rstrip("/").rsplit("/", 1)[-1] | |
| 241 | + detail: dict = {} | |
| 242 | + if real_fetches >= cap: # plafond : cache seulement | |
| 243 | + from .. import db | |
| 244 | + if self._detail_con is None: | |
| 245 | + self._detail_con = db.connect() | |
| 246 | + detail = db.get_cached_detail( | |
| 247 | + self._detail_con, self.source_id, | |
| 248 | + str(veh.external_id), _DETAIL_KEY) or {} | |
| 249 | + else: | |
| 250 | + def _fetch(s=slug): | |
| 251 | + return self._fetch_detail(s) | |
| 252 | + before = self._last_request | |
| 253 | + try: | |
| 254 | + detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch) | |
| 255 | + except Exception: | |
| 256 | + detail = {} # page disparue : données liste | |
| 257 | + if self._last_request != before: | |
| 258 | + real_fetches += 1 | |
| 259 | + if not detail: | |
| 260 | + continue | |
| 261 | + desc = detail.get("description") or "" | |
| 262 | + if len(desc) > len(veh.description or ""): | |
| 263 | + veh.description = desc | |
| 264 | + if detail.get("features") and not veh.features: | |
| 265 | + veh.features = list(detail["features"]) | |
| 266 | + | |
| 267 | + # -- enregistrement liste -> Vehicle ------------------------------------- | |
| 268 | + def _to_vehicle(self, d: dict, blob: str) -> Vehicle | None: | |
| 269 | + if d.get("is_sold") or d.get("is_active") is False: | |
| 270 | + return None | |
| 271 | + | |
| 272 | + slug = _s(d.get("slug")) | |
| 273 | + ext_id = _s(d.get("inventory_provider_vehicle_id")) or slug | |
| 274 | + if not ext_id or not slug: | |
| 275 | + return None | |
| 276 | + | |
| 277 | + make = _s(d.get("make")) | |
| 278 | + model = _s(d.get("model")) | |
| 279 | + body = _s(d.get("body_type")) | |
| 280 | + if _EXCLUDE_RE.search(f"{make} {model} {body}"): # non-automobile | |
| 281 | + return None | |
| 282 | + | |
| 283 | + year = d.get("year") | |
| 284 | + trim = _s(d.get("trim")) | |
| 285 | + title = " ".join(x for x in (make, model, trim, _s(year)) if x) | |
| 286 | + | |
| 287 | + price = d.get("price") | |
| 288 | + try: | |
| 289 | + price = float(price) if price else None | |
| 290 | + except (TypeError, ValueError): | |
| 291 | + price = None | |
| 292 | + | |
| 293 | + km = d.get("mileage_from_odometer") | |
| 294 | + try: | |
| 295 | + km = float(km) if km not in (None, "") else None | |
| 296 | + except (TypeError, ValueError): | |
| 297 | + km = None | |
| 298 | + | |
| 299 | + images = d.get("images") | |
| 300 | + if isinstance(images, str) and images.startswith("$"): | |
| 301 | + images = self._resolve_ref(blob, images) | |
| 302 | + if isinstance(images, str) and images.lstrip().startswith("["): | |
| 303 | + try: # parfois une liste JSON sérialisée | |
| 304 | + images = json.loads(images) | |
| 305 | + except ValueError: | |
| 306 | + images = [] | |
| 307 | + if isinstance(images, str): | |
| 308 | + images = [images] | |
| 309 | + images = [u for u in (images or []) if isinstance(u, str)][:20] | |
| 310 | + | |
| 311 | + doors = d.get("doors") | |
| 312 | + seats = d.get("seating_capacity") | |
| 313 | + | |
| 314 | + details: dict = {} | |
| 315 | + for src, dst in (("is_certified", "certified"), | |
| 316 | + ("is_accidented", "accidented"), | |
| 317 | + ("is_unique_owner", "unique_owner")): | |
| 318 | + if d.get(src) is not None: | |
| 319 | + details[dst] = bool(d[src]) | |
| 320 | + if _s(d.get("fuel_consumption_city")): | |
| 321 | + details["consumption_city_l_100km"] = _s(d["fuel_consumption_city"]) | |
| 322 | + if _s(d.get("fuel_consumption_highway")): | |
| 323 | + details["consumption_hwy_l_100km"] = _s(d["fuel_consumption_highway"]) | |
| 324 | + promo = _s(d.get("promotional_price")) | |
| 325 | + if promo and promo not in ("0", "0.0"): | |
| 326 | + details["promotional_price"] = promo | |
| 327 | + if _s(d.get("dealer_id")): | |
| 328 | + details["branch_slug"] = _s(d["dealer_id"]) | |
| 329 | + | |
| 330 | + return Vehicle( | |
| 331 | + source=self.source_id, | |
| 332 | + external_id=ext_id, | |
| 333 | + url=f"{self.base_url}/auto-usage/{slug}/", | |
| 334 | + title=title, | |
| 335 | + make=make, | |
| 336 | + model=model, | |
| 337 | + trim=trim, | |
| 338 | + year=int(year) if year else None, | |
| 339 | + price=price, | |
| 340 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 341 | + mileage_km=km, | |
| 342 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 343 | + transmission=_s(d.get("transmission")), | |
| 344 | + fuel=_s(d.get("fuel_type")), | |
| 345 | + drivetrain=_s(d.get("drive_wheel_configuration")), | |
| 346 | + body_type=body, | |
| 347 | + exterior_color=_s(d.get("exterior_color")), | |
| 348 | + interior_color=_s(d.get("interior_color")), | |
| 349 | + engine=_s(d.get("engine_type")), | |
| 350 | + doors=int(doors) if doors else None, | |
| 351 | + seats=int(seats) if seats else None, | |
| 352 | + vin=_s(d.get("vin")), | |
| 353 | + stock_number=_s(d.get("stock_number")), | |
| 354 | + dealer_name=_s(d.get("display_dealer_name")) or | |
| 355 | + _s(d.get("dealer_name")) or self.dealer_name, | |
| 356 | + city=_s(d.get("display_city")) or _s(d.get("city")), | |
| 357 | + description=_s(d.get("short_description"))[:4000], | |
| 358 | + details=details, | |
| 359 | + images=images, | |
| 360 | + carfax_url=_s(d.get("carfax_url")), | |
| 361 | + ) | |
added
apps/auto/autoka/connectors/occasioncharlevoix.py
+199 −0
@@ -0,0 +1,199 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/occasioncharlevoix.py : connecteur Occasion Charlevoix | |
| 5 | +# (occasioncharlevoix.com), regroupement de concessionnaires d'occasion de | |
| 6 | +# Baie-Saint-Paul (Charlevoix, Capitale-Nationale) — ~30 véhicules. | |
| 7 | +# | |
| 8 | +# Plateforme : OctoberCMS, thème « nerd2 » + plugins nerd/inventory | |
| 9 | +# (plateforme québécoise NerdAuto). La page liste /fr/inventaire/occasion | |
| 10 | +# est ENTIÈREMENT rendue côté serveur : chaque carte (div.vrp4-card) | |
| 11 | +# contient marque, modèle, version, année, no de stock, kilométrage, photos | |
| 12 | +# (carrousel) et un bloc « Autoverify » (div.av-srp-ecomm) avec VIN et prix | |
| 13 | +# réel (data-av-price — le prix affiché en gros est un paiement périodique). | |
| 14 | +# | |
| 15 | +# La page détail complète (cache BD, clé statique v1) : transmission, | |
| 16 | +# cylindrée, motricité, couleurs, catégorie, lien Carfax. | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import os | |
| 21 | +import re | |
| 22 | + | |
| 23 | +from ..schema import Vehicle, parse_mileage | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +# garde-fou non-automobile | |
| 27 | +_EXCLUDE_RE = re.compile( | |
| 28 | + r"motoneige|vtt|moto\b|motomarine|bateau|roulotte|remorque|spyder", re.I) | |
| 29 | + | |
| 30 | +_SPEC_RE = re.compile( | |
| 31 | + r'class="specs-name[^"]*">\s*([^<]+?)\s*</div>\s*' | |
| 32 | + r'<div class="specs-info[^"]*">\s*([^<]*?)\s*<', re.S) | |
| 33 | + | |
| 34 | +_CARFAX_RE = re.compile(r'data-lien-carfax="([^"]+)"') | |
| 35 | + | |
| 36 | +_AV_DETAIL_RE = re.compile( | |
| 37 | + r'id="av_vehicle_information"[^>]*data-av-mileage="([^"]*)"') | |
| 38 | + | |
| 39 | + | |
| 40 | +def _txt(node) -> str: | |
| 41 | + return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if node else "" | |
| 42 | + | |
| 43 | + | |
| 44 | +class OccasionCharlevoix(BaseConnector): | |
| 45 | + """Occasion Charlevoix — cartes liste rendues serveur (OctoberCMS nerd).""" | |
| 46 | + | |
| 47 | + source_id = "occasioncharlevoix" | |
| 48 | + base_url = "https://www.occasioncharlevoix.com" | |
| 49 | + dealer_name = "Occasion Charlevoix" | |
| 50 | + city = "Baie-Saint-Paul" | |
| 51 | + request_delay = 1.0 | |
| 52 | + max_details = 200 | |
| 53 | + | |
| 54 | + def fetch(self) -> list[Vehicle]: | |
| 55 | + from bs4 import BeautifulSoup | |
| 56 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 57 | + html = self.get(f"{self.base_url}/fr/inventaire/occasion").text | |
| 58 | + soup = BeautifulSoup(html, "html.parser") | |
| 59 | + | |
| 60 | + vehicles: list[Vehicle] = [] | |
| 61 | + seen: set[str] = set() | |
| 62 | + real_fetches = 0 | |
| 63 | + for card in soup.select("div.vrp4-card"): | |
| 64 | + try: | |
| 65 | + veh = self._card_to_vehicle(card) | |
| 66 | + except Exception: | |
| 67 | + continue # carte malformée isolée | |
| 68 | + if veh is None or veh.external_id in seen: | |
| 69 | + continue | |
| 70 | + seen.add(veh.external_id) | |
| 71 | + | |
| 72 | + # enrichissement page détail (transmission, couleurs, carfax…) | |
| 73 | + if real_fetches < cap: | |
| 74 | + url = veh.url | |
| 75 | + | |
| 76 | + def _fetch(u=url): | |
| 77 | + return self._fetch_detail(u) | |
| 78 | + | |
| 79 | + try: | |
| 80 | + extra = self.detail(veh.external_id, "v1", _fetch) | |
| 81 | + real_fetches += 1 | |
| 82 | + self._apply_detail(veh, extra) | |
| 83 | + except Exception: | |
| 84 | + real_fetches += 1 # ne jamais bloquer la source | |
| 85 | + vehicles.append(veh) | |
| 86 | + return vehicles | |
| 87 | + | |
| 88 | + # -- carte liste -> Vehicle ------------------------------------------------- | |
| 89 | + def _card_to_vehicle(self, card) -> Vehicle | None: | |
| 90 | + link = card.select_one("a.vrp4-card-header[href]") | |
| 91 | + if link is None: | |
| 92 | + return None | |
| 93 | + url = link["href"] | |
| 94 | + if "/inventaire/vehicule/" not in url: | |
| 95 | + return None | |
| 96 | + if url.startswith("/"): | |
| 97 | + url = self.base_url + url | |
| 98 | + | |
| 99 | + # /fr/inventaire/vehicule/occasion/<stock>/<marque>/<modèle>/… | |
| 100 | + m = re.search(r"/vehicule/[^/]+/([^/]+)/", url) | |
| 101 | + ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1] | |
| 102 | + | |
| 103 | + make = _txt(card.select_one(".car-make")) | |
| 104 | + model = _txt(card.select_one(".car-model")) | |
| 105 | + trim = _txt(card.select_one(".car-trim")) | |
| 106 | + if _EXCLUDE_RE.search(f"{make} {model} {trim}"): | |
| 107 | + return None | |
| 108 | + | |
| 109 | + year_txt = _txt(card.select_one(".car-year")) | |
| 110 | + year = int(year_txt) if year_txt.isdigit() else None | |
| 111 | + | |
| 112 | + stock = _txt(card.select_one(".car-stock")).lstrip("#") | |
| 113 | + odo_label = _txt(card.select_one(".car-odometer")) | |
| 114 | + km = parse_mileage(odo_label) | |
| 115 | + | |
| 116 | + # bloc Autoverify : VIN + prix réel (le prix visible est un paiement) | |
| 117 | + vin, price = "", None | |
| 118 | + av = card.select_one(".av-srp-ecomm") | |
| 119 | + if av is not None: | |
| 120 | + vin = (av.get("data-av-vin") or "").strip().upper() | |
| 121 | + try: | |
| 122 | + price = float(av.get("data-av-price") or 0) or None | |
| 123 | + except ValueError: | |
| 124 | + price = None | |
| 125 | + # garde-fou : < 1 500 $ = paiement périodique saisi comme prix | |
| 126 | + if price is not None and price < 1500: | |
| 127 | + price = None | |
| 128 | + | |
| 129 | + images = [] | |
| 130 | + for img in card.select("img.car-img[src]"): | |
| 131 | + src = img["src"] | |
| 132 | + if src.startswith("/"): | |
| 133 | + src = self.base_url + src | |
| 134 | + if src not in images: | |
| 135 | + images.append(src) | |
| 136 | + | |
| 137 | + title = " ".join(x for x in (make, model, trim, year_txt) if x) | |
| 138 | + if not title: | |
| 139 | + return None | |
| 140 | + | |
| 141 | + return Vehicle( | |
| 142 | + source=self.source_id, | |
| 143 | + external_id=ext_id, | |
| 144 | + url=url, | |
| 145 | + title=title, | |
| 146 | + make=make, | |
| 147 | + model=model, | |
| 148 | + trim=trim, | |
| 149 | + year=year, | |
| 150 | + price=price, | |
| 151 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 152 | + mileage_km=km, | |
| 153 | + mileage_label=odo_label, | |
| 154 | + vin=vin, | |
| 155 | + stock_number=stock or ext_id.upper(), | |
| 156 | + dealer_name=self.dealer_name, | |
| 157 | + city=self.city, | |
| 158 | + images=images[:20], | |
| 159 | + ) | |
| 160 | + | |
| 161 | + # -- page détail ------------------------------------------------------------- | |
| 162 | + def _fetch_detail(self, url: str) -> dict: | |
| 163 | + html = self.get(url).text | |
| 164 | + payload: dict = {"specs": {}} | |
| 165 | + for name, value in _SPEC_RE.findall(html): | |
| 166 | + payload["specs"][name.rstrip(": ").strip()] = value.strip() | |
| 167 | + m = _CARFAX_RE.search(html) | |
| 168 | + if m: | |
| 169 | + payload["carfax"] = m.group(1) | |
| 170 | + m = _AV_DETAIL_RE.search(html) | |
| 171 | + if m and m.group(1): | |
| 172 | + payload["mileage"] = m.group(1) | |
| 173 | + return payload | |
| 174 | + | |
| 175 | + def _apply_detail(self, veh: Vehicle, extra: dict) -> None: | |
| 176 | + specs = (extra or {}).get("specs") or {} | |
| 177 | + for name, value in specs.items(): | |
| 178 | + low = name.lower() | |
| 179 | + if not value or value.lower() in ("autre", "n/d", "s/o"): | |
| 180 | + continue | |
| 181 | + if "transmission" in low and not veh.transmission: | |
| 182 | + veh.transmission = value | |
| 183 | + elif "motricit" in low and not veh.drivetrain: | |
| 184 | + veh.drivetrain = value | |
| 185 | + elif "cat" in low and not veh.body_type: | |
| 186 | + veh.body_type = value | |
| 187 | + elif "cylindr" in low and not veh.engine: | |
| 188 | + veh.engine = value | |
| 189 | + elif "ext" in low and "couleur" in low and not veh.exterior_color: | |
| 190 | + veh.exterior_color = value | |
| 191 | + elif "int" in low and "couleur" in low and not veh.interior_color: | |
| 192 | + veh.interior_color = value | |
| 193 | + if extra.get("carfax") and not veh.carfax_url: | |
| 194 | + veh.carfax_url = extra["carfax"] | |
| 195 | + if veh.mileage_km is None and extra.get("mileage"): | |
| 196 | + try: | |
| 197 | + veh.mileage_km = float(extra["mileage"]) | |
| 198 | + except ValueError: | |
| 199 | + pass | |
added
apps/auto/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
apps/auto/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
apps/auto/autoka/connectors/sm360_dealers.py
+567 −0
@@ -0,0 +1,567 @@ | ||
| 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 | +# En complément, l'API GraphQL publique que le site utilise lui-même | |
| 22 | +# (webauto-supplier-api.sm360.ca — celle des widgets VDP) donne pour chaque | |
| 23 | +# id : la liste d'équipements (options.labels), la galerie complète | |
| 24 | +# (multimedia.pictures — indispensable pour les gabarits dont la galerie | |
| 25 | +# n'est PAS server-rendered : touteslesmarques, spinelli, rivesudchrysler, | |
| 26 | +# toyotagatineau) et une description/tagline de repli. Un POST par véhicule, | |
| 27 | +# mis en cache avec le même payload hebdomadaire. | |
| 28 | +# | |
| 29 | +# Le listing lui-même est un widget JS (GraphQL webauto-supplier-api) — | |
| 30 | +# inutile ici, le sitemap suffit. robots.txt : Allow /*?page=* et /*/api/*, | |
| 31 | +# Crawl-delay 10 → délai généreux + cache détail. | |
| 32 | +# ----------------------------------------------------------------------------- | |
| 33 | +from __future__ import annotations | |
| 34 | + | |
| 35 | +import datetime | |
| 36 | +import html as htmllib | |
| 37 | +import json | |
| 38 | +import os | |
| 39 | +import re | |
| 40 | +import time | |
| 41 | + | |
| 42 | +import requests | |
| 43 | + | |
| 44 | +from ..schema import Vehicle | |
| 45 | +from .base import BaseConnector | |
| 46 | + | |
| 47 | +# URLs de véhicules d'occasion dans le sitemap (jamais catalogue-neuf / | |
| 48 | +# inventaire-neuf, donc jamais de véhicules neufs) | |
| 49 | +_VEH_URL_RE = re.compile(r"/fr/inventaire-occasion/.+-id(\d+)/?$") | |
| 50 | + | |
| 51 | +# segments marque/modèle qui ne sont PAS des automobiles (récréatif) | |
| 52 | +_EXCLUDE_SEGMENTS = re.compile( | |
| 53 | + r"ski-?doo|sea-?doo|can-?am|polaris|arctic-?cat|lynx|brp|harley|" | |
| 54 | + r"kawasaki|suzuki-moto|ktm|husqvarna|triumph|ducati|vespa|" | |
| 55 | + r"motoneige|vtt|moto(?:s)?\b|motomarine|bateau|ponton|roulotte|" | |
| 56 | + r"remorque|\bvr\b|campeur|spyder|ryker|side-?by-?side|atv|snow|" | |
| 57 | + r"powersport|nautique", re.I) | |
| 58 | + | |
| 59 | +# catégories dataLayer non automobiles (par prudence — normalement le | |
| 60 | +# sitemap /fr/inventaire-occasion ne contient que des autos) | |
| 61 | +_EXCLUDE_CATEGORIES = { | |
| 62 | + "motorcycle", "atv", "snowmobile", "watercraft", "boat", "rv", | |
| 63 | + "trailer", "sxs", "utv", "scooter", "powersport", | |
| 64 | +} | |
| 65 | + | |
| 66 | +_LD_RE = re.compile( | |
| 67 | + r'<script type=["\']application/ld\+json["\']>(.*?)</script>', re.S) | |
| 68 | + | |
| 69 | +# galerie photo : URLs img.sm360.ca, avec ou sans préfixe resizer /ir/wNNN/ | |
| 70 | +_IMG_RE = re.compile( | |
| 71 | + r'https://img\.sm360\.ca/(?:ir/[a-zA-Z0-9]+/)?' | |
| 72 | + r'(images/inventory/[^"\'\s<>\\]+\.(?:jpe?g|png|webp))', re.I) | |
| 73 | + | |
| 74 | +_CARFAX_RE = re.compile(r'https?://(?:vhr\.)?carfax\.ca/[^\s"\'<>]+', re.I) | |
| 75 | + | |
| 76 | +# API GraphQL publique des widgets SM360 (celle que la page détail utilise | |
| 77 | +# elle-même pour afficher équipements et galerie) — id = id inventaire global | |
| 78 | +_GRAPHQL_API = "https://webauto-supplier-api.sm360.ca/webauto/graphql" | |
| 79 | +_GQL_VEHICLE = ("{ vehicle(id: %d) { description tagline " | |
| 80 | + "options { labels } multimedia { pictures { url } } } }") | |
| 81 | + | |
| 82 | +# clés extraites du dataLayer JS de la page détail (valeurs 'entre quotes') | |
| 83 | +_DL_KEYS = ( | |
| 84 | + "salePrice", "regularPrice", "mileage", "stockNo", "vehicleVin", | |
| 85 | + "isNew", "isDemo", "vehicleCategory", "condition", "vehicleLocation", | |
| 86 | + "vehicleFuelType", "transmission", "trim", "make", "model", "year", | |
| 87 | +) | |
| 88 | + | |
| 89 | +_DRIVE_MAP = { | |
| 90 | + "FrontWheelDriveConfiguration": "Traction", | |
| 91 | + "RearWheelDriveConfiguration": "Propulsion", | |
| 92 | + "AllWheelDriveConfiguration": "Intégrale / 4x4", | |
| 93 | + "FourWheelDriveConfiguration": "Intégrale / 4x4", | |
| 94 | +} | |
| 95 | + | |
| 96 | + | |
| 97 | +def _clean(text: str | None) -> str: | |
| 98 | + """Désencode les entités HTML et retire balises / « s/o » SM360.""" | |
| 99 | + if not text: | |
| 100 | + return "" | |
| 101 | + out = htmllib.unescape(htmllib.unescape(str(text))) | |
| 102 | + out = re.sub(r"<[^>]+>", " ", out) | |
| 103 | + out = re.sub(r"\s{2,}", " ", out).strip() | |
| 104 | + return "" if out.lower() in ("s/o", "n/d", "n/a") else out | |
| 105 | + | |
| 106 | + | |
| 107 | +def _num(value) -> float | None: | |
| 108 | + """float() tolérant ; 0 et négatif → None (SM360 met 0 = prix caché).""" | |
| 109 | + try: | |
| 110 | + f = float(value) | |
| 111 | + except (TypeError, ValueError): | |
| 112 | + return None | |
| 113 | + return f if f > 0 else None | |
| 114 | + | |
| 115 | + | |
| 116 | +class SM360Connector(BaseConnector): | |
| 117 | + """Base commune aux sites SM360 / 360.agency. Sous-classes : base_url…""" | |
| 118 | + | |
| 119 | + base_url: str = "" | |
| 120 | + dealer_name: str = "" | |
| 121 | + city: str = "" | |
| 122 | + request_delay: float = 1.5 # robots.txt SM360 demande de la patience | |
| 123 | + use_firecrawl: bool = False # au cas où un site passe sous Cloudflare | |
| 124 | + | |
| 125 | + # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit | |
| 126 | + # pour les tests) — le reste sera complété aux syncs suivants | |
| 127 | + max_details = 600 | |
| 128 | + | |
| 129 | + def _get_text(self, url: str) -> str: | |
| 130 | + if self.use_firecrawl: | |
| 131 | + return self.get_rendered(url) | |
| 132 | + try: | |
| 133 | + return self.get(url).text | |
| 134 | + except requests.exceptions.ConnectionError: | |
| 135 | + # le serveur SM360 ferme parfois la connexion keep-alive : on | |
| 136 | + # repart sur une connexion neuve et on réessaie une fois | |
| 137 | + self.session.close() | |
| 138 | + time.sleep(2.0) | |
| 139 | + return self.get(url).text | |
| 140 | + | |
| 141 | + # -- sitemap --------------------------------------------------------------- | |
| 142 | + def _vehicle_urls(self) -> list[str]: | |
| 143 | + xml = self._get_text(f"{self.base_url}/fr/sitemap-xml") | |
| 144 | + urls: list[str] = [] | |
| 145 | + for loc in re.findall(r"<loc>([^<]+)</loc>", xml): | |
| 146 | + loc = loc.strip() | |
| 147 | + if not _VEH_URL_RE.search(loc): | |
| 148 | + continue | |
| 149 | + # segments /fr/inventaire-occasion/<marque>/<modèle>/<slug> | |
| 150 | + path = loc.split("/fr/inventaire-occasion/", 1)[1] | |
| 151 | + segments = path.split("/")[:-1] | |
| 152 | + if any(_EXCLUDE_SEGMENTS.search(s) for s in segments): | |
| 153 | + continue | |
| 154 | + urls.append(loc) | |
| 155 | + return urls | |
| 156 | + | |
| 157 | + # -- page détail -> payload (JSON-LD + dataLayer + galerie + carfax) -------- | |
| 158 | + def _fetch_detail(self, url: str, ext_id: str) -> dict: | |
| 159 | + html = self._get_text(url) | |
| 160 | + payload: dict = {"ld": {}, "dl": {}, "images": [], "carfax": ""} | |
| 161 | + | |
| 162 | + for block in _LD_RE.findall(html): | |
| 163 | + try: | |
| 164 | + data = json.loads(block.strip(), strict=False) | |
| 165 | + except ValueError: | |
| 166 | + continue | |
| 167 | + items = data if isinstance(data, list) else [data] | |
| 168 | + for item in items: | |
| 169 | + if not isinstance(item, dict): | |
| 170 | + continue | |
| 171 | + types = item.get("@type") | |
| 172 | + types = types if isinstance(types, list) else [types] | |
| 173 | + if "Car" in types or "Vehicle" in types: | |
| 174 | + payload["ld"] = item | |
| 175 | + break | |
| 176 | + if payload["ld"]: | |
| 177 | + break | |
| 178 | + | |
| 179 | + for key in _DL_KEYS: | |
| 180 | + m = re.search(r"\b" + key + r"\s*:\s*'((?:[^'\\]|\\.)*)'", html) | |
| 181 | + if m: | |
| 182 | + payload["dl"][key] = m.group(1).replace("\\'", "'").strip() | |
| 183 | + | |
| 184 | + # galerie : uniquement les photos de CE véhicule, sans préfixe resizer, | |
| 185 | + # triées par index _NNN de fin de nom de fichier | |
| 186 | + seen: set[str] = set() | |
| 187 | + images: list[str] = [] | |
| 188 | + for path in _IMG_RE.findall(html): | |
| 189 | + if f"/{ext_id}/" not in path: | |
| 190 | + continue | |
| 191 | + full = "https://img.sm360.ca/" + path | |
| 192 | + if full not in seen: | |
| 193 | + seen.add(full) | |
| 194 | + images.append(full) | |
| 195 | + | |
| 196 | + def _idx(u: str) -> int: | |
| 197 | + m = re.search(r"_(\d+)\.\w+$", u) | |
| 198 | + return int(m.group(1)) if m else 999 | |
| 199 | + | |
| 200 | + payload["images"] = sorted(images, key=_idx)[:20] | |
| 201 | + | |
| 202 | + m = _CARFAX_RE.search(html) | |
| 203 | + if m: | |
| 204 | + payload["carfax"] = htmllib.unescape(m.group(0)) | |
| 205 | + | |
| 206 | + payload["gql"] = self._fetch_gql(ext_id) | |
| 207 | + return payload | |
| 208 | + | |
| 209 | + # -- API GraphQL : équipements + galerie + description de repli ------------- | |
| 210 | + def _fetch_gql(self, ext_id: str) -> dict: | |
| 211 | + """options.labels, multimedia.pictures et description/tagline du | |
| 212 | + véhicule via l'API publique des widgets. Tolérant : {} en cas d'échec | |
| 213 | + (le payload HTML reste utilisable seul).""" | |
| 214 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 215 | + if wait > 0: | |
| 216 | + time.sleep(wait) | |
| 217 | + try: | |
| 218 | + resp = self.session.post( | |
| 219 | + _GRAPHQL_API, | |
| 220 | + json={"query": _GQL_VEHICLE % int(ext_id)}, | |
| 221 | + timeout=self.timeout) | |
| 222 | + self._last_request = time.time() | |
| 223 | + resp.raise_for_status() | |
| 224 | + vehicle = (resp.json().get("data") or {}).get("vehicle") or {} | |
| 225 | + except Exception: | |
| 226 | + self._last_request = time.time() | |
| 227 | + return {} | |
| 228 | + if not isinstance(vehicle, dict): | |
| 229 | + return {} | |
| 230 | + labels = (vehicle.get("options") or {}).get("labels") or [] | |
| 231 | + options = [_clean(lb) for lb in labels if _clean(lb)] | |
| 232 | + | |
| 233 | + images: list[str] = [] | |
| 234 | + pictures = (vehicle.get("multimedia") or {}).get("pictures") or [] | |
| 235 | + for pic in pictures: | |
| 236 | + u = str((pic or {}).get("url") or "").strip() | |
| 237 | + if not u: | |
| 238 | + continue | |
| 239 | + if not u.startswith("http"): # chemin relatif au serveur d'images | |
| 240 | + u = "https://img.sm360.ca/images/inventory" + u | |
| 241 | + images.append(u) | |
| 242 | + | |
| 243 | + return { | |
| 244 | + "options": options[:60], | |
| 245 | + "images": images[:20], | |
| 246 | + "description": _clean(vehicle.get("description")), | |
| 247 | + "tagline": _clean(vehicle.get("tagline")), | |
| 248 | + } | |
| 249 | + | |
| 250 | + # -- multi-succursales (hook, surchargé par TLM) ----------------------------- | |
| 251 | + def _dealer_city(self, dl: dict, ld: dict) -> tuple[str, str]: | |
| 252 | + return self.dealer_name, self.city | |
| 253 | + | |
| 254 | + # -- contrat --------------------------------------------------------------- | |
| 255 | + def fetch(self) -> list[Vehicle]: | |
| 256 | + urls = self._vehicle_urls() | |
| 257 | + week = datetime.date.today().isocalendar() | |
| 258 | + # v2 : + équipements/galerie GraphQL (bump = re-crawl complet forcé) | |
| 259 | + cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire | |
| 260 | + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) | |
| 261 | + | |
| 262 | + vehicles: list[Vehicle] = [] | |
| 263 | + real_fetches = 0 | |
| 264 | + for url in urls: | |
| 265 | + ext_id = _VEH_URL_RE.search(url).group(1) | |
| 266 | + | |
| 267 | + def _fetch(u=url, e=ext_id): | |
| 268 | + return self._fetch_detail(u, e) | |
| 269 | + | |
| 270 | + # plafond atteint : ne servir que le cache (fetch réel interdit) | |
| 271 | + if real_fetches >= cap: | |
| 272 | + from .. import db | |
| 273 | + if self._detail_con is None: | |
| 274 | + self._detail_con = db.connect() | |
| 275 | + data = db.get_cached_detail(self._detail_con, self.source_id, | |
| 276 | + ext_id, cache_key) | |
| 277 | + if data is None: | |
| 278 | + continue | |
| 279 | + else: | |
| 280 | + before = self._last_request | |
| 281 | + try: | |
| 282 | + data = self.detail(ext_id, cache_key, _fetch) | |
| 283 | + except Exception: | |
| 284 | + # page disparue (vendu) ou erreur ponctuelle : ne jamais | |
| 285 | + # bloquer la source entière | |
| 286 | + if self._last_request != before: | |
| 287 | + real_fetches += 1 | |
| 288 | + continue | |
| 289 | + if self._last_request != before: # une vraie requête a eu lieu | |
| 290 | + real_fetches += 1 | |
| 291 | + if not data: | |
| 292 | + continue | |
| 293 | + veh = self._to_vehicle(ext_id, url, data) | |
| 294 | + if veh is not None: | |
| 295 | + vehicles.append(veh) | |
| 296 | + return vehicles | |
| 297 | + | |
| 298 | + def _to_vehicle(self, ext_id: str, url: str, data: dict) -> Vehicle | None: | |
| 299 | + ld: dict = data.get("ld") or {} | |
| 300 | + dl: dict = data.get("dl") or {} | |
| 301 | + if not ld and not dl: | |
| 302 | + return None | |
| 303 | + | |
| 304 | + # filtres de sécurité : jamais de neuf, jamais de récréatif | |
| 305 | + if dl.get("isNew", "").lower() == "true": | |
| 306 | + return None | |
| 307 | + if dl.get("vehicleCategory", "").lower() in _EXCLUDE_CATEGORIES: | |
| 308 | + return None | |
| 309 | + | |
| 310 | + brand = ld.get("brand") or {} | |
| 311 | + make = _clean(brand.get("name") if isinstance(brand, dict) else brand) | |
| 312 | + model = _clean(ld.get("model")) or _clean(dl.get("model")).title() | |
| 313 | + | |
| 314 | + year = None | |
| 315 | + for raw in (ld.get("vehicleModelDate"), ld.get("modelDate"), | |
| 316 | + dl.get("year")): | |
| 317 | + if raw: | |
| 318 | + try: | |
| 319 | + year = int(str(raw)[:4]) | |
| 320 | + break | |
| 321 | + except ValueError: | |
| 322 | + pass | |
| 323 | + | |
| 324 | + offers = ld.get("offers") or {} | |
| 325 | + price = _num(offers.get("price")) or _num(dl.get("salePrice")) \ | |
| 326 | + or _num(dl.get("regularPrice")) | |
| 327 | + | |
| 328 | + odo = ld.get("mileageFromOdometer") or {} | |
| 329 | + km = _num(odo.get("value") if isinstance(odo, dict) else odo) \ | |
| 330 | + or _num(dl.get("mileage")) | |
| 331 | + | |
| 332 | + # titre : le "name" JSON-LD varie (parfois "CR-V", parfois | |
| 333 | + # "Chevrolet Colorado 4WD Trail Boss 2025") — on complète au besoin | |
| 334 | + title = _clean(ld.get("name")) or f"{make} {model}".strip() | |
| 335 | + if make and make.lower() not in title.lower(): | |
| 336 | + title = f"{make} {title}" | |
| 337 | + if year and str(year) not in title: | |
| 338 | + title = f"{title} {year}" | |
| 339 | + | |
| 340 | + drive_raw = str(ld.get("driveWheelConfiguration") or "") | |
| 341 | + drivetrain = "" | |
| 342 | + for key, canon in _DRIVE_MAP.items(): | |
| 343 | + if key in drive_raw: | |
| 344 | + drivetrain = canon | |
| 345 | + break | |
| 346 | + | |
| 347 | + engines = ld.get("vehicleEngine") or [] | |
| 348 | + if isinstance(engines, dict): | |
| 349 | + engines = [engines] | |
| 350 | + fuel, engine_txt = "", "" | |
| 351 | + if engines and isinstance(engines[0], dict): | |
| 352 | + fuel = _clean(engines[0].get("fuelType")) | |
| 353 | + e_type = _clean(engines[0].get("engineType")) | |
| 354 | + if len(e_type) > 3: # "4" = nb cylindres, pas descriptif | |
| 355 | + engine_txt = e_type | |
| 356 | + fuel = fuel or _clean(dl.get("vehicleFuelType")) | |
| 357 | + | |
| 358 | + transmission = _clean(ld.get("vehicleTransmission")) \ | |
| 359 | + or _clean(dl.get("transmission")) | |
| 360 | + | |
| 361 | + trim = _clean(ld.get("vehicleConfiguration")) | |
| 362 | + if not trim: | |
| 363 | + trim = _clean(dl.get("trim")).upper() | |
| 364 | + | |
| 365 | + gql: dict = data.get("gql") or {} | |
| 366 | + | |
| 367 | + images = list(data.get("images") or []) | |
| 368 | + # la galerie GraphQL est complète même quand le gabarit ne la rend pas | |
| 369 | + # côté serveur (touteslesmarques, spinelli, rive-sud, toyotagatineau) | |
| 370 | + gql_images = list(gql.get("images") or []) | |
| 371 | + if len(gql_images) > len(images): | |
| 372 | + images = gql_images | |
| 373 | + if not images: | |
| 374 | + ld_img = ld.get("image") or [] | |
| 375 | + images = [ld_img] if isinstance(ld_img, str) else list(ld_img) | |
| 376 | + # écarter les stubs sans nom de fichier (véhicule sans photo) | |
| 377 | + images = [u for u in images if isinstance(u, str) | |
| 378 | + and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I)] | |
| 379 | + | |
| 380 | + def _int(v): | |
| 381 | + try: | |
| 382 | + return int(str(v)) | |
| 383 | + except (TypeError, ValueError): | |
| 384 | + return None | |
| 385 | + | |
| 386 | + dealer_name, city = self._dealer_city(dl, ld) | |
| 387 | + | |
| 388 | + details: dict = {} | |
| 389 | + if dl.get("isDemo", "").lower() == "true": | |
| 390 | + details["demo"] = True | |
| 391 | + if dl.get("condition"): | |
| 392 | + details["condition"] = dl["condition"] | |
| 393 | + | |
| 394 | + return Vehicle( | |
| 395 | + source=self.source_id, | |
| 396 | + external_id=ext_id, | |
| 397 | + url=url, | |
| 398 | + title=title, | |
| 399 | + make=make, | |
| 400 | + model=model, | |
| 401 | + trim=trim, | |
| 402 | + year=year, | |
| 403 | + price=price, | |
| 404 | + price_label=f"{price:,.0f} $".replace(",", " ") if price else "", | |
| 405 | + mileage_km=km, | |
| 406 | + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", | |
| 407 | + transmission=transmission, | |
| 408 | + fuel=fuel, | |
| 409 | + drivetrain=drivetrain, | |
| 410 | + body_type=_clean(ld.get("bodyType")), | |
| 411 | + exterior_color=_clean(ld.get("color")), | |
| 412 | + interior_color=_clean(ld.get("vehicleInteriorColor")), | |
| 413 | + engine=engine_txt, | |
| 414 | + doors=_int(ld.get("numberOfDoors")), | |
| 415 | + seats=_int(ld.get("vehicleSeatingCapacity") | |
| 416 | + or ld.get("seatingCapacity")), | |
| 417 | + vin=(_clean(ld.get("vehicleIdentificationNumber")) | |
| 418 | + or _clean(dl.get("vehicleVin"))).upper(), | |
| 419 | + stock_number=_clean(dl.get("stockNo")) or _clean(ld.get("sku")), | |
| 420 | + dealer_name=dealer_name, | |
| 421 | + city=city, | |
| 422 | + description=(_clean(ld.get("description")) | |
| 423 | + or gql.get("description", "") | |
| 424 | + or gql.get("tagline", ""))[:4000], | |
| 425 | + features=list(gql.get("options") or []), | |
| 426 | + details=details, | |
| 427 | + images=images, | |
| 428 | + carfax_url=data.get("carfax") or "", | |
| 429 | + ) | |
| 430 | + | |
| 431 | + | |
| 432 | +# --------------------------------------------------------------------------- | |
| 433 | +# Concessionnaires (1 sous-classe = 1 source) | |
| 434 | +# --------------------------------------------------------------------------- | |
| 435 | + | |
| 436 | +class ToutesLesMarques(SM360Connector): | |
| 437 | + """Groupe multi-succursales (Chicoutimi, Baie-Comeau, Sept-Îles). | |
| 438 | + | |
| 439 | + Le dataLayer `vehicleLocation` donne la succursale qui détient le | |
| 440 | + véhicule ; la ville est déduite du nom de la succursale. | |
| 441 | + """ | |
| 442 | + source_id = "touteslesmarques" | |
| 443 | + base_url = "https://www.touteslesmarques.ca" | |
| 444 | + dealer_name = "Toutes les marques" | |
| 445 | + city = "Chicoutimi" | |
| 446 | + | |
| 447 | + def _dealer_city(self, dl: dict, ld: dict) -> tuple[str, str]: | |
| 448 | + branch = _clean(dl.get("vehicleLocation")) | |
| 449 | + if not branch or branch.lower() == "touteslesmarques.ca": | |
| 450 | + return self.dealer_name, self.city | |
| 451 | + low = branch.lower() | |
| 452 | + if "baie" in low and "comeau" in low: | |
| 453 | + city = "Baie-Comeau" | |
| 454 | + elif "sept" in low and ("iles" in low or "îles" in low): | |
| 455 | + city = "Sept-Îles" | |
| 456 | + else: # Arnold, L'Ami Junior, Intégral Subaru… | |
| 457 | + city = "Chicoutimi" | |
| 458 | + return branch, city | |
| 459 | + | |
| 460 | + | |
| 461 | +class StJeromeAutoDepot(SM360Connector): | |
| 462 | + source_id = "stjeromeautodepot" | |
| 463 | + base_url = "https://www.stjeromeautodepot.com" | |
| 464 | + dealer_name = "St-Jérôme Auto Dépôt" | |
| 465 | + city = "Saint-Jérôme" | |
| 466 | + | |
| 467 | + | |
| 468 | +class LamontagneAuto(SM360Connector): | |
| 469 | + # concessionnaire Honda — le sitemap /fr/inventaire-occasion ne contient | |
| 470 | + # que l'occasion (le neuf vit sous /fr/catalogue-neuf, jamais ramassé) | |
| 471 | + source_id = "lamontagneauto" | |
| 472 | + base_url = "https://www.lamontagneauto.com" | |
| 473 | + dealer_name = "Lamontagne Auto" | |
| 474 | + city = "Rimouski" | |
| 475 | + | |
| 476 | + | |
| 477 | +# --------------------------------------------------------------------------- | |
| 478 | +# Expansion 2 — concessionnaires SM360 supplémentaires | |
| 479 | +# --------------------------------------------------------------------------- | |
| 480 | + | |
| 481 | +class Spinelli(SM360Connector): | |
| 482 | + """Groupe multi-concessions de l'île de Montréal (Pointe-Claire, Lachine…).""" | |
| 483 | + source_id = "spinelli" | |
| 484 | + base_url = "https://www.spinelli.com" | |
| 485 | + dealer_name = "Groupe Spinelli" | |
| 486 | + city = "Montréal" | |
| 487 | + | |
| 488 | + | |
| 489 | +class AutoDesjardins(SM360Connector): | |
| 490 | + source_id = "autodesjardins" | |
| 491 | + base_url = "https://www.autodesjardins.com" | |
| 492 | + dealer_name = "Automobiles Desjardins" | |
| 493 | + city = "Québec" | |
| 494 | + | |
| 495 | + | |
| 496 | +class LallierHondaHull(SM360Connector): | |
| 497 | + source_id = "lallierhondahull" | |
| 498 | + base_url = "https://www.lallierhondahull.com" | |
| 499 | + dealer_name = "Lallier Honda Hull" | |
| 500 | + city = "Gatineau" | |
| 501 | + | |
| 502 | + | |
| 503 | +class RiveSudChrysler(SM360Connector): | |
| 504 | + source_id = "rivesudchrysler" | |
| 505 | + base_url = "https://www.rivesudchrysler.com" | |
| 506 | + dealer_name = "Rive-Sud Chrysler" | |
| 507 | + city = "Brossard" | |
| 508 | + | |
| 509 | + | |
| 510 | +class ToyotaGatineau(SM360Connector): | |
| 511 | + source_id = "toyotagatineau" | |
| 512 | + base_url = "https://www.toyotagatineau.ca" | |
| 513 | + dealer_name = "Toyota Gatineau" | |
| 514 | + city = "Gatineau" | |
| 515 | + | |
| 516 | + | |
| 517 | +# --------------------------------------------------------------------------- | |
| 518 | +# Expansion 3 — Côte-Nord, Bas-Saint-Laurent (Matane) et île de Montréal | |
| 519 | +# --------------------------------------------------------------------------- | |
| 520 | + | |
| 521 | +class SeptIlesHonda(SM360Connector): | |
| 522 | + source_id = "septileshonda" | |
| 523 | + base_url = "https://www.septileshonda.com" | |
| 524 | + dealer_name = "Sept-Îles Honda" | |
| 525 | + city = "Sept-Îles" | |
| 526 | + | |
| 527 | + | |
| 528 | +class SeptIlesGM(SM360Connector): | |
| 529 | + source_id = "septilesgm" | |
| 530 | + base_url = "https://www.sept-ilesgm.com" | |
| 531 | + dealer_name = "Sept-Îles Chevrolet Buick GMC" | |
| 532 | + city = "Sept-Îles" | |
| 533 | + | |
| 534 | + | |
| 535 | +class BaieComeauGM(SM360Connector): | |
| 536 | + source_id = "baiecomeaugm" | |
| 537 | + base_url = "https://www.baiecomeaugm.com" | |
| 538 | + dealer_name = "Baie-Comeau Chevrolet Buick GMC" | |
| 539 | + city = "Baie-Comeau" | |
| 540 | + | |
| 541 | + | |
| 542 | +class VolvoCarsSaintLeonard(SM360Connector): | |
| 543 | + source_id = "volvosaintleonard" | |
| 544 | + base_url = "https://www.volvocarssaintleonard.com" | |
| 545 | + dealer_name = "Volvo Cars Saint-Léonard" | |
| 546 | + city = "Montréal" | |
| 547 | + | |
| 548 | + | |
| 549 | +class KiaMatane(SM360Connector): | |
| 550 | + source_id = "kiamatane" | |
| 551 | + base_url = "https://www.kiamatane.com" | |
| 552 | + dealer_name = "Kia Matane" | |
| 553 | + city = "Matane" | |
| 554 | + | |
| 555 | + | |
| 556 | +class MarquisAutomobiles(SM360Connector): | |
| 557 | + source_id = "marquisautomobiles" | |
| 558 | + base_url = "https://www.marquisautomobiles.com" | |
| 559 | + dealer_name = "Marquis Automobiles" | |
| 560 | + city = "Matane" | |
| 561 | + | |
| 562 | + | |
| 563 | +class VilleneuveFord(SM360Connector): | |
| 564 | + source_id = "villeneuveford" | |
| 565 | + base_url = "https://www.villeneuveford.com" | |
| 566 | + dealer_name = "Villeneuve Ford" | |
| 567 | + city = "Matane" | |
added
apps/auto/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
apps/auto/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
apps/auto/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
apps/auto/autoka/db.py
+329 −0
@@ -0,0 +1,329 @@ | ||
| 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 | + kind TEXT DEFAULT 'auto', | |
| 38 | + title TEXT, | |
| 39 | + make TEXT, | |
| 40 | + model TEXT, | |
| 41 | + trim TEXT, | |
| 42 | + year INTEGER, | |
| 43 | + price REAL, | |
| 44 | + price_label TEXT, | |
| 45 | + mileage_km REAL, | |
| 46 | + mileage_label TEXT, | |
| 47 | + transmission TEXT, | |
| 48 | + fuel TEXT, | |
| 49 | + drivetrain TEXT, | |
| 50 | + body_type TEXT, | |
| 51 | + exterior_color TEXT, | |
| 52 | + interior_color TEXT, | |
| 53 | + engine TEXT, | |
| 54 | + doors INTEGER, | |
| 55 | + seats INTEGER, | |
| 56 | + vin TEXT, | |
| 57 | + stock_number TEXT, | |
| 58 | + dealer_name TEXT, | |
| 59 | + city TEXT, | |
| 60 | + region TEXT, | |
| 61 | + description TEXT, | |
| 62 | + features TEXT, -- JSON (liste de textes source) | |
| 63 | + details TEXT, -- JSON (champs structurés) | |
| 64 | + images TEXT, -- JSON | |
| 65 | + carfax_url TEXT, | |
| 66 | + content_hash TEXT, | |
| 67 | + first_seen REAL, | |
| 68 | + last_seen REAL, | |
| 69 | + updated_at REAL, | |
| 70 | + miss_count INTEGER DEFAULT 0, | |
| 71 | + active INTEGER DEFAULT 1 | |
| 72 | +); | |
| 73 | +CREATE INDEX IF NOT EXISTS idx_vehicles_source ON vehicles(source); | |
| 74 | +CREATE INDEX IF NOT EXISTS idx_vehicles_make ON vehicles(make); | |
| 75 | +CREATE INDEX IF NOT EXISTS idx_vehicles_region ON vehicles(region); | |
| 76 | +CREATE INDEX IF NOT EXISTS idx_vehicles_active ON vehicles(active); | |
| 77 | +CREATE INDEX IF NOT EXISTS idx_vehicles_price ON vehicles(price); | |
| 78 | + | |
| 79 | +CREATE TABLE IF NOT EXISTS sync_log ( | |
| 80 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 81 | + source TEXT, | |
| 82 | + ts REAL, | |
| 83 | + found INTEGER, | |
| 84 | + added INTEGER, | |
| 85 | + updated INTEGER, | |
| 86 | + removed INTEGER, | |
| 87 | + ok INTEGER, | |
| 88 | + message TEXT, | |
| 89 | + stats TEXT -- JSON : taux de champs null, missed, alerte… | |
| 90 | +); | |
| 91 | + | |
| 92 | +CREATE TABLE IF NOT EXISTS detail_cache ( | |
| 93 | + source TEXT NOT NULL, | |
| 94 | + external_id TEXT NOT NULL, | |
| 95 | + key TEXT, -- hash du contenu « liste » de l'annonce | |
| 96 | + payload TEXT, -- JSON opaque propre au connecteur | |
| 97 | + fetched_at REAL, | |
| 98 | + PRIMARY KEY (source, external_id) | |
| 99 | +); | |
| 100 | + | |
| 101 | +CREATE TABLE IF NOT EXISTS price_log ( | |
| 102 | + uid TEXT NOT NULL, | |
| 103 | + ts REAL NOT NULL, | |
| 104 | + price REAL -- prix observé (NULL = retiré de l'affichage) | |
| 105 | +); | |
| 106 | +CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid); | |
| 107 | +""" | |
| 108 | + | |
| 109 | + | |
| 110 | +# Colonnes ajoutées après la v1 — migration automatique des bases existantes. | |
| 111 | +_MIGRATIONS = { | |
| 112 | + "vehicles": { | |
| 113 | + "kind": "TEXT DEFAULT 'auto'", | |
| 114 | + }, | |
| 115 | +} | |
| 116 | + | |
| 117 | + | |
| 118 | +def connect() -> sqlite3.Connection: | |
| 119 | + DB_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| 120 | + con = sqlite3.connect(DB_PATH, timeout=30) | |
| 121 | + con.row_factory = sqlite3.Row | |
| 122 | + # WAL : plusieurs processus de sync peuvent écrire sans se bloquer | |
| 123 | + con.execute("PRAGMA journal_mode=WAL") | |
| 124 | + con.execute("PRAGMA busy_timeout=15000") | |
| 125 | + con.executescript(_SCHEMA) | |
| 126 | + for table, cols in _MIGRATIONS.items(): | |
| 127 | + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} | |
| 128 | + for col, decl in cols.items(): | |
| 129 | + if col not in existing: | |
| 130 | + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") | |
| 131 | + # index sur des colonnes issues de migrations : après l'ALTER TABLE | |
| 132 | + con.execute("CREATE INDEX IF NOT EXISTS idx_vehicles_kind ON vehicles(kind)") | |
| 133 | + con.commit() | |
| 134 | + return con | |
| 135 | + | |
| 136 | + | |
| 137 | +# --------------------------------------------------------------------------- | |
| 138 | +# Synchronisation d'une source | |
| 139 | +# --------------------------------------------------------------------------- | |
| 140 | + | |
| 141 | +def _drift_alert(con: sqlite3.Connection, source: str, found: int, | |
| 142 | + null_price_rate: float) -> str | None: | |
| 143 | + """Détecte une dérive du connecteur (chute du volume ou des prix extraits).""" | |
| 144 | + hist = con.execute( | |
| 145 | + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1" | |
| 146 | + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall() | |
| 147 | + if len(hist) < 3: | |
| 148 | + return None | |
| 149 | + med_found = statistics.median(r["found"] for r in hist) | |
| 150 | + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found: | |
| 151 | + return (f"dérive: {found} annonce(s) trouvée(s) contre une médiane de " | |
| 152 | + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur") | |
| 153 | + if found >= DRIFT_MIN_BASE and null_price_rate >= 0.8: | |
| 154 | + rates = [] | |
| 155 | + for r in hist: | |
| 156 | + try: | |
| 157 | + rates.append(json.loads(r["stats"] or "{}")["null_price_rate"]) | |
| 158 | + except (KeyError, ValueError, TypeError): | |
| 159 | + continue | |
| 160 | + if rates and statistics.median(rates) <= 0.3: | |
| 161 | + return (f"dérive: {null_price_rate:.0%} des annonces sans prix " | |
| 162 | + f"(habituellement {statistics.median(rates):.0%}) — " | |
| 163 | + "le format de la source a probablement changé") | |
| 164 | + return None | |
| 165 | + | |
| 166 | + | |
| 167 | +def sync_source(con: sqlite3.Connection, source: str, | |
| 168 | + vehicles: list[Vehicle]) -> dict: | |
| 169 | + """Synchronise les annonces d'une source. | |
| 170 | + | |
| 171 | + - nouvelle annonce -> insertion | |
| 172 | + - annonce modifiée -> mise à jour (comparaison de content_hash) | |
| 173 | + - annonce disparue -> miss_count += 1, puis active=0 après MISS_GRACE | |
| 174 | + exécutions consécutives (délai de grâce) — véhicule vendu ou retiré | |
| 175 | + - dérive détectée -> alerte consignée, retraits suspendus | |
| 176 | + """ | |
| 177 | + now = time.time() | |
| 178 | + added = updated = 0 | |
| 179 | + seen_uids = set() | |
| 180 | + | |
| 181 | + n = len(vehicles) | |
| 182 | + null_price = sum(1 for v in vehicles if v.price is None) | |
| 183 | + null_km = sum(1 for v in vehicles if v.mileage_km is None) | |
| 184 | + null_price_rate = round(null_price / n, 3) if n else 0.0 | |
| 185 | + | |
| 186 | + alert = _drift_alert(con, source, n, null_price_rate) | |
| 187 | + | |
| 188 | + for veh in vehicles: | |
| 189 | + seen_uids.add(veh.uid) | |
| 190 | + h = veh.content_hash() | |
| 191 | + row = con.execute("SELECT content_hash, price FROM vehicles WHERE uid=?", | |
| 192 | + (veh.uid,)).fetchone() | |
| 193 | + params = dict( | |
| 194 | + uid=veh.uid, source=veh.source, external_id=veh.external_id, | |
| 195 | + url=veh.url, kind=veh.kind or "auto", | |
| 196 | + title=veh.title, make=veh.make, model=veh.model, | |
| 197 | + trim=veh.trim, year=veh.year, price=veh.price, | |
| 198 | + price_label=veh.price_label, mileage_km=veh.mileage_km, | |
| 199 | + mileage_label=veh.mileage_label, transmission=veh.transmission, | |
| 200 | + fuel=veh.fuel, drivetrain=veh.drivetrain, body_type=veh.body_type, | |
| 201 | + exterior_color=veh.exterior_color, interior_color=veh.interior_color, | |
| 202 | + engine=veh.engine, doors=veh.doors, seats=veh.seats, vin=veh.vin, | |
| 203 | + stock_number=veh.stock_number, dealer_name=veh.dealer_name, | |
| 204 | + city=veh.city, region=veh.region, description=veh.description, | |
| 205 | + features=json.dumps(veh.features, ensure_ascii=False), | |
| 206 | + details=json.dumps(veh.details, ensure_ascii=False), | |
| 207 | + images=json.dumps(veh.images, ensure_ascii=False), | |
| 208 | + carfax_url=veh.carfax_url, content_hash=h, now=now, | |
| 209 | + ) | |
| 210 | + if row is None: | |
| 211 | + con.execute( | |
| 212 | + """INSERT INTO vehicles (uid, source, external_id, url, kind, | |
| 213 | + title, make, model, trim, year, price, price_label, | |
| 214 | + mileage_km, mileage_label, transmission, fuel, drivetrain, | |
| 215 | + body_type, exterior_color, interior_color, engine, doors, | |
| 216 | + seats, vin, stock_number, dealer_name, city, region, | |
| 217 | + description, features, details, images, carfax_url, | |
| 218 | + content_hash, first_seen, last_seen, updated_at, | |
| 219 | + miss_count, active) | |
| 220 | + VALUES (:uid,:source,:external_id,:url,:kind,:title,:make, | |
| 221 | + :model,:trim,:year,:price,:price_label,:mileage_km, | |
| 222 | + :mileage_label,:transmission,:fuel,:drivetrain,:body_type, | |
| 223 | + :exterior_color,:interior_color,:engine,:doors,:seats,:vin, | |
| 224 | + :stock_number,:dealer_name,:city,:region,:description, | |
| 225 | + :features,:details,:images,:carfax_url,:content_hash, | |
| 226 | + :now,:now,:now,0,1)""", | |
| 227 | + params) | |
| 228 | + if veh.price is not None: # prix initial = départ de l'historique | |
| 229 | + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", | |
| 230 | + (veh.uid, now, veh.price)) | |
| 231 | + added += 1 | |
| 232 | + elif row["content_hash"] != h: | |
| 233 | + con.execute( | |
| 234 | + """UPDATE vehicles SET url=:url, kind=:kind, title=:title, | |
| 235 | + make=:make, model=:model, trim=:trim, year=:year, price=:price, | |
| 236 | + price_label=:price_label, mileage_km=:mileage_km, | |
| 237 | + mileage_label=:mileage_label, transmission=:transmission, | |
| 238 | + fuel=:fuel, drivetrain=:drivetrain, body_type=:body_type, | |
| 239 | + exterior_color=:exterior_color, interior_color=:interior_color, | |
| 240 | + engine=:engine, doors=:doors, seats=:seats, vin=:vin, | |
| 241 | + stock_number=:stock_number, dealer_name=:dealer_name, | |
| 242 | + city=:city, region=:region, description=:description, | |
| 243 | + features=:features, details=:details, images=:images, | |
| 244 | + carfax_url=:carfax_url, content_hash=:content_hash, | |
| 245 | + last_seen=:now, updated_at=:now, miss_count=0, active=1 | |
| 246 | + WHERE uid=:uid""", params) | |
| 247 | + if veh.price != row["price"]: # changement de prix -> historique | |
| 248 | + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", | |
| 249 | + (veh.uid, now, veh.price)) | |
| 250 | + updated += 1 | |
| 251 | + else: | |
| 252 | + con.execute( | |
| 253 | + "UPDATE vehicles SET last_seen=?, miss_count=0, active=1 WHERE uid=?", | |
| 254 | + (now, veh.uid)) | |
| 255 | + | |
| 256 | + # Annonces de cette source qui n'apparaissent plus : délai de grâce, | |
| 257 | + # puis désactivation (véhicule vendu). Suspendu si dérive détectée. | |
| 258 | + removed = missed = 0 | |
| 259 | + if not alert: | |
| 260 | + for r in con.execute( | |
| 261 | + "SELECT uid, miss_count FROM vehicles WHERE source=? AND active=1", | |
| 262 | + (source,)).fetchall(): | |
| 263 | + if r["uid"] in seen_uids: | |
| 264 | + continue | |
| 265 | + missed += 1 | |
| 266 | + if r["miss_count"] + 1 >= MISS_GRACE: | |
| 267 | + con.execute( | |
| 268 | + "UPDATE vehicles SET active=0, miss_count=?, updated_at=?" | |
| 269 | + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"])) | |
| 270 | + removed += 1 | |
| 271 | + else: | |
| 272 | + con.execute("UPDATE vehicles SET miss_count=miss_count+1 WHERE uid=?", | |
| 273 | + (r["uid"],)) | |
| 274 | + | |
| 275 | + stats = { | |
| 276 | + "null_price_rate": null_price_rate, | |
| 277 | + "null_km_rate": round(null_km / n, 3) if n else 0.0, | |
| 278 | + "missed": missed, | |
| 279 | + } | |
| 280 | + if alert: | |
| 281 | + stats["alert"] = alert | |
| 282 | + con.execute( | |
| 283 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok," | |
| 284 | + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)", | |
| 285 | + (source, now, n, added, updated, removed, alert or "ok", | |
| 286 | + json.dumps(stats, ensure_ascii=False))) | |
| 287 | + con.commit() | |
| 288 | + out = {"source": source, "found": n, "added": added, | |
| 289 | + "updated": updated, "removed": removed} | |
| 290 | + if alert: | |
| 291 | + out["alert"] = alert | |
| 292 | + return out | |
| 293 | + | |
| 294 | + | |
| 295 | +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None: | |
| 296 | + con.execute( | |
| 297 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)" | |
| 298 | + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message)) | |
| 299 | + con.commit() | |
| 300 | + | |
| 301 | + | |
| 302 | +# --------------------------------------------------------------------------- | |
| 303 | +# Cache des pages détail (« détail si nouveau/modifié ») | |
| 304 | +# --------------------------------------------------------------------------- | |
| 305 | + | |
| 306 | +def get_cached_detail(con: sqlite3.Connection, source: str, | |
| 307 | + external_id: str, key: str) -> dict | None: | |
| 308 | + """Payload détail mis en cache si la clé (hash liste) n'a pas changé.""" | |
| 309 | + row = con.execute( | |
| 310 | + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?", | |
| 311 | + (source, external_id)).fetchone() | |
| 312 | + if row and row["key"] == key and row["payload"]: | |
| 313 | + try: | |
| 314 | + return json.loads(row["payload"]) | |
| 315 | + except ValueError: | |
| 316 | + return None | |
| 317 | + return None | |
| 318 | + | |
| 319 | + | |
| 320 | +def put_cached_detail(con: sqlite3.Connection, source: str, | |
| 321 | + external_id: str, key: str, payload: dict) -> None: | |
| 322 | + con.execute( | |
| 323 | + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)" | |
| 324 | + " VALUES (?,?,?,?,?)" | |
| 325 | + " ON CONFLICT(source, external_id) DO UPDATE SET" | |
| 326 | + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at", | |
| 327 | + (source, external_id, key, json.dumps(payload, ensure_ascii=False), | |
| 328 | + time.time())) | |
| 329 | + con.commit() | |
added
apps/auto/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
apps/auto/autoka/marketstats.py
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# marketstats.py : agrégats du marché — source unique pour /api/stats/detailed | |
| 5 | +# et le rapport PDF (autoka/pdfgen.py). | |
| 6 | +# ----------------------------------------------------------------------------- | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +import statistics | |
| 10 | +import time | |
| 11 | + | |
| 12 | +from . import db | |
| 13 | + | |
| 14 | +# bornes des classes de prix (k$) — dernière classe ouverte | |
| 15 | +PRICE_BUCKETS = [0, 5, 10, 15, 20, 25, 30, 40, 50, 75, 100] | |
| 16 | +KM_BUCKETS = [0, 20, 40, 60, 80, 100, 130, 160, 200] # en milliers de km | |
| 17 | + | |
| 18 | + | |
| 19 | +def compute() -> dict: | |
| 20 | + """Tous les agrégats du marché sur l'inventaire actif.""" | |
| 21 | + con = db.connect() | |
| 22 | + now = time.time() | |
| 23 | + | |
| 24 | + prices = [r["price"] for r in con.execute( | |
| 25 | + "SELECT price FROM vehicles WHERE active=1 AND kind='auto' AND price IS NOT NULL")] | |
| 26 | + kms = [r["mileage_km"] for r in con.execute( | |
| 27 | + "SELECT mileage_km FROM vehicles WHERE active=1 AND kind='auto' AND mileage_km IS NOT NULL")] | |
| 28 | + | |
| 29 | + head = con.execute( | |
| 30 | + """SELECT COUNT(*) total, COUNT(DISTINCT source) sources, | |
| 31 | + COUNT(DISTINCT region) regions, AVG(price) avg_price, | |
| 32 | + AVG(mileage_km) avg_km, AVG(year) avg_year, | |
| 33 | + SUM(CASE WHEN first_seen > ? THEN 1 ELSE 0 END) new_7d, | |
| 34 | + SUM(CASE WHEN fuel IN ('Électrique') THEN 1 ELSE 0 END) ev, | |
| 35 | + SUM(CASE WHEN fuel LIKE 'Hybride%' THEN 1 ELSE 0 END) hybrid | |
| 36 | + FROM vehicles WHERE active=1 AND kind='auto'""", (now - 7 * 86400,)).fetchone() | |
| 37 | + | |
| 38 | + # histogramme des prix | |
| 39 | + price_hist = [] | |
| 40 | + for i, lo in enumerate(PRICE_BUCKETS): | |
| 41 | + hi = PRICE_BUCKETS[i + 1] if i + 1 < len(PRICE_BUCKETS) else None | |
| 42 | + n = sum(1 for p in prices | |
| 43 | + if p >= lo * 1000 and (hi is None or p < hi * 1000)) | |
| 44 | + label = f"{lo}–{hi}k" if hi else f"{lo}k+" | |
| 45 | + price_hist.append({"label": label, "n": n}) | |
| 46 | + | |
| 47 | + # histogramme kilométrage | |
| 48 | + km_hist = [] | |
| 49 | + for i, lo in enumerate(KM_BUCKETS): | |
| 50 | + hi = KM_BUCKETS[i + 1] if i + 1 < len(KM_BUCKETS) else None | |
| 51 | + n = sum(1 for k in kms | |
| 52 | + if k >= lo * 1000 and (hi is None or k < hi * 1000)) | |
| 53 | + label = f"{lo}–{hi}k" if hi else f"{lo}k+" | |
| 54 | + km_hist.append({"label": label, "n": n}) | |
| 55 | + | |
| 56 | + # répartition par année (2010+ ; avant regroupé) | |
| 57 | + year_rows = con.execute( | |
| 58 | + "SELECT year, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 59 | + " AND year IS NOT NULL GROUP BY year ORDER BY year").fetchall() | |
| 60 | + year_hist, older = [], 0 | |
| 61 | + for r in year_rows: | |
| 62 | + if r["year"] < 2012: | |
| 63 | + older += r["n"] | |
| 64 | + else: | |
| 65 | + year_hist.append({"label": str(r["year"]), "n": r["n"]}) | |
| 66 | + if older: | |
| 67 | + year_hist.insert(0, {"label": "≤2011", "n": older}) | |
| 68 | + | |
| 69 | + def _rows(sql, args=()): | |
| 70 | + return [dict(r) for r in con.execute(sql, args).fetchall()] | |
| 71 | + | |
| 72 | + out = { | |
| 73 | + "generated_at": now, | |
| 74 | + "total": head["total"], | |
| 75 | + "sources": head["sources"], | |
| 76 | + "regions": head["regions"], | |
| 77 | + "avg_price": head["avg_price"], | |
| 78 | + "median_price": statistics.median(prices) if prices else None, | |
| 79 | + "avg_km": head["avg_km"], | |
| 80 | + "median_km": statistics.median(kms) if kms else None, | |
| 81 | + "avg_year": head["avg_year"], | |
| 82 | + "new_7d": head["new_7d"], | |
| 83 | + "electrified_pct": round(100.0 * (head["ev"] + head["hybrid"]) | |
| 84 | + / head["total"], 1) if head["total"] else 0, | |
| 85 | + "ev": head["ev"], "hybrid": head["hybrid"], | |
| 86 | + "price_hist": price_hist, | |
| 87 | + "km_hist": km_hist, | |
| 88 | + "year_hist": year_hist, | |
| 89 | + "by_make": _rows( | |
| 90 | + "SELECT make label, COUNT(*) n, ROUND(AVG(price)) avg_price" | |
| 91 | + " FROM vehicles WHERE active=1 AND kind='auto' AND make<>''" | |
| 92 | + " GROUP BY make ORDER BY n DESC LIMIT 14"), | |
| 93 | + "by_region": _rows( | |
| 94 | + "SELECT region label, COUNT(*) n, ROUND(AVG(price)) avg_price" | |
| 95 | + " FROM vehicles WHERE active=1 AND kind='auto' AND region<>''" | |
| 96 | + " GROUP BY region ORDER BY n DESC"), | |
| 97 | + "by_body": _rows( | |
| 98 | + "SELECT body_type label, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 99 | + " AND body_type<>'' GROUP BY body_type ORDER BY n DESC"), | |
| 100 | + "by_fuel": _rows( | |
| 101 | + "SELECT fuel label, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 102 | + " AND fuel<>'' GROUP BY fuel ORDER BY n DESC"), | |
| 103 | + "top_models": _rows( | |
| 104 | + "SELECT make || ' ' || model label, COUNT(*) n," | |
| 105 | + " ROUND(AVG(price)) avg_price, ROUND(AVG(mileage_km)) avg_km" | |
| 106 | + " FROM vehicles WHERE active=1 AND kind='auto' AND make<>'' AND model<>''" | |
| 107 | + " GROUP BY make, model ORDER BY n DESC LIMIT 15"), | |
| 108 | + "top_dealers": _rows( | |
| 109 | + "SELECT dealer_name label, COUNT(*) n, ROUND(AVG(price)) avg_price" | |
| 110 | + " FROM vehicles WHERE active=1 AND kind='auto' AND dealer_name<>''" | |
| 111 | + " GROUP BY dealer_name ORDER BY n DESC LIMIT 12"), | |
| 112 | + "avg_price_by_year": _rows( | |
| 113 | + "SELECT year label, ROUND(AVG(price)) n FROM vehicles" | |
| 114 | + " WHERE active=1 AND kind='auto' AND year>=2012 AND price IS NOT NULL" | |
| 115 | + " GROUP BY year ORDER BY year"), | |
| 116 | + "price_drops": _rows( | |
| 117 | + """SELECT v.uid, v.title, v.year, v.price, v.dealer_name, v.city, | |
| 118 | + p.prev_price | |
| 119 | + FROM vehicles v JOIN ( | |
| 120 | + SELECT uid, price prev_price, | |
| 121 | + ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn | |
| 122 | + FROM price_log) p ON p.uid=v.uid AND p.rn=2 | |
| 123 | + WHERE v.active=1 AND kind='auto' AND v.price IS NOT NULL | |
| 124 | + AND p.prev_price > v.price | |
| 125 | + ORDER BY (p.prev_price - v.price) DESC LIMIT 12"""), | |
| 126 | + } | |
| 127 | + con.close() | |
| 128 | + return out | |
added
apps/auto/autoka/normalize.py
+378 −0
@@ -0,0 +1,378 @@ | ||
| 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 | +# marques MOTO/SCOOTER (verticale moto — connectors/moto_dealers.py) : | |
| 115 | +# ajoutées au lookup de normalize_make SEULEMENT (pas à MAKES, pour ne pas | |
| 116 | +# influencer split_title côté autos). Sans elles, le repli .title() casserait | |
| 117 | +# les sigles : « KTM » → « Ktm », « CFMOTO » → « Cfmoto », etc. | |
| 118 | +_MOTO_MAKE_LOOKUP = { | |
| 119 | + "harleydavidson": "Harley-Davidson", "harley": "Harley-Davidson", | |
| 120 | + "indianmotorcycle": "Indian Motorcycle", "indian": "Indian Motorcycle", | |
| 121 | + "ktm": "KTM", "brp": "BRP", "canam": "Can-Am", | |
| 122 | + "cfmoto": "CFMOTO", "gasgas": "GASGAS", "kymco": "KYMCO", | |
| 123 | + "mvagusta": "MV Agusta", "motoguzzi": "Moto Guzzi", | |
| 124 | + "royalenfield": "Royal Enfield", "victorymotorcycles": "Victory", | |
| 125 | + "victory": "Victory", "ssr": "SSR", "surron": "Sur-Ron", | |
| 126 | +} | |
| 127 | +_MAKE_LOOKUP.update(_MOTO_MAKE_LOOKUP) | |
| 128 | + | |
| 129 | + | |
| 130 | +def normalize_make(raw: str | None) -> str: | |
| 131 | + if not raw: | |
| 132 | + return "" | |
| 133 | + key = strip_accents(raw.strip()).lower().replace(" ", "").replace("-", "") | |
| 134 | + return _MAKE_LOOKUP.get(key, raw.strip().title() if raw.strip() else "") | |
| 135 | + | |
| 136 | + | |
| 137 | +# marques triées par longueur décroissante pour matcher « Land Rover » avant « Rover » | |
| 138 | +_MAKES_BY_LEN = sorted(MAKES + ["Mercedes"], key=len, reverse=True) | |
| 139 | + | |
| 140 | + | |
| 141 | +def split_title(title: str) -> tuple[int | None, str, str]: | |
| 142 | + """« Toyota RAV4 2019 XLE AWD » ou « 2019 Toyota RAV4 » -> | |
| 143 | + (année, marque, modèle+version). Champs vides si non détectés.""" | |
| 144 | + if not title: | |
| 145 | + return None, "", "" | |
| 146 | + year = parse_year(title) | |
| 147 | + text = _YEAR_RE.sub(" ", title) | |
| 148 | + flat = strip_accents(text).lower() | |
| 149 | + for make in _MAKES_BY_LEN: | |
| 150 | + mk = strip_accents(make).lower() | |
| 151 | + idx = flat.find(mk) | |
| 152 | + if idx >= 0: | |
| 153 | + before = flat[idx - 1] if idx > 0 else " " | |
| 154 | + after_i = idx + len(mk) | |
| 155 | + after = flat[after_i] if after_i < len(flat) else " " | |
| 156 | + if not before.isalnum() and not after.isalnum(): | |
| 157 | + model = text[after_i:].strip(" -–|,·") | |
| 158 | + model = re.sub(r"\s{2,}", " ", model).strip() | |
| 159 | + return year, normalize_make(make), model | |
| 160 | + return year, "", text.strip() | |
| 161 | + | |
| 162 | + | |
| 163 | +# --------------------------------------------------------------------------- | |
| 164 | +# Transmission, carburant, motricité, carrosserie | |
| 165 | +# --------------------------------------------------------------------------- | |
| 166 | + | |
| 167 | +def normalize_transmission(raw: str | None) -> str: | |
| 168 | + if not raw: | |
| 169 | + return "" | |
| 170 | + t = strip_accents(raw).lower() | |
| 171 | + if "cvt" in t or "variation continue" in t: | |
| 172 | + return "Automatique" # CVT présentée comme automatique | |
| 173 | + if "man" in t: | |
| 174 | + return "Manuelle" | |
| 175 | + if "auto" in t or "tiptronic" in t or "dsg" in t or "pdk" in t: | |
| 176 | + return "Automatique" # « Automatique 6 vitesses » = automatique | |
| 177 | + if re.search(r"\b[56]\s?(vit|spd|speed)", t): | |
| 178 | + return "Manuelle" # « 6 vitesses » seul = boîte manuelle | |
| 179 | + return "" | |
| 180 | + | |
| 181 | + | |
| 182 | +def normalize_fuel(raw: str | None) -> str: | |
| 183 | + if not raw: | |
| 184 | + return "" | |
| 185 | + t = strip_accents(raw).lower() | |
| 186 | + if "rechargeable" in t or "plug" in t or "phev" in t: | |
| 187 | + return "Hybride rechargeable" | |
| 188 | + if "hybrid" in t or "hybride" in t: | |
| 189 | + return "Hybride" | |
| 190 | + if "electr" in t or "ev" == t.strip() or "electric" in t: | |
| 191 | + return "Électrique" | |
| 192 | + if "diesel" in t or "tdi" in t: | |
| 193 | + return "Diesel" | |
| 194 | + if "essence" in t or "gas" in t or "petrol" in t or "sans plomb" in t: | |
| 195 | + return "Essence" | |
| 196 | + return "" | |
| 197 | + | |
| 198 | + | |
| 199 | +def normalize_drivetrain(raw: str | None) -> str: | |
| 200 | + if not raw: | |
| 201 | + return "" | |
| 202 | + t = strip_accents(raw).lower() | |
| 203 | + if "awd" in t or "4wd" in t or "4x4" in t or "integrale" in t or "all" in t \ | |
| 204 | + or "quattro" in t or "xdrive" in t or "4matic" in t or "4motion" in t: | |
| 205 | + return "Intégrale / 4x4" | |
| 206 | + if "rwd" in t or "propulsion" in t or "arriere" in t or "rear" in t: | |
| 207 | + return "Propulsion" | |
| 208 | + if "fwd" in t or "traction" in t or "avant" in t or "front" in t or "2wd" in t: | |
| 209 | + return "Traction" | |
| 210 | + return "" | |
| 211 | + | |
| 212 | + | |
| 213 | +# jetons STRICTS repérables dans un titre/une version (pas de faux positifs) | |
| 214 | +_DRIVE_TOKEN_RE = re.compile( | |
| 215 | + r"\b(awd|4wd|4x4|4rm|quattro|xdrive|4matic|4motion|sh-awd|e-four|" | |
| 216 | + r"traction integrale|integrale|awc)\b", re.I) | |
| 217 | +_RWD_TOKEN_RE = re.compile(r"\b(rwd|propulsion|sdrive)\b", re.I) | |
| 218 | +_FWD_TOKEN_RE = re.compile(r"\b(fwd|2rm|traction avant)\b", re.I) | |
| 219 | + | |
| 220 | + | |
| 221 | +def infer_drivetrain_from_text(text: str | None) -> str: | |
| 222 | + """Motricité déduite des jetons explicites du titre/version (repli).""" | |
| 223 | + if not text: | |
| 224 | + return "" | |
| 225 | + flat = strip_accents(text) | |
| 226 | + if _DRIVE_TOKEN_RE.search(flat): | |
| 227 | + return "Intégrale / 4x4" | |
| 228 | + if _RWD_TOKEN_RE.search(flat): | |
| 229 | + return "Propulsion" | |
| 230 | + if _FWD_TOKEN_RE.search(flat): | |
| 231 | + return "Traction" | |
| 232 | + return "" | |
| 233 | + | |
| 234 | + | |
| 235 | +_FUEL_HR_RE = re.compile(r"\b(phev|plug-?in|hybride?\s+rechargeable)\b", re.I) | |
| 236 | +_FUEL_HYB_RE = re.compile(r"\b(hybride?|hybrid)\b", re.I) | |
| 237 | +_FUEL_EV_RE = re.compile( | |
| 238 | + r"\b(electrique|electric|ev|bev|e-tron|ioniq \d|kona electric|" | |
| 239 | + r"bolt|leaf|model [3sxy]|id\.?4)\b", re.I) | |
| 240 | +_FUEL_DIESEL_RE = re.compile(r"\b(diesel|tdi|duramax|cummins|ecodiesel)\b", re.I) | |
| 241 | + | |
| 242 | + | |
| 243 | +def infer_fuel_from_text(text: str | None) -> str: | |
| 244 | + """Carburant déduit des jetons explicites du titre/version (repli). | |
| 245 | + | |
| 246 | + Volontairement conservateur : ne regarde jamais la description | |
| 247 | + (« sièges électriques » n'est pas un véhicule électrique). | |
| 248 | + """ | |
| 249 | + if not text: | |
| 250 | + return "" | |
| 251 | + flat = strip_accents(text) | |
| 252 | + if _FUEL_HR_RE.search(flat): | |
| 253 | + return "Hybride rechargeable" | |
| 254 | + if _FUEL_HYB_RE.search(flat): | |
| 255 | + return "Hybride" | |
| 256 | + if _FUEL_EV_RE.search(flat): | |
| 257 | + return "Électrique" | |
| 258 | + if _FUEL_DIESEL_RE.search(flat): | |
| 259 | + return "Diesel" | |
| 260 | + return "" | |
| 261 | + | |
| 262 | + | |
| 263 | +_BODY_MAP = [ | |
| 264 | + (("vus", "suv", "utilitaire", "crossover", "multisegment"), "VUS"), | |
| 265 | + (("camionnette", "camion", "pickup", "pick-up", "truck", "cabine"), "Camionnette"), | |
| 266 | + (("fourgonnette", "minivan", "mini-fourgonnette", "van", "fourgon"), "Fourgonnette"), | |
| 267 | + (("cabriolet", "convertible", "decapotable"), "Cabriolet"), | |
| 268 | + (("coupe",), "Coupé"), | |
| 269 | + (("hayon", "hatchback", "a hayon", "5 portes"), "Hayon"), | |
| 270 | + (("familiale", "wagon", "break"), "Familiale"), | |
| 271 | + (("berline", "sedan", "4 portes"), "Berline"), | |
| 272 | +] | |
| 273 | + | |
| 274 | + | |
| 275 | +def normalize_body(raw: str | None) -> str: | |
| 276 | + if not raw: | |
| 277 | + return "" | |
| 278 | + t = strip_accents(raw).lower() | |
| 279 | + for keys, canon in _BODY_MAP: | |
| 280 | + if any(k in t for k in keys): | |
| 281 | + return canon | |
| 282 | + return "" | |
| 283 | + | |
| 284 | + | |
| 285 | +# --------------------------------------------------------------------------- | |
| 286 | +# Ville -> région administrative du Québec | |
| 287 | +# --------------------------------------------------------------------------- | |
| 288 | + | |
| 289 | +_REGIONS: dict[str, tuple[str, ...]] = { | |
| 290 | + "Capitale-Nationale": ( | |
| 291 | + "quebec", "ste-foy", "sainte-foy", "val-belair", "beauport", "charlesbourg", | |
| 292 | + "loretteville", "l'ancienne-lorette", "ancienne-lorette", "donnacona", | |
| 293 | + "cap-sante", "pont-rouge", "saint-augustin", "st-augustin", "baie-saint-paul", | |
| 294 | + "la malbaie", "portneuf", "saint-raymond", "boischatel", "vanier", | |
| 295 | + ), | |
| 296 | + "Chaudière-Appalaches": ( | |
| 297 | + "levis", "saint-nicolas", "st-nicolas", "charny", "saint-romuald", | |
| 298 | + "st-romuald", "saint-georges", "st-georges", "thetford", "montmagny", | |
| 299 | + "sainte-marie", "ste-marie", "beauce", "saint-joseph", "lac-etchemin", | |
| 300 | + "bernieres", "breakeyville", "laurier-station", | |
| 301 | + ), | |
| 302 | + "Montréal": ( | |
| 303 | + "montreal", "saint-leonard", "st-leonard", "anjou", "lasalle", "verdun", | |
| 304 | + "dorval", "pointe-claire", "kirkland", "montreal-nord", "montreal-est", | |
| 305 | + "ahuntsic", "villeray", "rosemont", "hochelaga", "lachine", "pierrefonds", | |
| 306 | + ), | |
| 307 | + "Laval": ("laval", "chomedey", "vimont", "duvernay", "sainte-rose", "ste-rose"), | |
| 308 | + "Montérégie": ( | |
| 309 | + "longueuil", "brossard", "saint-hubert", "st-hubert", "boucherville", | |
| 310 | + "chateauguay", "granby", "saint-hyacinthe", "st-hyacinthe", "sorel", | |
| 311 | + "valleyfield", "salaberry", "vaudreuil", "saint-jean-sur-richelieu", | |
| 312 | + "st-jean-sur-richelieu", "chambly", "beloeil", "la prairie", "candiac", | |
| 313 | + "saint-constant", "st-constant", "varennes", "cowansville", "magog", | |
| 314 | + "sainte-julie", "ste-julie", "mont-saint-hilaire", "acton vale", | |
| 315 | + "carignan", "ile-perrot", "l'ile-perrot", "saint-basile", "st-basile", | |
| 316 | + "marieville", "mont-saint-gregoire", "napierville", "saint-remi", | |
| 317 | + ), | |
| 318 | + "Laurentides": ( | |
| 319 | + "saint-jerome", "st-jerome", "blainville", "boisbriand", "mirabel", | |
| 320 | + "saint-eustache", "st-eustache", "sainte-therese", "ste-therese", | |
| 321 | + "lachute", "mont-tremblant", "sainte-agathe", "ste-agathe", "saint-sauveur", | |
| 322 | + "st-sauveur", "rosemere", "deux-montagnes", "mont-laurier", | |
| 323 | + ), | |
| 324 | + "Lanaudière": ( | |
| 325 | + "joliette", "repentigny", "terrebonne", "mascouche", "l'assomption", | |
| 326 | + "assomption", "rawdon", "berthierville", "lavaltrie", "saint-lin", | |
| 327 | + "st-lin", "charlemagne", | |
| 328 | + ), | |
| 329 | + "Estrie": ( | |
| 330 | + "sherbrooke", "rock forest", "fleurimont", "coaticook", "windsor", | |
| 331 | + "east angus", "lac-megantic", "asbestos", "val-des-sources", "bromont", | |
| 332 | + ), | |
| 333 | + "Mauricie": ( | |
| 334 | + "trois-rivieres", "shawinigan", "grand-mere", "louiseville", "nicolet", | |
| 335 | + "la tuque", "cap-de-la-madeleine", | |
| 336 | + ), | |
| 337 | + "Centre-du-Québec": ( | |
| 338 | + "drummondville", "victoriaville", "plessisville", "becancour", | |
| 339 | + "warwick", "princeville", | |
| 340 | + ), | |
| 341 | + "Saguenay–Lac-Saint-Jean": ( | |
| 342 | + "saguenay", "chicoutimi", "jonquiere", "alma", "roberval", "dolbeau", | |
| 343 | + "saint-felicien", "st-felicien", "la baie", | |
| 344 | + ), | |
| 345 | + "Bas-Saint-Laurent": ( | |
| 346 | + "rimouski", "riviere-du-loup", "matane", "mont-joli", "amqui", | |
| 347 | + "la pocatiere", "temiscouata", "trois-pistoles", | |
| 348 | + ), | |
| 349 | + "Gaspésie–Îles-de-la-Madeleine": ( | |
| 350 | + "gaspe", "caplan", "bonaventure", "carleton", "chandler", "new richmond", | |
| 351 | + "sainte-anne-des-monts", "ste-anne-des-monts", | |
| 352 | + ), | |
| 353 | + "Côte-Nord": ( | |
| 354 | + "baie-comeau", "sept-iles", "port-cartier", "forestville", "havre-saint-pierre", | |
| 355 | + ), | |
| 356 | + "Abitibi-Témiscamingue": ( | |
| 357 | + "rouyn-noranda", "rouyn", "val-d'or", "val-dor", "amos", "la sarre", | |
| 358 | + "ville-marie", "malartic", "senneterre", | |
| 359 | + ), | |
| 360 | + "Outaouais": ( | |
| 361 | + "gatineau", "hull", "aylmer", "buckingham", "maniwaki", "papineauville", | |
| 362 | + ), | |
| 363 | +} | |
| 364 | + | |
| 365 | +_CITY_TO_REGION = {city: region for region, cities in _REGIONS.items() | |
| 366 | + for city in cities} | |
| 367 | + | |
| 368 | + | |
| 369 | +def infer_region(city: str | None) -> str: | |
| 370 | + if not city: | |
| 371 | + return "" | |
| 372 | + key = strip_accents(city.strip()).lower() | |
| 373 | + if key in _CITY_TO_REGION: | |
| 374 | + return _CITY_TO_REGION[key] | |
| 375 | + for c, region in _CITY_TO_REGION.items(): | |
| 376 | + if c in key: | |
| 377 | + return region | |
| 378 | + return "" | |
added
apps/auto/autoka/pdfgen.py
+280 −0
@@ -0,0 +1,280 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# pdfgen.py : rapport PDF « Le marché de l'occasion » — vue d'ensemble des | |
| 5 | +# statistiques (héros, distributions, marques, régions, aubaines). | |
| 6 | +# ----------------------------------------------------------------------------- | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +import datetime | |
| 10 | +import io | |
| 11 | + | |
| 12 | +from reportlab.lib.colors import HexColor | |
| 13 | +from reportlab.lib.pagesizes import letter | |
| 14 | +from reportlab.lib.units import mm | |
| 15 | +from reportlab.pdfgen.canvas import Canvas | |
| 16 | + | |
| 17 | +from . import marketstats | |
| 18 | + | |
| 19 | +INK = HexColor("#17181c") | |
| 20 | +INK2 = HexColor("#4c4f57") | |
| 21 | +INK3 = HexColor("#8b8e96") | |
| 22 | +ACCENT = HexColor("#ff5a2a") | |
| 23 | +ACCENT_DEEP = HexColor("#cc3f16") | |
| 24 | +PAPER = HexColor("#f4f2ec") | |
| 25 | +SURFACE = HexColor("#ffffff") | |
| 26 | +GOOD = HexColor("#1c7a4d") | |
| 27 | +LINE = HexColor("#d8d5cd") | |
| 28 | + | |
| 29 | +W, H = letter | |
| 30 | +MARGIN = 18 * mm | |
| 31 | + | |
| 32 | + | |
| 33 | +def _fmt(n, suffix="") -> str: | |
| 34 | + if n is None: | |
| 35 | + return "—" | |
| 36 | + return f"{round(n):,}".replace(",", " ") + suffix | |
| 37 | + | |
| 38 | + | |
| 39 | +class _Doc: | |
| 40 | + """Petit assistant de mise en page (curseur vertical + gabarits).""" | |
| 41 | + | |
| 42 | + def __init__(self, canvas: Canvas): | |
| 43 | + self.c = canvas | |
| 44 | + self.y = H - MARGIN | |
| 45 | + self.page = 1 | |
| 46 | + self._chrome() | |
| 47 | + | |
| 48 | + # -- gabarit de page ------------------------------------------------------- | |
| 49 | + def _chrome(self): | |
| 50 | + c = self.c | |
| 51 | + c.setFillColor(PAPER) | |
| 52 | + c.rect(0, 0, W, H, stroke=0, fill=1) | |
| 53 | + # bandeau encre | |
| 54 | + c.setFillColor(INK) | |
| 55 | + c.rect(0, H - 12 * mm, W, 12 * mm, stroke=0, fill=1) | |
| 56 | + c.setFillColor(ACCENT) | |
| 57 | + c.setFont("Helvetica-Bold", 11) | |
| 58 | + c.drawString(MARGIN, H - 8 * mm, "Auto·Ka") | |
| 59 | + c.setFillColor(HexColor("#c9cbd1")) | |
| 60 | + c.setFont("Helvetica", 8) | |
| 61 | + c.drawString(MARGIN + 18 * mm, H - 8 * mm, | |
| 62 | + "Le marché des voitures usagées au Québec — rapport d'ensemble") | |
| 63 | + c.drawRightString(W - MARGIN, H - 8 * mm, | |
| 64 | + datetime.date.today().strftime("%Y-%m-%d")) | |
| 65 | + # pied | |
| 66 | + c.setFillColor(INK3) | |
| 67 | + c.setFont("Helvetica", 7) | |
| 68 | + c.drawString(MARGIN, 10 * mm, | |
| 69 | + "www.auto-ka.com — agrégateur indépendant, données lues " | |
| 70 | + "directement sur les sites des concessionnaires") | |
| 71 | + c.drawRightString(W - MARGIN, 10 * mm, f"page {self.page}") | |
| 72 | + self.y = H - 22 * mm | |
| 73 | + | |
| 74 | + def new_page(self): | |
| 75 | + self.c.showPage() | |
| 76 | + self.page += 1 | |
| 77 | + self._chrome() | |
| 78 | + | |
| 79 | + def need(self, height: float): | |
| 80 | + if self.y - height < 16 * mm: | |
| 81 | + self.new_page() | |
| 82 | + | |
| 83 | + # -- blocs ----------------------------------------------------------------- | |
| 84 | + def title(self, text: str): | |
| 85 | + self.need(14 * mm) | |
| 86 | + self.c.setFillColor(ACCENT_DEEP) | |
| 87 | + self.c.setFont("Helvetica-Bold", 8) | |
| 88 | + self.c.drawString(MARGIN, self.y, "— " + text.upper()) | |
| 89 | + self.y -= 7 * mm | |
| 90 | + | |
| 91 | + def hero_row(self, items: list[tuple[str, str]]): | |
| 92 | + """Rangée de tuiles héros (valeur + étiquette).""" | |
| 93 | + n = len(items) | |
| 94 | + gap = 4 * mm | |
| 95 | + w = (W - 2 * MARGIN - gap * (n - 1)) / n | |
| 96 | + h = 20 * mm | |
| 97 | + self.need(h + 6 * mm) | |
| 98 | + x = MARGIN | |
| 99 | + for value, label in items: | |
| 100 | + self.c.setFillColor(SURFACE) | |
| 101 | + self.c.setStrokeColor(INK) | |
| 102 | + self.c.setLineWidth(1.2) | |
| 103 | + self.c.roundRect(x, self.y - h, w, h, 2 * mm, stroke=1, fill=1) | |
| 104 | + self.c.setFillColor(INK) | |
| 105 | + self.c.setFont("Helvetica-Bold", 15) | |
| 106 | + self.c.drawString(x + 3.5 * mm, self.y - 9 * mm, value) | |
| 107 | + self.c.setFillColor(INK3) | |
| 108 | + self.c.setFont("Helvetica", 6.5) | |
| 109 | + self.c.drawString(x + 3.5 * mm, self.y - h + 3.5 * mm, label.upper()) | |
| 110 | + x += w + gap | |
| 111 | + self.y -= h + 8 * mm | |
| 112 | + | |
| 113 | + def bars(self, rows: list[dict], value_key="n", label_key="label", | |
| 114 | + extra_key=None, height_per=6.2 * mm, color=ACCENT, | |
| 115 | + value_fmt=lambda v: _fmt(v)): | |
| 116 | + """Barres horizontales fines, étiquettes directes, coins arrondis.""" | |
| 117 | + if not rows: | |
| 118 | + return | |
| 119 | + total_h = height_per * len(rows) | |
| 120 | + self.need(total_h + 4 * mm) | |
| 121 | + max_v = max(r[value_key] or 0 for r in rows) or 1 | |
| 122 | + label_w = 42 * mm | |
| 123 | + bar_max = W - 2 * MARGIN - label_w - 26 * mm | |
| 124 | + for r in rows: | |
| 125 | + v = r[value_key] or 0 | |
| 126 | + bw = max(1.2 * mm, bar_max * v / max_v) | |
| 127 | + yb = self.y - 4.2 * mm | |
| 128 | + self.c.setFillColor(INK2) | |
| 129 | + self.c.setFont("Helvetica", 7.5) | |
| 130 | + label = str(r[label_key])[:30] | |
| 131 | + self.c.drawRightString(MARGIN + label_w - 2 * mm, yb + 0.6 * mm, label) | |
| 132 | + self.c.setFillColor(color) | |
| 133 | + self.c.roundRect(MARGIN + label_w, yb, bw, 3.4 * mm, 1 * mm, | |
| 134 | + stroke=0, fill=1) | |
| 135 | + self.c.setFillColor(INK) | |
| 136 | + self.c.setFont("Helvetica-Bold", 7) | |
| 137 | + txt = value_fmt(v) | |
| 138 | + if extra_key and r.get(extra_key) is not None: | |
| 139 | + txt += f" · moy. {_fmt(r[extra_key], ' $')}" | |
| 140 | + self.c.setFont("Helvetica", 7) | |
| 141 | + self.c.drawString(MARGIN + label_w + bw + 2 * mm, yb + 0.6 * mm, txt) | |
| 142 | + self.y -= height_per | |
| 143 | + self.y -= 5 * mm | |
| 144 | + | |
| 145 | + def histogram(self, rows: list[dict], height=32 * mm, color=ACCENT): | |
| 146 | + """Histogramme vertical (classes de prix/km/années).""" | |
| 147 | + if not rows: | |
| 148 | + return | |
| 149 | + self.need(height + 14 * mm) | |
| 150 | + n = len(rows) | |
| 151 | + gap = 1.6 * mm | |
| 152 | + bw = (W - 2 * MARGIN - gap * (n - 1)) / n | |
| 153 | + max_v = max(r["n"] for r in rows) or 1 | |
| 154 | + base = self.y - height | |
| 155 | + x = MARGIN | |
| 156 | + self.c.setFont("Helvetica", 6) | |
| 157 | + for r in rows: | |
| 158 | + bh = max(1 * mm, height * r["n"] / max_v) | |
| 159 | + self.c.setFillColor(color) | |
| 160 | + self.c.roundRect(x, base, bw, bh, 1 * mm, stroke=0, fill=1) | |
| 161 | + self.c.setFillColor(INK) | |
| 162 | + self.c.setFont("Helvetica-Bold", 6) | |
| 163 | + self.c.drawCentredString(x + bw / 2, base + bh + 1.4 * mm, _fmt(r["n"])) | |
| 164 | + self.c.setFillColor(INK3) | |
| 165 | + self.c.setFont("Helvetica", 5.8) | |
| 166 | + self.c.drawCentredString(x + bw / 2, base - 3.2 * mm, r["label"]) | |
| 167 | + x += bw + gap | |
| 168 | + self.y = base - 9 * mm | |
| 169 | + | |
| 170 | + def table(self, headers: list[str], rows: list[list[str]], | |
| 171 | + widths: list[float]): | |
| 172 | + self.need(6 * mm * (len(rows) + 1)) | |
| 173 | + x = MARGIN | |
| 174 | + self.c.setFont("Helvetica-Bold", 7) | |
| 175 | + self.c.setFillColor(INK3) | |
| 176 | + for htxt, w in zip(headers, widths): | |
| 177 | + self.c.drawString(x, self.y, htxt.upper()) | |
| 178 | + x += w | |
| 179 | + self.y -= 1.6 * mm | |
| 180 | + self.c.setStrokeColor(INK) | |
| 181 | + self.c.setLineWidth(0.8) | |
| 182 | + self.c.line(MARGIN, self.y, W - MARGIN, self.y) | |
| 183 | + self.y -= 4.4 * mm | |
| 184 | + for row in rows: | |
| 185 | + self.need(5.4 * mm) | |
| 186 | + x = MARGIN | |
| 187 | + self.c.setFont("Helvetica", 7.5) | |
| 188 | + self.c.setFillColor(INK) | |
| 189 | + for cell, w in zip(row, widths): | |
| 190 | + self.c.drawString(x, self.y, str(cell)[:42]) | |
| 191 | + x += w | |
| 192 | + self.c.setStrokeColor(LINE) | |
| 193 | + self.c.setLineWidth(0.4) | |
| 194 | + self.c.line(MARGIN, self.y - 1.6 * mm, W - MARGIN, self.y - 1.6 * mm) | |
| 195 | + self.y -= 5.4 * mm | |
| 196 | + self.y -= 4 * mm | |
| 197 | + | |
| 198 | + | |
| 199 | +def rapport_pdf() -> bytes: | |
| 200 | + """Rapport PDF multi-pages : vue d'ensemble du marché de l'occasion.""" | |
| 201 | + s = marketstats.compute() | |
| 202 | + buf = io.BytesIO() | |
| 203 | + c = Canvas(buf, pagesize=letter) | |
| 204 | + c.setTitle("Auto-Ka — Rapport du marché de l'occasion") | |
| 205 | + c.setAuthor("Simon-Pierre Boucher — contact@spboucher.ai") | |
| 206 | + doc = _Doc(c) | |
| 207 | + | |
| 208 | + # -- entête / héros --------------------------------------------------------- | |
| 209 | + doc.c.setFillColor(INK) | |
| 210 | + doc.c.setFont("Helvetica-Bold", 22) | |
| 211 | + doc.c.drawString(MARGIN, doc.y, "Le marché de l'occasion, en un coup d'œil") | |
| 212 | + doc.y -= 6 * mm | |
| 213 | + doc.c.setFillColor(INK2) | |
| 214 | + doc.c.setFont("Helvetica", 9) | |
| 215 | + doc.c.drawString( | |
| 216 | + MARGIN, doc.y, | |
| 217 | + f"Inventaire actif agrégé de {s['sources']} concessionnaires dans " | |
| 218 | + f"{s['regions']} régions du Québec.") | |
| 219 | + doc.y -= 10 * mm | |
| 220 | + | |
| 221 | + doc.hero_row([ | |
| 222 | + (_fmt(s["total"]), "véhicules en vente"), | |
| 223 | + (_fmt(s["avg_price"], " $"), "prix moyen"), | |
| 224 | + (_fmt(s["median_price"], " $"), "prix médian"), | |
| 225 | + (_fmt(s["avg_km"], " km"), "km moyen"), | |
| 226 | + ]) | |
| 227 | + doc.hero_row([ | |
| 228 | + (f"{s['avg_year']:.0f}" if s["avg_year"] else "—", "année moyenne"), | |
| 229 | + (_fmt(s["new_7d"]), "arrivages (7 jours)"), | |
| 230 | + (f"{s['electrified_pct']} %", "électrifiés (VÉ + hybrides)"), | |
| 231 | + (_fmt(s["sources"]), "concessionnaires"), | |
| 232 | + ]) | |
| 233 | + | |
| 234 | + # -- distributions ---------------------------------------------------------- | |
| 235 | + doc.title("Distribution des prix") | |
| 236 | + doc.histogram(s["price_hist"]) | |
| 237 | + doc.title("Distribution du kilométrage (milliers de km)") | |
| 238 | + doc.histogram(s["km_hist"], color=HexColor("#1f6fb5")) | |
| 239 | + | |
| 240 | + # -- marques / régions ------------------------------------------------------ | |
| 241 | + doc.new_page() | |
| 242 | + doc.title("Top marques (inventaire et prix moyen)") | |
| 243 | + doc.bars(s["by_make"], extra_key="avg_price") | |
| 244 | + doc.title("Par région (inventaire et prix moyen)") | |
| 245 | + doc.bars(s["by_region"], extra_key="avg_price", | |
| 246 | + color=HexColor("#1f6fb5")) | |
| 247 | + | |
| 248 | + # -- modèles ---------------------------------------------------------------- | |
| 249 | + doc.new_page() | |
| 250 | + doc.title("Modèles les plus offerts") | |
| 251 | + doc.table( | |
| 252 | + ["Modèle", "En vente", "Prix moyen", "KM moyen"], | |
| 253 | + [[r["label"], _fmt(r["n"]), _fmt(r["avg_price"], " $"), | |
| 254 | + _fmt(r["avg_km"], " km")] for r in s["top_models"]], | |
| 255 | + [70 * mm, 30 * mm, 38 * mm, 38 * mm]) | |
| 256 | + | |
| 257 | + doc.title("Carrosseries") | |
| 258 | + doc.bars(s["by_body"], color=HexColor("#1c7a4d")) | |
| 259 | + doc.title("Carburants") | |
| 260 | + doc.bars(s["by_fuel"], color=HexColor("#b58500")) | |
| 261 | + | |
| 262 | + # -- aubaines --------------------------------------------------------------- | |
| 263 | + if s["price_drops"]: | |
| 264 | + doc.new_page() | |
| 265 | + doc.title("Baisses de prix récentes (aubaines détectées)") | |
| 266 | + doc.table( | |
| 267 | + ["Véhicule", "Avant", "Maintenant", "Économie", "Concessionnaire"], | |
| 268 | + [[f"{r['title'][:34]}", _fmt(r["prev_price"], " $"), | |
| 269 | + _fmt(r["price"], " $"), | |
| 270 | + "-" + _fmt(r["prev_price"] - r["price"], " $"), | |
| 271 | + f"{r['dealer_name'][:22]} ({r['city']})"] | |
| 272 | + for r in s["price_drops"]], | |
| 273 | + [62 * mm, 24 * mm, 26 * mm, 24 * mm, 44 * mm]) | |
| 274 | + | |
| 275 | + doc.title("Plus grands inventaires") | |
| 276 | + doc.bars(s["top_dealers"], extra_key="avg_price", | |
| 277 | + color=HexColor("#7d4fc9")) | |
| 278 | + | |
| 279 | + c.save() | |
| 280 | + return buf.getvalue() | |
added
apps/auto/autoka/schema.py
+142 −0
@@ -0,0 +1,142 @@ | ||
| 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 | + kind: str = "auto" # verticale : auto | moto | scooter | |
| 50 | + title: str = "" # ex. "Toyota RAV4 XLE 2019" | |
| 51 | + make: str = "" # Toyota, Honda, ... | |
| 52 | + model: str = "" # RAV4, Civic, ... | |
| 53 | + trim: str = "" # XLE AWD, Sport, ... | |
| 54 | + year: int | None = None | |
| 55 | + price: float | None = None # $ CAD affiché | |
| 56 | + price_label: str = "" # texte original (ex. "21 495 $ + tx") | |
| 57 | + mileage_km: float | None = None | |
| 58 | + mileage_label: str = "" # texte original (ex. "45 678 km") | |
| 59 | + transmission: str = "" # Automatique | Manuelle | |
| 60 | + fuel: str = "" # Essence | Diesel | Hybride | Hybride rechargeable | Électrique | |
| 61 | + drivetrain: str = "" # Traction | Propulsion | Intégrale / 4x4 | |
| 62 | + body_type: str = "" # VUS | Berline | Camionnette | ... | |
| 63 | + exterior_color: str = "" | |
| 64 | + interior_color: str = "" | |
| 65 | + engine: str = "" # ex. "2.5L 4 cyl." | |
| 66 | + doors: int | None = None | |
| 67 | + seats: int | None = None | |
| 68 | + vin: str = "" | |
| 69 | + stock_number: str = "" | |
| 70 | + dealer_name: str = "" # nom d'affichage du commerce | |
| 71 | + city: str = "" # ville du concessionnaire | |
| 72 | + region: str = "" # région administrative (déduite de city) | |
| 73 | + description: str = "" | |
| 74 | + features: list[str] = field(default_factory=list) # équipements (texte source) | |
| 75 | + details: dict = field(default_factory=dict) # champs structurés (JSON) | |
| 76 | + images: list[str] = field(default_factory=list) # URLs absolues | |
| 77 | + carfax_url: str = "" | |
| 78 | + | |
| 79 | + @property | |
| 80 | + def uid(self) -> str: | |
| 81 | + return f"{self.source}:{self.external_id}" | |
| 82 | + | |
| 83 | + def content_hash(self) -> str: | |
| 84 | + """Hash du contenu pour la détection de changements (pseudo-webhook).""" | |
| 85 | + payload = asdict(self) | |
| 86 | + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) | |
| 87 | + return hashlib.sha256(blob.encode("utf-8")).hexdigest() | |
| 88 | + | |
| 89 | + def finalize(self) -> "Vehicle": | |
| 90 | + """Applique la normalisation commune. Appelé par le pipeline d'ingestion. | |
| 91 | + | |
| 92 | + Idempotent ; ne remplace jamais une valeur explicite du connecteur. | |
| 93 | + """ | |
| 94 | + self.title = " ".join((self.title or "").split()) | |
| 95 | + | |
| 96 | + # année / marque / modèle déduits du titre si absents | |
| 97 | + t_year, t_make, t_model = split_title(self.title) | |
| 98 | + if self.year is None: | |
| 99 | + self.year = t_year | |
| 100 | + if not self.make: | |
| 101 | + self.make = t_make | |
| 102 | + if not self.model and t_model: | |
| 103 | + # retirer la version du modèle déduit si elle duplique trim | |
| 104 | + self.model = t_model.split(" ")[0] | |
| 105 | + self.make = normalize_make(self.make) | |
| 106 | + self.model = " ".join(self.model.split()) | |
| 107 | + | |
| 108 | + if self.price is None: | |
| 109 | + self.price = parse_price(self.price_label) | |
| 110 | + if self.mileage_km is None: | |
| 111 | + self.mileage_km = parse_mileage(self.mileage_label or self.description) | |
| 112 | + | |
| 113 | + self.transmission = normalize_transmission(self.transmission) or self.transmission | |
| 114 | + self.fuel = normalize_fuel(self.fuel) or self.fuel | |
| 115 | + self.drivetrain = normalize_drivetrain(self.drivetrain) or self.drivetrain | |
| 116 | + self.body_type = normalize_body(self.body_type) or self.body_type | |
| 117 | + | |
| 118 | + # replis conservateurs depuis le titre/la version quand la source | |
| 119 | + # n'expose pas le champ (jetons explicites seulement : AWD, PHEV…) | |
| 120 | + badge = f"{self.title} {self.trim}" | |
| 121 | + if not self.drivetrain: | |
| 122 | + from .normalize import infer_drivetrain_from_text | |
| 123 | + self.drivetrain = infer_drivetrain_from_text(badge) | |
| 124 | + if not self.fuel: | |
| 125 | + from .normalize import infer_fuel_from_text | |
| 126 | + self.fuel = infer_fuel_from_text(badge) | |
| 127 | + | |
| 128 | + if not self.region: | |
| 129 | + self.region = infer_region(self.city) | |
| 130 | + | |
| 131 | + if self.price_label and price_is_from(self.price_label): | |
| 132 | + self.details["price_from"] = True | |
| 133 | + | |
| 134 | + # année plausible seulement | |
| 135 | + if self.year is not None and not (1980 <= self.year <= 2027): | |
| 136 | + self.year = None | |
| 137 | + | |
| 138 | + # dédoublonner les images en préservant l'ordre | |
| 139 | + seen: set[str] = set() | |
| 140 | + self.images = [u for u in self.images | |
| 141 | + if u and not (u in seen or seen.add(u))] | |
| 142 | + return self | |
added
apps/auto/autoka/web.py
+295 −0
@@ -0,0 +1,295 @@ | ||
| 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 | + kind: str = "auto", | |
| 93 | + make: str | None = None, | |
| 94 | + model: str | None = None, | |
| 95 | + body_type: str | None = None, | |
| 96 | + fuel: str | None = None, | |
| 97 | + transmission: str | None = None, | |
| 98 | + drivetrain: str | None = None, | |
| 99 | + region: str | None = None, | |
| 100 | + city: str | None = None, | |
| 101 | + source: str | None = None, | |
| 102 | + year_min: int | None = None, | |
| 103 | + year_max: int | None = None, | |
| 104 | + price_min: float | None = None, | |
| 105 | + price_max: float | None = None, | |
| 106 | + km_max: float | None = None, | |
| 107 | + q: str | None = None, | |
| 108 | + sort: str = "price_asc", | |
| 109 | + active: int = 1, | |
| 110 | + limit: int = Query(60, le=500), | |
| 111 | + offset: int = 0, | |
| 112 | +): | |
| 113 | + con = db.connect() | |
| 114 | + sql = "SELECT * FROM vehicles WHERE 1=1" | |
| 115 | + args: list = [] | |
| 116 | + if active in (0, 1): | |
| 117 | + sql += " AND active=?"; args.append(active) | |
| 118 | + if kind and kind != "tous": | |
| 119 | + sql += " AND kind=?"; args.append(kind) | |
| 120 | + sql = _apply_filters(sql, args, make=make, model=model, body_type=body_type, | |
| 121 | + fuel=fuel, transmission=transmission, | |
| 122 | + drivetrain=drivetrain, region=region, city=city, | |
| 123 | + source=source, year_min=year_min, year_max=year_max, | |
| 124 | + price_min=price_min, price_max=price_max, | |
| 125 | + km_max=km_max, q=q) | |
| 126 | + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] | |
| 127 | + sql += f" ORDER BY {_SORTS.get(sort, _SORTS['price_asc'])} LIMIT ? OFFSET ?" | |
| 128 | + args += [limit, offset] | |
| 129 | + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] | |
| 130 | + con.close() | |
| 131 | + return {"total": total, "count": len(rows), "vehicles": rows} | |
| 132 | + | |
| 133 | + | |
| 134 | +@app.get("/api/vehicles/{uid}") | |
| 135 | +def get_vehicle(uid: str): | |
| 136 | + con = db.connect() | |
| 137 | + row = con.execute("SELECT * FROM vehicles WHERE uid=?", (uid,)).fetchone() | |
| 138 | + d = None | |
| 139 | + if row is not None: | |
| 140 | + d = _row_to_dict(row) | |
| 141 | + d["price_history"] = [dict(r) for r in con.execute( | |
| 142 | + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10", | |
| 143 | + (uid,)).fetchall()] | |
| 144 | + # véhicules similaires : même marque+modèle, autres sources incluses | |
| 145 | + if d.get("make") and d.get("model"): | |
| 146 | + base_model = d["model"].split()[0] | |
| 147 | + d["similar"] = [_row_to_dict(r) for r in con.execute( | |
| 148 | + "SELECT * FROM vehicles WHERE active=1 AND make=? AND model LIKE ?" | |
| 149 | + " AND uid<>? ORDER BY price IS NULL, price ASC LIMIT 6", | |
| 150 | + (d["make"], f"{base_model}%", uid)).fetchall()] | |
| 151 | + else: | |
| 152 | + d["similar"] = [] | |
| 153 | + con.close() | |
| 154 | + if d is None: | |
| 155 | + raise HTTPException(404, "Véhicule introuvable") | |
| 156 | + return d | |
| 157 | + | |
| 158 | + | |
| 159 | +@app.get("/api/facets") | |
| 160 | +def facets(make: str | None = None, kind: str = "auto"): | |
| 161 | + """Valeurs distinctes pour construire les filtres du frontend. | |
| 162 | + | |
| 163 | + `make` (optionnel) restreint la liste des modèles à cette marque — | |
| 164 | + utilisé par le sélecteur « Modèle » dépendant de « Marque ». | |
| 165 | + """ | |
| 166 | + con = db.connect() | |
| 167 | + kf = "" if kind in ("", "tous") else f" AND kind='{'moto' if kind=='moto' else 'scooter' if kind=='scooter' else 'auto'}'" | |
| 168 | + model_sql = ("SELECT model, COUNT(*) n FROM vehicles" | |
| 169 | + " WHERE active=1 AND model<>''" + kf) | |
| 170 | + model_args: list = [] | |
| 171 | + if make: | |
| 172 | + model_sql += " AND make=?" | |
| 173 | + model_args.append(make) | |
| 174 | + out = { | |
| 175 | + "makes": [dict(r) for r in con.execute( | |
| 176 | + "SELECT make, COUNT(*) n FROM vehicles WHERE active=1" + kf + " AND make<>''" | |
| 177 | + " GROUP BY make ORDER BY n DESC")], | |
| 178 | + "models": [dict(r) for r in con.execute( | |
| 179 | + model_sql + " GROUP BY model ORDER BY n DESC LIMIT 80", model_args)], | |
| 180 | + "body_types": [r["body_type"] for r in con.execute( | |
| 181 | + "SELECT DISTINCT body_type FROM vehicles WHERE active=1" + kf + "" | |
| 182 | + " AND body_type<>'' ORDER BY body_type")], | |
| 183 | + "fuels": [r["fuel"] for r in con.execute( | |
| 184 | + "SELECT DISTINCT fuel FROM vehicles WHERE active=1" + kf + " AND fuel<>''" | |
| 185 | + " ORDER BY fuel")], | |
| 186 | + "regions": [dict(r) for r in con.execute( | |
| 187 | + "SELECT region, COUNT(*) n FROM vehicles WHERE active=1" + kf + " AND region<>''" | |
| 188 | + " GROUP BY region ORDER BY n DESC")], | |
| 189 | + "sources": [dict(r) for r in con.execute( | |
| 190 | + "SELECT source, dealer_name, COUNT(*) n FROM vehicles WHERE active=1" + kf + "" | |
| 191 | + " GROUP BY source ORDER BY n DESC")], | |
| 192 | + "years": [dict(r) for r in con.execute( | |
| 193 | + "SELECT MIN(year) y_min, MAX(year) y_max FROM vehicles" | |
| 194 | + " WHERE active=1" + kf + " AND year IS NOT NULL")], | |
| 195 | + } | |
| 196 | + con.close() | |
| 197 | + return out | |
| 198 | + | |
| 199 | + | |
| 200 | +@app.get("/api/sources") | |
| 201 | +def sources(): | |
| 202 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | |
| 203 | + con = db.connect() | |
| 204 | + counts = {r["source"]: r["n"] for r in con.execute( | |
| 205 | + "SELECT source, COUNT(*) n FROM vehicles WHERE active=1 GROUP BY source")} | |
| 206 | + last = {r["source"]: r["ts"] for r in con.execute( | |
| 207 | + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} | |
| 208 | + con.close() | |
| 209 | + for s in registry: | |
| 210 | + s["active_listings"] = counts.get(s["id"], 0) | |
| 211 | + s["last_sync"] = last.get(s["id"]) | |
| 212 | + return {"sources": registry} | |
| 213 | + | |
| 214 | + | |
| 215 | +@app.get("/api/stats") | |
| 216 | +def stats(): | |
| 217 | + con = db.connect() | |
| 218 | + row = con.execute( | |
| 219 | + """SELECT COUNT(*) total, | |
| 220 | + COUNT(DISTINCT source) sources, | |
| 221 | + COUNT(DISTINCT region) regions, | |
| 222 | + AVG(price) avg_price, | |
| 223 | + AVG(mileage_km) avg_km, | |
| 224 | + AVG(year) avg_year | |
| 225 | + FROM vehicles WHERE active=1 AND kind='auto'""").fetchone() | |
| 226 | + by_region = [dict(r) for r in con.execute( | |
| 227 | + "SELECT region, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles" | |
| 228 | + " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region ORDER BY n DESC")] | |
| 229 | + by_make = [dict(r) for r in con.execute( | |
| 230 | + "SELECT make, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles" | |
| 231 | + " WHERE active=1 AND kind='auto' AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")] | |
| 232 | + by_body = [dict(r) for r in con.execute( | |
| 233 | + "SELECT body_type, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 234 | + " AND body_type<>'' GROUP BY body_type ORDER BY n DESC")] | |
| 235 | + # baisses de prix récentes (signal d'aubaine) | |
| 236 | + drops = [dict(r) for r in con.execute( | |
| 237 | + """SELECT v.uid, v.title, v.year, v.price, v.images, v.dealer_name, v.city, | |
| 238 | + p.prev_price | |
| 239 | + FROM vehicles v JOIN ( | |
| 240 | + SELECT uid, price prev_price, | |
| 241 | + ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn | |
| 242 | + FROM price_log) p ON p.uid=v.uid AND p.rn=2 | |
| 243 | + WHERE v.active=1 AND kind='auto' AND v.price IS NOT NULL AND p.prev_price > v.price | |
| 244 | + ORDER BY (p.prev_price - v.price) DESC LIMIT 12""")] | |
| 245 | + for d in drops: | |
| 246 | + d["images"] = json.loads(d.get("images") or "[]")[:1] | |
| 247 | + log = [dict(r) for r in con.execute( | |
| 248 | + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 249 | + con.close() | |
| 250 | + return {**dict(row), "by_region": by_region, "by_make": by_make, | |
| 251 | + "by_body": by_body, "price_drops": drops, "recent_syncs": log} | |
| 252 | + | |
| 253 | + | |
| 254 | +@app.get("/api/stats/detailed") | |
| 255 | +def stats_detailed(): | |
| 256 | + """Agrégats complets du marché (source unique : autoka/marketstats.py).""" | |
| 257 | + from . import marketstats | |
| 258 | + return marketstats.compute() | |
| 259 | + | |
| 260 | + | |
| 261 | +@app.get("/api/stats/rapport.pdf") | |
| 262 | +def rapport_pdf(): | |
| 263 | + """Rapport PDF « Le marché de l'occasion » — vue d'ensemble.""" | |
| 264 | + from fastapi.responses import Response | |
| 265 | + from . import pdfgen | |
| 266 | + return Response( | |
| 267 | + content=pdfgen.rapport_pdf(), media_type="application/pdf", | |
| 268 | + headers={"Content-Disposition": | |
| 269 | + 'attachment; filename="auto-ka-rapport-marche.pdf"'}) | |
| 270 | + | |
| 271 | + | |
| 272 | +@app.post("/api/sync") | |
| 273 | +def trigger_sync(background: BackgroundTasks, source: str | None = None): | |
| 274 | + """Déclenche une synchronisation (équivalent d'un webhook entrant).""" | |
| 275 | + def _job(): | |
| 276 | + with _sync_lock: | |
| 277 | + ingest.run([source] if source else None) | |
| 278 | + background.add_task(_job) | |
| 279 | + return {"status": "démarré", "source": source or "toutes"} | |
| 280 | + | |
| 281 | + | |
| 282 | +# --- Frontend React (build Vite) -------------------------------------------- | |
| 283 | +if FRONTEND_DIST.exists(): | |
| 284 | + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") | |
| 285 | + | |
| 286 | + @app.get("/{full_path:path}") | |
| 287 | + def spa(full_path: str): | |
| 288 | + target = FRONTEND_DIST / full_path | |
| 289 | + if full_path and target.is_file(): | |
| 290 | + return FileResponse(target) | |
| 291 | + # index.html jamais mis en cache : les téléphones reçoivent toujours | |
| 292 | + # la dernière version (les bundles /assets sont hachés, eux se cachent) | |
| 293 | + return FileResponse( | |
| 294 | + FRONTEND_DIST / "index.html", | |
| 295 | + headers={"Cache-Control": "no-cache, must-revalidate"}) | |
added
apps/auto/data/sources.json
+1536 −0
@@ -0,0 +1,1536 @@ | ||
| 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 | + "id": "dupontautomobile", | |
| 1106 | + "name": "Dupont Automobile", | |
| 1107 | + "url": "https://www.dupontautomobile.com", | |
| 1108 | + "listing_url": "https://www.dupontautomobile.com/occasion/recherche.html", | |
| 1109 | + "city": "Alma", | |
| 1110 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 1111 | + "platform": "D2C Media", | |
| 1112 | + "connector": "d2c_dealers", | |
| 1113 | + "status": "actif" | |
| 1114 | + }, | |
| 1115 | + { | |
| 1116 | + "id": "almahonda", | |
| 1117 | + "name": "Alma Honda", | |
| 1118 | + "url": "https://www.almahonda.com", | |
| 1119 | + "listing_url": "https://www.almahonda.com/occasion/recherche.html", | |
| 1120 | + "city": "Alma", | |
| 1121 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 1122 | + "platform": "D2C Media", | |
| 1123 | + "connector": "d2c_dealers", | |
| 1124 | + "status": "actif" | |
| 1125 | + }, | |
| 1126 | + { | |
| 1127 | + "id": "hondanewrichmond", | |
| 1128 | + "name": "Honda New Richmond", | |
| 1129 | + "url": "https://www.hondanewrichmond.com", | |
| 1130 | + "listing_url": "https://www.hondanewrichmond.com/occasion/recherche.html", | |
| 1131 | + "city": "New Richmond", | |
| 1132 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 1133 | + "platform": "D2C Media", | |
| 1134 | + "connector": "d2c_dealers", | |
| 1135 | + "status": "actif" | |
| 1136 | + }, | |
| 1137 | + { | |
| 1138 | + "id": "olivierhyundaibc", | |
| 1139 | + "name": "Olivier Hyundai Baie-Comeau", | |
| 1140 | + "url": "https://www.olivierhyundaibaiecomeau.com", | |
| 1141 | + "listing_url": "https://www.olivierhyundaibaiecomeau.com/occasion/recherche.html", | |
| 1142 | + "city": "Baie-Comeau", | |
| 1143 | + "region": "Côte-Nord", | |
| 1144 | + "platform": "D2C Media", | |
| 1145 | + "connector": "d2c_dealers", | |
| 1146 | + "status": "actif" | |
| 1147 | + }, | |
| 1148 | + { | |
| 1149 | + "id": "olivieroccasionseptiles", | |
| 1150 | + "name": "Olivier Occasion Sept-Îles", | |
| 1151 | + "url": "https://www.olivieroccasionseptiles.com", | |
| 1152 | + "listing_url": "https://www.olivieroccasionseptiles.com/occasion/recherche.html", | |
| 1153 | + "city": "Sept-Îles", | |
| 1154 | + "region": "Côte-Nord", | |
| 1155 | + "platform": "D2C Media (inventaire commun aux 4 sites Olivier de Sept-Îles : Ford/Nissan/Chrysler/Occasion — un seul connecté pour éviter les doublons)", | |
| 1156 | + "connector": "d2c_dealers", | |
| 1157 | + "status": "actif" | |
| 1158 | + }, | |
| 1159 | + { | |
| 1160 | + "id": "charlesbourgtoyota", | |
| 1161 | + "name": "Charlesbourg Toyota", | |
| 1162 | + "url": "https://www.charlesbourgtoyota.com", | |
| 1163 | + "listing_url": "https://www.charlesbourgtoyota.com/occasion/recherche.html", | |
| 1164 | + "city": "Québec", | |
| 1165 | + "region": "Capitale-Nationale", | |
| 1166 | + "platform": "D2C Media", | |
| 1167 | + "connector": "d2c_dealers", | |
| 1168 | + "status": "actif" | |
| 1169 | + }, | |
| 1170 | + { | |
| 1171 | + "id": "hondacharlesbourg", | |
| 1172 | + "name": "Honda Charlesbourg", | |
| 1173 | + "url": "https://www.hondacharlesbourg.com", | |
| 1174 | + "listing_url": "https://www.hondacharlesbourg.com/occasion/recherche.html", | |
| 1175 | + "city": "Québec", | |
| 1176 | + "region": "Capitale-Nationale", | |
| 1177 | + "platform": "D2C Media", | |
| 1178 | + "connector": "d2c_dealers", | |
| 1179 | + "status": "actif" | |
| 1180 | + }, | |
| 1181 | + { | |
| 1182 | + "id": "kiabeauport", | |
| 1183 | + "name": "Kia Beauport", | |
| 1184 | + "url": "https://www.kiabeauport.com", | |
| 1185 | + "listing_url": "https://www.kiabeauport.com/occasion/recherche.html", | |
| 1186 | + "city": "Québec", | |
| 1187 | + "region": "Capitale-Nationale", | |
| 1188 | + "platform": "D2C Media", | |
| 1189 | + "connector": "d2c_dealers", | |
| 1190 | + "status": "actif" | |
| 1191 | + }, | |
| 1192 | + { | |
| 1193 | + "id": "hondacharlevoix", | |
| 1194 | + "name": "Honda Charlevoix", | |
| 1195 | + "url": "https://www.hondacharlevoix.com", | |
| 1196 | + "listing_url": "https://www.hondacharlevoix.com/occasion/recherche.html", | |
| 1197 | + "city": "La Malbaie", | |
| 1198 | + "region": "Capitale-Nationale", | |
| 1199 | + "platform": "D2C Media", | |
| 1200 | + "connector": "d2c_dealers", | |
| 1201 | + "status": "actif" | |
| 1202 | + }, | |
| 1203 | + { | |
| 1204 | + "id": "hyundaicharlevoix", | |
| 1205 | + "name": "Hyundai Charlevoix", | |
| 1206 | + "url": "https://www.hyundaicharlevoix.com", | |
| 1207 | + "listing_url": "https://www.hyundaicharlevoix.com/occasion/recherche.html", | |
| 1208 | + "city": "Baie-Saint-Paul", | |
| 1209 | + "region": "Capitale-Nationale", | |
| 1210 | + "platform": "D2C Media", | |
| 1211 | + "connector": "d2c_dealers", | |
| 1212 | + "status": "actif" | |
| 1213 | + }, | |
| 1214 | + { | |
| 1215 | + "id": "charlevoixtoyota", | |
| 1216 | + "name": "Charlevoix Toyota", | |
| 1217 | + "url": "https://www.charlevoixtoyota.com", | |
| 1218 | + "listing_url": "https://www.charlevoixtoyota.com/occasion/recherche.html", | |
| 1219 | + "city": "La Malbaie", | |
| 1220 | + "region": "Capitale-Nationale", | |
| 1221 | + "platform": "D2C Media", | |
| 1222 | + "connector": "d2c_dealers", | |
| 1223 | + "status": "actif" | |
| 1224 | + }, | |
| 1225 | + { | |
| 1226 | + "id": "autovilleneuve", | |
| 1227 | + "name": "Automobile Villeneuve Amqui", | |
| 1228 | + "url": "https://www.autovilleneuve.com", | |
| 1229 | + "listing_url": "https://www.autovilleneuve.com/occasion/recherche.html", | |
| 1230 | + "city": "Amqui", | |
| 1231 | + "region": "Bas-Saint-Laurent", | |
| 1232 | + "platform": "D2C Media (petit inventaire ~5-10 véhicules)", | |
| 1233 | + "connector": "d2c_dealers", | |
| 1234 | + "status": "actif" | |
| 1235 | + }, | |
| 1236 | + { | |
| 1237 | + "id": "septileshonda", | |
| 1238 | + "name": "Sept-Îles Honda", | |
| 1239 | + "url": "https://www.septileshonda.com", | |
| 1240 | + "listing_url": "https://www.septileshonda.com/fr/inventaire-occasion", | |
| 1241 | + "city": "Sept-Îles", | |
| 1242 | + "region": "Côte-Nord", | |
| 1243 | + "platform": "sm360", | |
| 1244 | + "connector": "sm360_dealers", | |
| 1245 | + "status": "actif" | |
| 1246 | + }, | |
| 1247 | + { | |
| 1248 | + "id": "septilesgm", | |
| 1249 | + "name": "Sept-Îles Chevrolet Buick GMC", | |
| 1250 | + "url": "https://www.sept-ilesgm.com", | |
| 1251 | + "listing_url": "https://www.sept-ilesgm.com/fr/inventaire-occasion", | |
| 1252 | + "city": "Sept-Îles", | |
| 1253 | + "region": "Côte-Nord", | |
| 1254 | + "platform": "sm360", | |
| 1255 | + "connector": "sm360_dealers", | |
| 1256 | + "status": "actif" | |
| 1257 | + }, | |
| 1258 | + { | |
| 1259 | + "id": "baiecomeaugm", | |
| 1260 | + "name": "Baie-Comeau Chevrolet Buick GMC", | |
| 1261 | + "url": "https://www.baiecomeaugm.com", | |
| 1262 | + "listing_url": "https://www.baiecomeaugm.com/fr/inventaire-occasion", | |
| 1263 | + "city": "Baie-Comeau", | |
| 1264 | + "region": "Côte-Nord", | |
| 1265 | + "platform": "sm360", | |
| 1266 | + "connector": "sm360_dealers", | |
| 1267 | + "status": "actif" | |
| 1268 | + }, | |
| 1269 | + { | |
| 1270 | + "id": "volvosaintleonard", | |
| 1271 | + "name": "Volvo Cars Saint-Léonard", | |
| 1272 | + "url": "https://www.volvocarssaintleonard.com", | |
| 1273 | + "listing_url": "https://www.volvocarssaintleonard.com/fr/inventaire-occasion", | |
| 1274 | + "city": "Montréal", | |
| 1275 | + "region": "Montréal", | |
| 1276 | + "platform": "sm360", | |
| 1277 | + "connector": "sm360_dealers", | |
| 1278 | + "status": "actif" | |
| 1279 | + }, | |
| 1280 | + { | |
| 1281 | + "id": "kiamatane", | |
| 1282 | + "name": "Kia Matane", | |
| 1283 | + "url": "https://www.kiamatane.com", | |
| 1284 | + "listing_url": "https://www.kiamatane.com/fr/inventaire-occasion", | |
| 1285 | + "city": "Matane", | |
| 1286 | + "region": "Bas-Saint-Laurent", | |
| 1287 | + "platform": "sm360", | |
| 1288 | + "connector": "sm360_dealers", | |
| 1289 | + "status": "actif" | |
| 1290 | + }, | |
| 1291 | + { | |
| 1292 | + "id": "marquisautomobiles", | |
| 1293 | + "name": "Marquis Automobiles", | |
| 1294 | + "url": "https://www.marquisautomobiles.com", | |
| 1295 | + "listing_url": "https://www.marquisautomobiles.com/fr/inventaire-occasion", | |
| 1296 | + "city": "Matane", | |
| 1297 | + "region": "Bas-Saint-Laurent", | |
| 1298 | + "platform": "sm360", | |
| 1299 | + "connector": "sm360_dealers", | |
| 1300 | + "status": "actif" | |
| 1301 | + }, | |
| 1302 | + { | |
| 1303 | + "id": "villeneuveford", | |
| 1304 | + "name": "Villeneuve Ford", | |
| 1305 | + "url": "https://www.villeneuveford.com", | |
| 1306 | + "listing_url": "https://www.villeneuveford.com/fr/inventaire-occasion", | |
| 1307 | + "city": "Matane", | |
| 1308 | + "region": "Bas-Saint-Laurent", | |
| 1309 | + "platform": "sm360", | |
| 1310 | + "connector": "sm360_dealers", | |
| 1311 | + "status": "actif" | |
| 1312 | + }, | |
| 1313 | + { | |
| 1314 | + "id": "autobsl", | |
| 1315 | + "name": "Auto BSL", | |
| 1316 | + "url": "https://www.autobsl.com", | |
| 1317 | + "listing_url": "https://www.autobsl.com/inventaire/", | |
| 1318 | + "city": "Rimouski", | |
| 1319 | + "region": "Bas-Saint-Laurent", | |
| 1320 | + "platform": "AMVOQ Focus 360 (theme_central)", | |
| 1321 | + "connector": "central_dealers", | |
| 1322 | + "status": "actif" | |
| 1323 | + }, | |
| 1324 | + { | |
| 1325 | + "id": "autoxtreme", | |
| 1326 | + "name": "Auto Xtrême", | |
| 1327 | + "url": "https://www.autoxtreme.ca", | |
| 1328 | + "listing_url": "https://www.autoxtreme.ca/inventaire/", | |
| 1329 | + "city": "Montréal", | |
| 1330 | + "region": "Montréal", | |
| 1331 | + "platform": "AMVOQ Focus 360 (theme_central)", | |
| 1332 | + "connector": "central_dealers", | |
| 1333 | + "status": "actif" | |
| 1334 | + }, | |
| 1335 | + { | |
| 1336 | + "id": "dastousauto", | |
| 1337 | + "name": "D'Astous Auto", | |
| 1338 | + "url": "https://www.dastousauto.com", | |
| 1339 | + "listing_url": "https://www.dastousauto.com/inventaire/", | |
| 1340 | + "city": "Montréal", | |
| 1341 | + "region": "Montréal", | |
| 1342 | + "platform": "AMVOQ Focus 360 (theme_central)", | |
| 1343 | + "connector": "central_dealers", | |
| 1344 | + "status": "actif" | |
| 1345 | + }, | |
| 1346 | + { | |
| 1347 | + "id": "montjolichrysler", | |
| 1348 | + "name": "Chrysler Mont-Joli", | |
| 1349 | + "url": "https://www.montjolichrysler.com", | |
| 1350 | + "listing_url": "https://www.montjolichrysler.com/auto-usage/", | |
| 1351 | + "city": "Mont-Joli", | |
| 1352 | + "region": "Bas-Saint-Laurent", | |
| 1353 | + "platform": "Next.js RSC autoroot.io / EvalAuto (pattern Occasion Beaucage)", | |
| 1354 | + "connector": "montjolichrysler", | |
| 1355 | + "status": "actif" | |
| 1356 | + }, | |
| 1357 | + { | |
| 1358 | + "id": "classeauto", | |
| 1359 | + "name": "Classe Auto", | |
| 1360 | + "url": "https://www.classeauto.ca", | |
| 1361 | + "listing_url": "https://www.classeauto.ca/en/cars", | |
| 1362 | + "city": "Montréal", | |
| 1363 | + "region": "Montréal", | |
| 1364 | + "platform": "WordPress Astra + DriveGood (export JSON statique car_single_page_data/cars_formatted.json — 1 requête pour tout l'inventaire)", | |
| 1365 | + "connector": "classeauto", | |
| 1366 | + "status": "actif" | |
| 1367 | + }, | |
| 1368 | + { | |
| 1369 | + "id": "occasioncharlevoix", | |
| 1370 | + "name": "Occasion Charlevoix", | |
| 1371 | + "url": "https://www.occasioncharlevoix.com", | |
| 1372 | + "listing_url": "https://www.occasioncharlevoix.com/fr/inventaire/occasion", | |
| 1373 | + "city": "Baie-Saint-Paul", | |
| 1374 | + "region": "Capitale-Nationale", | |
| 1375 | + "platform": "OctoberCMS thème nerd2 / NerdAuto (cartes liste rendues serveur + bloc Autoverify data-av-* ; pages détail pour specs/carfax)", | |
| 1376 | + "connector": "occasioncharlevoix", | |
| 1377 | + "status": "actif" | |
| 1378 | + }, | |
| 1379 | + { | |
| 1380 | + "id": "mathiassports", | |
| 1381 | + "name": "Mathias Sports", | |
| 1382 | + "url": "https://mathiassports.com", | |
| 1383 | + "listing_url": "https://mathiassports.com/fr/usage/motocyclette/", | |
| 1384 | + "city": "Saint-Mathias-sur-Richelieu", | |
| 1385 | + "region": "Montérégie", | |
| 1386 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1387 | + "connector": "moto_dealers", | |
| 1388 | + "vertical": "moto", | |
| 1389 | + "status": "actif" | |
| 1390 | + }, | |
| 1391 | + { | |
| 1392 | + "id": "smsport", | |
| 1393 | + "name": "SM Sport", | |
| 1394 | + "url": "https://smsport.ca", | |
| 1395 | + "listing_url": "https://smsport.ca/fr/usage/motocyclette/", | |
| 1396 | + "city": "Québec", | |
| 1397 | + "region": "Capitale-Nationale", | |
| 1398 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1399 | + "connector": "moto_dealers", | |
| 1400 | + "vertical": "moto", | |
| 1401 | + "status": "actif" | |
| 1402 | + }, | |
| 1403 | + { | |
| 1404 | + "id": "motosillimitees", | |
| 1405 | + "name": "Motos Illimitées", | |
| 1406 | + "url": "https://www.motosillimitees.com", | |
| 1407 | + "listing_url": "https://www.motosillimitees.com/fr/usage/motocyclette/", | |
| 1408 | + "city": "Terrebonne", | |
| 1409 | + "region": "Lanaudière", | |
| 1410 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1411 | + "connector": "moto_dealers", | |
| 1412 | + "vertical": "moto", | |
| 1413 | + "status": "actif" | |
| 1414 | + }, | |
| 1415 | + { | |
| 1416 | + "id": "rmmotosport", | |
| 1417 | + "name": "RM Motosport", | |
| 1418 | + "url": "https://www.rmmotosport.com", | |
| 1419 | + "listing_url": "https://www.rmmotosport.com/fr/usage/", | |
| 1420 | + "city": "Victoriaville", | |
| 1421 | + "region": "Centre-du-Québec", | |
| 1422 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1423 | + "connector": "moto_dealers", | |
| 1424 | + "vertical": "moto", | |
| 1425 | + "status": "actif" | |
| 1426 | + }, | |
| 1427 | + { | |
| 1428 | + "id": "glsport", | |
| 1429 | + "name": "GL Sport", | |
| 1430 | + "url": "https://www.glsport.ca", | |
| 1431 | + "listing_url": "https://www.glsport.ca/fr/usage/", | |
| 1432 | + "city": "Saint-Gervais", | |
| 1433 | + "region": "Chaudière-Appalaches", | |
| 1434 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1435 | + "connector": "moto_dealers", | |
| 1436 | + "vertical": "moto", | |
| 1437 | + "status": "actif" | |
| 1438 | + }, | |
| 1439 | + { | |
| 1440 | + "id": "excelmoto", | |
| 1441 | + "name": "Excel Moto", | |
| 1442 | + "url": "https://www.excelmoto.com", | |
| 1443 | + "listing_url": "https://www.excelmoto.com/fr/usage/", | |
| 1444 | + "city": "Montréal", | |
| 1445 | + "region": "Montréal", | |
| 1446 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1447 | + "connector": "moto_dealers", | |
| 1448 | + "vertical": "moto", | |
| 1449 | + "status": "actif" | |
| 1450 | + }, | |
| 1451 | + { | |
| 1452 | + "id": "groupecontant", | |
| 1453 | + "name": "Groupe Contant", | |
| 1454 | + "url": "https://www.contant.ca", | |
| 1455 | + "listing_url": "https://www.contant.ca/fr/usage/", | |
| 1456 | + "city": "Repentigny", | |
| 1457 | + "region": "Lanaudière", | |
| 1458 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; multi-succursales (Mirabel, Repentigny, Vaudreuil, Beloeil, Ste-Agathe) — ville par véhicule via location", | |
| 1459 | + "connector": "moto_dealers", | |
| 1460 | + "vertical": "moto", | |
| 1461 | + "status": "actif" | |
| 1462 | + }, | |
| 1463 | + { | |
| 1464 | + "id": "gabrielharleydavidson", | |
| 1465 | + "name": "Gabriel Harley-Davidson Montréal", | |
| 1466 | + "url": "https://gabrielharleydavidsonmtl.com", | |
| 1467 | + "listing_url": "https://gabrielharleydavidsonmtl.com/fr/usage/motocyclette/", | |
| 1468 | + "city": "Montréal", | |
| 1469 | + "region": "Montréal", | |
| 1470 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; JSON-LD dans un @graph", | |
| 1471 | + "connector": "moto_dealers", | |
| 1472 | + "vertical": "moto", | |
| 1473 | + "status": "actif" | |
| 1474 | + }, | |
| 1475 | + { | |
| 1476 | + "id": "stjeromeharleydavidson", | |
| 1477 | + "name": "St-Jérôme Harley-Davidson", | |
| 1478 | + "url": "https://stjeromeharleydavidson.com", | |
| 1479 | + "listing_url": "https://stjeromeharleydavidson.com/fr/usage/motocyclette/", | |
| 1480 | + "city": "Saint-Jérôme", | |
| 1481 | + "region": "Laurentides", | |
| 1482 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; JSON-LD dans un @graph", | |
| 1483 | + "connector": "moto_dealers", | |
| 1484 | + "vertical": "moto", | |
| 1485 | + "status": "actif" | |
| 1486 | + }, | |
| 1487 | + { | |
| 1488 | + "id": "mecamoto", | |
| 1489 | + "name": "Mecamoto", | |
| 1490 | + "url": "https://www.mecamoto.ca", | |
| 1491 | + "listing_url": "https://www.mecamoto.ca/fr/usage/", | |
| 1492 | + "city": "Montréal", | |
| 1493 | + "region": "Montréal", | |
| 1494 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; spécialiste Vespa/Piaggio/Moto Guzzi — scooters usagés en catégorie Cyclomoteurs", | |
| 1495 | + "connector": "moto_dealers", | |
| 1496 | + "vertical": "moto", | |
| 1497 | + "status": "actif" | |
| 1498 | + }, | |
| 1499 | + { | |
| 1500 | + "id": "motosthibaulttr", | |
| 1501 | + "name": "Motos Thibault Trois-Rivières", | |
| 1502 | + "url": "https://www.motosthibault.ca", | |
| 1503 | + "listing_url": "https://www.motosthibault.ca/fr/usage/", | |
| 1504 | + "city": "Trois-Rivières", | |
| 1505 | + "region": "Mauricie", | |
| 1506 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail)", | |
| 1507 | + "connector": "moto_dealers", | |
| 1508 | + "vertical": "moto", | |
| 1509 | + "status": "actif" | |
| 1510 | + }, | |
| 1511 | + { | |
| 1512 | + "id": "groupeimperium", | |
| 1513 | + "name": "Groupe Imperium", | |
| 1514 | + "url": "https://www.groupeimperium.ca", | |
| 1515 | + "listing_url": "https://www.groupeimperium.ca/fr/usage/", | |
| 1516 | + "city": "Chicoutimi", | |
| 1517 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 1518 | + "platform": "PowerGo (JSON inventaire public /data/vehicles-inventory-fr-items.json + JSON-LD Vehicle des pages détail) ; multi-succursales (Chicoutimi, Dolbeau-Mistassini, Chibougamau, Rouyn-Noranda) — ville par véhicule via location", | |
| 1519 | + "connector": "moto_dealers", | |
| 1520 | + "vertical": "moto", | |
| 1521 | + "status": "actif" | |
| 1522 | + }, | |
| 1523 | + { | |
| 1524 | + "id": "motoducharme", | |
| 1525 | + "name": "Moto Ducharme", | |
| 1526 | + "url": "https://www.motoducharme.com", | |
| 1527 | + "listing_url": "https://www.motoducharme.com/fr/vehicules-d-occasion/motocyclettes", | |
| 1528 | + "city": "Joliette", | |
| 1529 | + "region": "Lanaudière", | |
| 1530 | + "platform": "Magento thème Traction (listing motos usagées server-rendered ?p=N ; détail : JSON-LD Product + table de spécifications Kilométrage/Couleur + galerie JSON Magento)", | |
| 1531 | + "connector": "moto_dealers", | |
| 1532 | + "vertical": "moto", | |
| 1533 | + "status": "actif" | |
| 1534 | + } | |
| 1535 | + ] | |
| 1536 | +} | |
added
apps/auto/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
apps/auto/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
apps/auto/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
apps/auto/frontend/src/App.tsx
+132 −0
@@ -0,0 +1,132 @@ | ||
| 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 | + Autos | |
| 62 | + </NavLink> | |
| 63 | + <NavLink to="/motos" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 64 | + Motos | |
| 65 | + </NavLink> | |
| 66 | + <NavLink to="/scooters" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 67 | + Scooters | |
| 68 | + </NavLink> | |
| 69 | + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 70 | + Stats | |
| 71 | + </NavLink> | |
| 72 | + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 73 | + Sources | |
| 74 | + </NavLink> | |
| 75 | + </nav> | |
| 76 | + </div> | |
| 77 | + </header> | |
| 78 | + <Ticker /> | |
| 79 | + </> | |
| 80 | + ); | |
| 81 | +} | |
| 82 | + | |
| 83 | +function Footer() { | |
| 84 | + return ( | |
| 85 | + <footer className="footer"> | |
| 86 | + <div className="container"> | |
| 87 | + <div className="fbrand"> | |
| 88 | + Auto<span className="ka">Ka</span> | |
| 89 | + </div> | |
| 90 | + <div className="frow"> | |
| 91 | + <b>Agrégateur indépendant</b> de voitures usagées à vendre dans la | |
| 92 | + province de Québec. Les annonces proviennent directement des sites | |
| 93 | + publics des concessionnaires — pas des plateformes d'annonces — et | |
| 94 | + sont rafraîchies automatiquement. Chaque fiche renvoie vers l'annonce | |
| 95 | + originale du commerçant. | |
| 96 | + </div> | |
| 97 | + <div className="fmono"> | |
| 98 | + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </footer> | |
| 102 | + ); | |
| 103 | +} | |
| 104 | + | |
| 105 | +export default function App() { | |
| 106 | + return ( | |
| 107 | + <> | |
| 108 | + <Header /> | |
| 109 | + <main> | |
| 110 | + <Routes> | |
| 111 | + <Route path="/" element={<Home kind="auto" />} /> | |
| 112 | + <Route path="/motos" element={<Home kind="moto" />} /> | |
| 113 | + <Route path="/scooters" element={<Home kind="scooter" />} /> | |
| 114 | + <Route path="/vehicule/:uid" element={<VehiclePage />} /> | |
| 115 | + <Route path="/stats" element={<StatsPage />} /> | |
| 116 | + <Route path="/sources" element={<SourcesPage />} /> | |
| 117 | + <Route | |
| 118 | + path="*" | |
| 119 | + element={ | |
| 120 | + <div className="notice container"> | |
| 121 | + <div className="big">🧭</div> | |
| 122 | + <h2>Page introuvable</h2> | |
| 123 | + <p>Le lien demandé n'existe pas.</p> | |
| 124 | + </div> | |
| 125 | + } | |
| 126 | + /> | |
| 127 | + </Routes> | |
| 128 | + </main> | |
| 129 | + <Footer /> | |
| 130 | + </> | |
| 131 | + ); | |
| 132 | +} | |
added
apps/auto/frontend/src/api.ts
+179 −0
@@ -0,0 +1,179 @@ | ||
| 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 | + kind: string; | |
| 10 | + source: string; | |
| 11 | + external_id: string; | |
| 12 | + url: string; | |
| 13 | + title: string; | |
| 14 | + make: string; | |
| 15 | + model: string; | |
| 16 | + trim: string; | |
| 17 | + year: number | null; | |
| 18 | + price: number | null; | |
| 19 | + price_label: string; | |
| 20 | + mileage_km: number | null; | |
| 21 | + mileage_label: string; | |
| 22 | + transmission: string; | |
| 23 | + fuel: string; | |
| 24 | + drivetrain: string; | |
| 25 | + body_type: string; | |
| 26 | + exterior_color: string; | |
| 27 | + interior_color: string; | |
| 28 | + engine: string; | |
| 29 | + doors: number | null; | |
| 30 | + seats: number | null; | |
| 31 | + vin: string; | |
| 32 | + stock_number: string; | |
| 33 | + dealer_name: string; | |
| 34 | + city: string; | |
| 35 | + region: string; | |
| 36 | + description: string; | |
| 37 | + features: string[]; | |
| 38 | + details: Record<string, unknown>; | |
| 39 | + images: string[]; | |
| 40 | + carfax_url: string; | |
| 41 | + first_seen: number; | |
| 42 | + updated_at: number; | |
| 43 | + active: number; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export interface VehicleDetail extends Vehicle { | |
| 47 | + price_history: { ts: number; price: number | null }[]; | |
| 48 | + similar: Vehicle[]; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export interface Facets { | |
| 52 | + makes: { make: string; n: number }[]; | |
| 53 | + models: { model: string; n: number }[]; | |
| 54 | + body_types: string[]; | |
| 55 | + fuels: string[]; | |
| 56 | + regions: { region: string; n: number }[]; | |
| 57 | + sources: { source: string; dealer_name: string; n: number }[]; | |
| 58 | + years: { y_min: number | null; y_max: number | null }[]; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export interface Source { | |
| 62 | + id: string; | |
| 63 | + name: string; | |
| 64 | + url: string; | |
| 65 | + listing_url?: string; | |
| 66 | + city?: string; | |
| 67 | + region?: string; | |
| 68 | + platform?: string; | |
| 69 | + status?: string; | |
| 70 | + active_listings: number; | |
| 71 | + last_sync: number | null; | |
| 72 | +} | |
| 73 | + | |
| 74 | +export interface Stats { | |
| 75 | + total: number; | |
| 76 | + sources: number; | |
| 77 | + regions: number; | |
| 78 | + avg_price: number | null; | |
| 79 | + avg_km: number | null; | |
| 80 | + avg_year: number | null; | |
| 81 | + by_region: { region: string; n: number; avg_price: number | null }[]; | |
| 82 | + by_make: { make: string; n: number; avg_price: number | null }[]; | |
| 83 | + by_body: { body_type: string; n: number }[]; | |
| 84 | + price_drops: { | |
| 85 | + uid: string; title: string; year: number | null; price: number; | |
| 86 | + prev_price: number; images: string[]; dealer_name: string; city: string; | |
| 87 | + }[]; | |
| 88 | + recent_syncs: { | |
| 89 | + source: string; ts: number; found: number; added: number; | |
| 90 | + updated: number; removed: number; ok: number; message: string; | |
| 91 | + }[]; | |
| 92 | +} | |
| 93 | + | |
| 94 | +export interface VehicleQuery { | |
| 95 | + kind?: string; | |
| 96 | + make?: string; | |
| 97 | + model?: string; | |
| 98 | + body_type?: string; | |
| 99 | + fuel?: string; | |
| 100 | + transmission?: string; | |
| 101 | + drivetrain?: string; | |
| 102 | + region?: string; | |
| 103 | + source?: string; | |
| 104 | + year_min?: number; | |
| 105 | + year_max?: number; | |
| 106 | + price_min?: number; | |
| 107 | + price_max?: number; | |
| 108 | + km_max?: number; | |
| 109 | + q?: string; | |
| 110 | + sort?: string; | |
| 111 | + limit?: number; | |
| 112 | + offset?: number; | |
| 113 | +} | |
| 114 | + | |
| 115 | +// Préfixe de base (ex. "/auto") pour tous les appels API relatifs. | |
| 116 | +export const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); | |
| 117 | + | |
| 118 | +async function get<T>(path: string): Promise<T> { | |
| 119 | + const res = await fetch(API_BASE + path); | |
| 120 | + if (!res.ok) throw new Error(`API ${res.status}`); | |
| 121 | + return res.json(); | |
| 122 | +} | |
| 123 | + | |
| 124 | +export function fetchVehicles(query: VehicleQuery) { | |
| 125 | + const params = new URLSearchParams(); | |
| 126 | + for (const [k, v] of Object.entries(query)) { | |
| 127 | + if (v !== undefined && v !== null && v !== "") params.set(k, String(v)); | |
| 128 | + } | |
| 129 | + return get<{ total: number; count: number; vehicles: Vehicle[] }>( | |
| 130 | + `/api/vehicles?${params.toString()}` | |
| 131 | + ); | |
| 132 | +} | |
| 133 | + | |
| 134 | +export function fetchVehicle(uid: string) { | |
| 135 | + return get<VehicleDetail>(`/api/vehicles/${encodeURIComponent(uid)}`); | |
| 136 | +} | |
| 137 | + | |
| 138 | +export function fetchFacets(make?: string, kind?: string) { | |
| 139 | + const params = new URLSearchParams(); | |
| 140 | + if (make) params.set("make", make); | |
| 141 | + if (kind) params.set("kind", kind); | |
| 142 | + const qs = params.toString(); | |
| 143 | + return get<Facets>(`/api/facets${qs ? `?${qs}` : ""}`); | |
| 144 | +} | |
| 145 | + | |
| 146 | +export function fetchSources() { | |
| 147 | + return get<{ sources: Source[] }>("/api/sources"); | |
| 148 | +} | |
| 149 | + | |
| 150 | +export function fetchStats() { | |
| 151 | + return get<Stats>("/api/stats"); | |
| 152 | +} | |
| 153 | + | |
| 154 | +// -- noms d'affichage des sources (peuplés depuis /api/sources) --------------- | |
| 155 | +const names: Record<string, string> = {}; | |
| 156 | + | |
| 157 | +export function registerSourceNames(sources: Source[]) { | |
| 158 | + for (const s of sources) names[s.id] = s.name; | |
| 159 | +} | |
| 160 | + | |
| 161 | +export function sourceName(id: string): string { | |
| 162 | + return names[id] ?? id; | |
| 163 | +} | |
| 164 | + | |
| 165 | +// -- formatteurs --------------------------------------------------------------- | |
| 166 | +export const fmtPrice = (p: number | null | undefined) => | |
| 167 | + p == null ? "Prix sur demande" : `${Math.round(p).toLocaleString("fr-CA")} $`; | |
| 168 | + | |
| 169 | +export const fmtKm = (km: number | null | undefined) => | |
| 170 | + km == null ? "— km" : `${Math.round(km).toLocaleString("fr-CA")} km`; | |
| 171 | + | |
| 172 | +export const fmtDate = (ts: number | null | undefined) => | |
| 173 | + ts == null | |
| 174 | + ? "—" | |
| 175 | + : new Date(ts * 1000).toLocaleDateString("fr-CA", { | |
| 176 | + day: "numeric", | |
| 177 | + month: "short", | |
| 178 | + year: "numeric", | |
| 179 | + }); | |
added
apps/auto/frontend/src/components/Charts.tsx
+193 −0
@@ -0,0 +1,193 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// Charts.tsx : graphiques SVG maison — histogramme, barres horizontales, donut. | |
| 5 | +// Marques fines, bouts arrondis, étiquettes directes sélectives, infobulle | |
| 6 | +// au survol, vue tableau repliable (accessibilité). Palette catégorielle | |
| 7 | +// validée (contraste + daltonisme) : #d94f1e #1f6fb5 #1c7a4d #b58500 | |
| 8 | +// #7d4fc9 #a05a2c. | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { ReactNode, useState } from "react"; | |
| 11 | + | |
| 12 | +export const CAT_COLORS = ["#d94f1e", "#1f6fb5", "#1c7a4d", "#b58500", "#7d4fc9", "#a05a2c"]; | |
| 13 | + | |
| 14 | +export interface Datum { | |
| 15 | + label: string; | |
| 16 | + n: number; | |
| 17 | + extra?: string; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const fmt = (n: number) => Math.round(n).toLocaleString("fr-CA"); | |
| 21 | + | |
| 22 | +function DataTable({ rows, valueLabel }: { rows: Datum[]; valueLabel: string }) { | |
| 23 | + return ( | |
| 24 | + <details className="chart-data"> | |
| 25 | + <summary>Voir les données</summary> | |
| 26 | + <table> | |
| 27 | + <thead> | |
| 28 | + <tr><th>Catégorie</th><th>{valueLabel}</th></tr> | |
| 29 | + </thead> | |
| 30 | + <tbody> | |
| 31 | + {rows.map((r) => ( | |
| 32 | + <tr key={r.label}> | |
| 33 | + <td>{r.label}</td> | |
| 34 | + <td>{fmt(r.n)}{r.extra ? ` · ${r.extra}` : ""}</td> | |
| 35 | + </tr> | |
| 36 | + ))} | |
| 37 | + </tbody> | |
| 38 | + </table> | |
| 39 | + </details> | |
| 40 | + ); | |
| 41 | +} | |
| 42 | + | |
| 43 | +function Tip({ x, y, children }: { x: number; y: number; children: ReactNode }) { | |
| 44 | + return ( | |
| 45 | + <div className="chart-tip" style={{ left: x, top: y }}> | |
| 46 | + {children} | |
| 47 | + </div> | |
| 48 | + ); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Histogramme vertical — magnitude d'une distribution, une seule teinte. */ | |
| 52 | +export function VBars({ data, color = "#d94f1e", valueLabel = "véhicules", height = 190 }: | |
| 53 | + { data: Datum[]; color?: string; valueLabel?: string; height?: number }) { | |
| 54 | + const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null); | |
| 55 | + if (!data.length) return null; | |
| 56 | + const max = Math.max(...data.map((d) => d.n), 1); | |
| 57 | + const W = 720, PAD = 6, LBL = 28; | |
| 58 | + const bw = (W - PAD * (data.length - 1)) / data.length; | |
| 59 | + const plotH = height - LBL - 18; | |
| 60 | + return ( | |
| 61 | + <div className="chart-wrap" onMouseLeave={() => setTip(null)}> | |
| 62 | + <svg viewBox={`0 0 ${W} ${height}`} className="chart vbars" role="img" | |
| 63 | + aria-label={`Histogramme : ${valueLabel} par classe`}> | |
| 64 | + {data.map((d, i) => { | |
| 65 | + const h = Math.max(3, (d.n / max) * plotH); | |
| 66 | + const x = i * (bw + PAD); | |
| 67 | + const y = 18 + (plotH - h); | |
| 68 | + const show = d.n === max || i === 0 || i === data.length - 1; | |
| 69 | + return ( | |
| 70 | + <g key={d.label}> | |
| 71 | + <rect | |
| 72 | + x={x} y={y} width={bw} height={h} rx={4} fill={color} | |
| 73 | + className="mark" | |
| 74 | + onMouseEnter={(e) => { | |
| 75 | + const r = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect(); | |
| 76 | + setTip({ x: e.clientX - r.left, y: e.clientY - r.top - 10, d }); | |
| 77 | + }} | |
| 78 | + /> | |
| 79 | + {show && ( | |
| 80 | + <text x={x + bw / 2} y={y - 5} textAnchor="middle" className="c-val"> | |
| 81 | + {fmt(d.n)} | |
| 82 | + </text> | |
| 83 | + )} | |
| 84 | + <text x={x + bw / 2} y={height - 4} textAnchor="middle" className="c-lbl"> | |
| 85 | + {d.label} | |
| 86 | + </text> | |
| 87 | + </g> | |
| 88 | + ); | |
| 89 | + })} | |
| 90 | + </svg> | |
| 91 | + {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}</Tip>} | |
| 92 | + <DataTable rows={data} valueLabel={valueLabel} /> | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Barres horizontales — classement d'une même mesure, une seule teinte. */ | |
| 98 | +export function HBars({ data, color = "#d94f1e", valueLabel = "véhicules" }: | |
| 99 | + { data: Datum[]; color?: string; valueLabel?: string }) { | |
| 100 | + const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null); | |
| 101 | + if (!data.length) return null; | |
| 102 | + const max = Math.max(...data.map((d) => d.n), 1); | |
| 103 | + const W = 720, ROW = 26, LBL = 150; | |
| 104 | + const H = data.length * ROW; | |
| 105 | + return ( | |
| 106 | + <div className="chart-wrap" onMouseLeave={() => setTip(null)}> | |
| 107 | + <svg viewBox={`0 0 ${W} ${H}`} className="chart hbars" role="img" | |
| 108 | + aria-label={`Barres : ${valueLabel} par catégorie`}> | |
| 109 | + {data.map((d, i) => { | |
| 110 | + const w = Math.max(4, (d.n / max) * (W - LBL - 120)); | |
| 111 | + const y = i * ROW + 4; | |
| 112 | + return ( | |
| 113 | + <g key={d.label}> | |
| 114 | + <text x={LBL - 8} y={y + 13} textAnchor="end" className="c-cat"> | |
| 115 | + {d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label} | |
| 116 | + </text> | |
| 117 | + <rect | |
| 118 | + x={LBL} y={y} width={w} height={ROW - 10} rx={4} fill={color} | |
| 119 | + className="mark" | |
| 120 | + onMouseEnter={(e) => { | |
| 121 | + const r = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect(); | |
| 122 | + setTip({ x: e.clientX - r.left, y: e.clientY - r.top - 10, d }); | |
| 123 | + }} | |
| 124 | + /> | |
| 125 | + <text x={LBL + w + 7} y={y + 12} className="c-val"> | |
| 126 | + {fmt(d.n)}{d.extra ? ` · ${d.extra}` : ""} | |
| 127 | + </text> | |
| 128 | + </g> | |
| 129 | + ); | |
| 130 | + })} | |
| 131 | + </svg> | |
| 132 | + {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}</Tip>} | |
| 133 | + <DataTable rows={data} valueLabel={valueLabel} /> | |
| 134 | + </div> | |
| 135 | + ); | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** Donut — parts d'un tout (≤ 6 catégories + « Autres »), palette validée. */ | |
| 139 | +export function Donut({ data, valueLabel = "véhicules" }: | |
| 140 | + { data: Datum[]; valueLabel?: string }) { | |
| 141 | + const [tip, setTip] = useState<{ x: number; y: number; d: Datum; pct: number } | null>(null); | |
| 142 | + if (!data.length) return null; | |
| 143 | + const top = data.slice(0, 5); | |
| 144 | + const rest = data.slice(5).reduce((s, d) => s + d.n, 0); | |
| 145 | + const parts: Datum[] = rest > 0 ? [...top, { label: "Autres", n: rest }] : top; | |
| 146 | + const total = parts.reduce((s, d) => s + d.n, 0) || 1; | |
| 147 | + const R = 80, r = 46, CX = 110, CY = 100; | |
| 148 | + let angle = -Math.PI / 2; | |
| 149 | + const arcs = parts.map((d, i) => { | |
| 150 | + const span = (d.n / total) * Math.PI * 2; | |
| 151 | + const a0 = angle, a1 = angle + span; | |
| 152 | + angle = a1; | |
| 153 | + const large = span > Math.PI ? 1 : 0; | |
| 154 | + const p = (a: number, rad: number) => | |
| 155 | + `${CX + rad * Math.cos(a)},${CY + rad * Math.sin(a)}`; | |
| 156 | + return { | |
| 157 | + d, | |
| 158 | + pct: (d.n / total) * 100, | |
| 159 | + color: CAT_COLORS[i % CAT_COLORS.length], | |
| 160 | + path: `M ${p(a0, R)} A ${R} ${R} 0 ${large} 1 ${p(a1, R)} L ${p(a1, r)} A ${r} ${r} 0 ${large} 0 ${p(a0, r)} Z`, | |
| 161 | + }; | |
| 162 | + }); | |
| 163 | + return ( | |
| 164 | + <div className="chart-wrap donut-wrap" onMouseLeave={() => setTip(null)}> | |
| 165 | + <svg viewBox="0 0 220 200" className="chart donut" role="img" | |
| 166 | + aria-label={`Répartition : ${valueLabel} par catégorie`}> | |
| 167 | + {arcs.map((a) => ( | |
| 168 | + <path | |
| 169 | + key={a.d.label} d={a.path} fill={a.color} stroke="var(--surface)" | |
| 170 | + strokeWidth={2} className="mark" | |
| 171 | + onMouseEnter={(e) => { | |
| 172 | + const rr = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect(); | |
| 173 | + setTip({ x: e.clientX - rr.left, y: e.clientY - rr.top - 10, d: a.d, pct: a.pct }); | |
| 174 | + }} | |
| 175 | + /> | |
| 176 | + ))} | |
| 177 | + <text x={CX} y={CY - 2} textAnchor="middle" className="donut-total">{fmt(total)}</text> | |
| 178 | + <text x={CX} y={CY + 14} textAnchor="middle" className="c-lbl">{valueLabel}</text> | |
| 179 | + </svg> | |
| 180 | + <div className="donut-legend"> | |
| 181 | + {arcs.map((a) => ( | |
| 182 | + <div key={a.d.label} className="dl-row"> | |
| 183 | + <span className="dl-swatch" style={{ background: a.color }} /> | |
| 184 | + <span className="dl-name">{a.d.label}</span> | |
| 185 | + <span className="dl-val">{a.pct.toFixed(1)} %</span> | |
| 186 | + </div> | |
| 187 | + ))} | |
| 188 | + </div> | |
| 189 | + {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel} ({tip.pct.toFixed(1)} %)</Tip>} | |
| 190 | + <DataTable rows={parts} valueLabel={valueLabel} /> | |
| 191 | + </div> | |
| 192 | + ); | |
| 193 | +} | |
added
apps/auto/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
apps/auto/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 basename={import.meta.env.BASE_URL.replace(/\/$/, "")}> | |
| 14 | + <App /> | |
| 15 | + </BrowserRouter> | |
| 16 | + </React.StrictMode> | |
| 17 | +); | |
added
apps/auto/frontend/src/pages/Home.tsx
+279 −0
@@ -0,0 +1,279 @@ | ||
| 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 | +const KIND_COPY: Record<string, { label: string; hero: string; sub: string }> = { | |
| 27 | + auto: { | |
| 28 | + label: "voitures usagées", | |
| 29 | + hero: "voitures usagées", | |
| 30 | + sub: "Auto-Ka visite les sites des concessionnaires et marchands d'occasion de toutes les régions, normalise chaque annonce et détecte les nouveautés, les ventes et les baisses de prix — automatiquement.", | |
| 31 | + }, | |
| 32 | + moto: { | |
| 33 | + label: "motos usagées", | |
| 34 | + hero: "motos usagées", | |
| 35 | + sub: "Les inventaires des concessionnaires moto du Québec — Harley-Davidson, Honda, Yamaha, Kawasaki, BMW et plus — agrégés à la source et tenus à jour automatiquement.", | |
| 36 | + }, | |
| 37 | + scooter: { | |
| 38 | + label: "scooters usagés", | |
| 39 | + hero: "scooters usagés", | |
| 40 | + sub: "Les scooters usagés des concessionnaires du Québec — Vespa, Honda, Yamaha, Kymco et plus — agrégés à la source et tenus à jour automatiquement.", | |
| 41 | + }, | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export default function Home({ kind = "auto" }: { kind?: string }) { | |
| 45 | + const [params, setParams] = useSearchParams(); | |
| 46 | + const [facets, setFacets] = useState<Facets | null>(null); | |
| 47 | + const [vehicles, setVehicles] = useState<Vehicle[]>([]); | |
| 48 | + const [total, setTotal] = useState(0); | |
| 49 | + const [loading, setLoading] = useState(true); | |
| 50 | + const [heroStats, setHeroStats] = useState<{ total: number; sources: number; regions: number } | null>(null); | |
| 51 | + | |
| 52 | + const query: VehicleQuery = useMemo(() => { | |
| 53 | + const g = (k: string) => params.get(k) || undefined; | |
| 54 | + const gn = (k: string) => (params.get(k) ? Number(params.get(k)) : undefined); | |
| 55 | + return { | |
| 56 | + kind, | |
| 57 | + make: g("make"), model: g("model"), body_type: g("body"), | |
| 58 | + fuel: g("fuel"), transmission: g("trans"), region: g("region"), | |
| 59 | + source: g("source"), year_min: gn("ymin"), year_max: gn("ymax"), | |
| 60 | + price_max: gn("pmax"), km_max: gn("kmax"), q: g("q"), | |
| 61 | + sort: g("sort") || "price_asc", | |
| 62 | + limit: PAGE_SIZE, | |
| 63 | + offset: (Math.max(1, gn("page") || 1) - 1) * PAGE_SIZE, | |
| 64 | + }; | |
| 65 | + }, [params, kind]); | |
| 66 | + | |
| 67 | + const page = Math.max(1, Number(params.get("page") || 1)); | |
| 68 | + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); | |
| 69 | + | |
| 70 | + useEffect(() => { | |
| 71 | + fetchFacets(params.get("make") || undefined, kind).then(setFacets).catch(() => {}); | |
| 72 | + }, [params.get("make"), kind]); | |
| 73 | + | |
| 74 | + useEffect(() => { | |
| 75 | + fetchStats() | |
| 76 | + .then((s) => setHeroStats({ total: s.total, sources: s.sources, regions: s.regions })) | |
| 77 | + .catch(() => {}); | |
| 78 | + }, []); | |
| 79 | + | |
| 80 | + useEffect(() => { | |
| 81 | + setLoading(true); | |
| 82 | + fetchVehicles(query) | |
| 83 | + .then((r) => { setVehicles(r.vehicles); setTotal(r.total); }) | |
| 84 | + .catch(() => { setVehicles([]); setTotal(0); }) | |
| 85 | + .finally(() => setLoading(false)); | |
| 86 | + }, [query]); | |
| 87 | + | |
| 88 | + const setFilter = (key: string, value: string) => { | |
| 89 | + const next = new URLSearchParams(params); | |
| 90 | + if (value) next.set(key, value); | |
| 91 | + else next.delete(key); | |
| 92 | + if (key !== "page") next.delete("page"); | |
| 93 | + if (key === "make") next.delete("model"); // le modèle dépend de la marque | |
| 94 | + setParams(next, { replace: false }); | |
| 95 | + }; | |
| 96 | + | |
| 97 | + const clearAll = () => setParams(new URLSearchParams(), { replace: false }); | |
| 98 | + | |
| 99 | + const years: number[] = []; | |
| 100 | + const yb = facets?.years?.[0]; | |
| 101 | + if (yb?.y_min && yb?.y_max) { | |
| 102 | + for (let y = yb.y_max; y >= yb.y_min; y--) years.push(y); | |
| 103 | + } | |
| 104 | + | |
| 105 | + return ( | |
| 106 | + <div className="container"> | |
| 107 | + <section className="hero"> | |
| 108 | + <span className="kicker">Agrégateur indépendant — direct des concessionnaires</span> | |
| 109 | + <h1> | |
| 110 | + {kind === "scooter" ? "Tous les " : "Toutes les "} | |
| 111 | + <em>{KIND_COPY[kind]?.hero ?? "véhicules"}</em> à vendre au Québec. | |
| 112 | + Un seul endroit. | |
| 113 | + </h1> | |
| 114 | + <p className="sub">{KIND_COPY[kind]?.sub}</p> | |
| 115 | + {kind === "auto" && heroStats && ( | |
| 116 | + <div className="hero-stats"> | |
| 117 | + <div className="hstat"><b>{heroStats.total.toLocaleString("fr-CA")}</b> véhicules en vente</div> | |
| 118 | + <div className="hstat"><b>{heroStats.sources}</b> concessionnaires</div> | |
| 119 | + <div className="hstat"><b>{heroStats.regions}</b> régions couvertes</div> | |
| 120 | + </div> | |
| 121 | + )} | |
| 122 | + </section> | |
| 123 | + | |
| 124 | + <section className="filters" aria-label="Filtres de recherche"> | |
| 125 | + <div className="f-field full"> | |
| 126 | + <label htmlFor="f-q">Recherche</label> | |
| 127 | + <input | |
| 128 | + id="f-q" | |
| 129 | + type="search" | |
| 130 | + placeholder="Marque, modèle, concessionnaire, ville…" | |
| 131 | + defaultValue={params.get("q") || ""} | |
| 132 | + onKeyDown={(e) => { | |
| 133 | + if (e.key === "Enter") setFilter("q", (e.target as HTMLInputElement).value); | |
| 134 | + }} | |
| 135 | + /> | |
| 136 | + </div> | |
| 137 | + <div className="f-field"> | |
| 138 | + <label htmlFor="f-make">Marque</label> | |
| 139 | + <select id="f-make" value={params.get("make") || ""} onChange={(e) => setFilter("make", e.target.value)}> | |
| 140 | + <option value="">Toutes</option> | |
| 141 | + {facets?.makes.map((m) => ( | |
| 142 | + <option key={m.make} value={m.make}>{m.make} ({m.n})</option> | |
| 143 | + ))} | |
| 144 | + </select> | |
| 145 | + </div> | |
| 146 | + <div className="f-field"> | |
| 147 | + <label htmlFor="f-model">Modèle</label> | |
| 148 | + <select id="f-model" value={params.get("model") || ""} onChange={(e) => setFilter("model", e.target.value)}> | |
| 149 | + <option value="">Tous</option> | |
| 150 | + {facets?.models.map((m) => ( | |
| 151 | + <option key={m.model} value={m.model}>{m.model} ({m.n})</option> | |
| 152 | + ))} | |
| 153 | + </select> | |
| 154 | + </div> | |
| 155 | + <div className="f-field"> | |
| 156 | + <label htmlFor="f-region">Région</label> | |
| 157 | + <select id="f-region" value={params.get("region") || ""} onChange={(e) => setFilter("region", e.target.value)}> | |
| 158 | + <option value="">Toutes</option> | |
| 159 | + {facets?.regions.map((r) => ( | |
| 160 | + <option key={r.region} value={r.region}>{r.region} ({r.n})</option> | |
| 161 | + ))} | |
| 162 | + </select> | |
| 163 | + </div> | |
| 164 | + <div className="f-field"> | |
| 165 | + <label htmlFor="f-body">Carrosserie</label> | |
| 166 | + <select id="f-body" value={params.get("body") || ""} onChange={(e) => setFilter("body", e.target.value)}> | |
| 167 | + <option value="">Toutes</option> | |
| 168 | + {facets?.body_types.map((b) => ( | |
| 169 | + <option key={b} value={b}>{b}</option> | |
| 170 | + ))} | |
| 171 | + </select> | |
| 172 | + </div> | |
| 173 | + <div className="f-field"> | |
| 174 | + <label htmlFor="f-fuel">Carburant</label> | |
| 175 | + <select id="f-fuel" value={params.get("fuel") || ""} onChange={(e) => setFilter("fuel", e.target.value)}> | |
| 176 | + <option value="">Tous</option> | |
| 177 | + {facets?.fuels.map((f) => ( | |
| 178 | + <option key={f} value={f}>{f}</option> | |
| 179 | + ))} | |
| 180 | + </select> | |
| 181 | + </div> | |
| 182 | + <div className="f-field"> | |
| 183 | + <label htmlFor="f-trans">Transmission</label> | |
| 184 | + <select id="f-trans" value={params.get("trans") || ""} onChange={(e) => setFilter("trans", e.target.value)}> | |
| 185 | + <option value="">Toutes</option> | |
| 186 | + <option value="Automatique">Automatique</option> | |
| 187 | + <option value="Manuelle">Manuelle</option> | |
| 188 | + </select> | |
| 189 | + </div> | |
| 190 | + <div className="f-field"> | |
| 191 | + <label htmlFor="f-ymin">Année min</label> | |
| 192 | + <select id="f-ymin" value={params.get("ymin") || ""} onChange={(e) => setFilter("ymin", e.target.value)}> | |
| 193 | + <option value="">—</option> | |
| 194 | + {years.map((y) => ( | |
| 195 | + <option key={y} value={y}>{y}</option> | |
| 196 | + ))} | |
| 197 | + </select> | |
| 198 | + </div> | |
| 199 | + <div className="f-field"> | |
| 200 | + <label htmlFor="f-pmax">Prix max</label> | |
| 201 | + <select id="f-pmax" value={params.get("pmax") || ""} onChange={(e) => setFilter("pmax", e.target.value)}> | |
| 202 | + <option value="">—</option> | |
| 203 | + {PRICE_STEPS.map((p) => ( | |
| 204 | + <option key={p} value={p}>{p.toLocaleString("fr-CA")} $</option> | |
| 205 | + ))} | |
| 206 | + </select> | |
| 207 | + </div> | |
| 208 | + <div className="f-field"> | |
| 209 | + <label htmlFor="f-kmax">KM max</label> | |
| 210 | + <select id="f-kmax" value={params.get("kmax") || ""} onChange={(e) => setFilter("kmax", e.target.value)}> | |
| 211 | + <option value="">—</option> | |
| 212 | + {KM_STEPS.map((k) => ( | |
| 213 | + <option key={k} value={k}>{k.toLocaleString("fr-CA")} km</option> | |
| 214 | + ))} | |
| 215 | + </select> | |
| 216 | + </div> | |
| 217 | + <div className="f-actions"> | |
| 218 | + <button className="btn ghost" onClick={clearAll}>Réinitialiser</button> | |
| 219 | + </div> | |
| 220 | + </section> | |
| 221 | + | |
| 222 | + <div className="result-bar"> | |
| 223 | + <h2> | |
| 224 | + <span className="count">{total.toLocaleString("fr-CA")}</span>{" "} | |
| 225 | + véhicule{total !== 1 ? "s" : ""} trouvé{total !== 1 ? "s" : ""} | |
| 226 | + </h2> | |
| 227 | + <div className="sort-box"> | |
| 228 | + <label htmlFor="f-sort">Trier</label> | |
| 229 | + <select id="f-sort" value={params.get("sort") || "price_asc"} onChange={(e) => setFilter("sort", e.target.value)}> | |
| 230 | + {SORTS.map((s) => ( | |
| 231 | + <option key={s.v} value={s.v}>{s.label}</option> | |
| 232 | + ))} | |
| 233 | + </select> | |
| 234 | + </div> | |
| 235 | + </div> | |
| 236 | + | |
| 237 | + {loading ? ( | |
| 238 | + <div className="vgrid"> | |
| 239 | + {Array.from({ length: 8 }).map((_, i) => ( | |
| 240 | + <div key={i} className="skeleton" style={{ height: 360 }} /> | |
| 241 | + ))} | |
| 242 | + </div> | |
| 243 | + ) : vehicles.length === 0 ? ( | |
| 244 | + <div className="notice"> | |
| 245 | + <div className="big">🔍</div> | |
| 246 | + <h2>Aucun véhicule ne correspond</h2> | |
| 247 | + <p>Essayez d'élargir vos filtres — l'inventaire évolue chaque jour.</p> | |
| 248 | + </div> | |
| 249 | + ) : ( | |
| 250 | + <> | |
| 251 | + <div className="vgrid"> | |
| 252 | + {vehicles.map((v) => ( | |
| 253 | + <VehicleCard key={v.uid} v={v} /> | |
| 254 | + ))} | |
| 255 | + </div> | |
| 256 | + {pages > 1 && ( | |
| 257 | + <div className="pager"> | |
| 258 | + <button | |
| 259 | + className="btn ghost" | |
| 260 | + disabled={page <= 1} | |
| 261 | + onClick={() => { setFilter("page", String(page - 1)); window.scrollTo(0, 0); }} | |
| 262 | + > | |
| 263 | + ← Précédent | |
| 264 | + </button> | |
| 265 | + <span className="mono">page {page} / {pages}</span> | |
| 266 | + <button | |
| 267 | + className="btn ghost" | |
| 268 | + disabled={page >= pages} | |
| 269 | + onClick={() => { setFilter("page", String(page + 1)); window.scrollTo(0, 0); }} | |
| 270 | + > | |
| 271 | + Suivant → | |
| 272 | + </button> | |
| 273 | + </div> | |
| 274 | + )} | |
| 275 | + </> | |
| 276 | + )} | |
| 277 | + </div> | |
| 278 | + ); | |
| 279 | +} | |
added
apps/auto/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
apps/auto/frontend/src/pages/Stats.tsx
+186 −0
@@ -0,0 +1,186 @@ | ||
| 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 : tableau de bord du marché — héros, distributions, classements, | |
| 5 | +// parts de marché, aubaines + rapport PDF téléchargeable. | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { Link } from "react-router-dom"; | |
| 9 | +import { API_BASE, fmtPrice } from "../api"; | |
| 10 | +import { Datum, Donut, HBars, VBars } from "../components/Charts"; | |
| 11 | + | |
| 12 | +interface Detailed { | |
| 13 | + total: number; | |
| 14 | + sources: number; | |
| 15 | + regions: number; | |
| 16 | + avg_price: number | null; | |
| 17 | + median_price: number | null; | |
| 18 | + avg_km: number | null; | |
| 19 | + median_km: number | null; | |
| 20 | + avg_year: number | null; | |
| 21 | + new_7d: number; | |
| 22 | + electrified_pct: number; | |
| 23 | + price_hist: Datum[]; | |
| 24 | + km_hist: Datum[]; | |
| 25 | + year_hist: Datum[]; | |
| 26 | + by_make: { label: string; n: number; avg_price: number | null }[]; | |
| 27 | + by_region: { label: string; n: number; avg_price: number | null }[]; | |
| 28 | + by_body: Datum[]; | |
| 29 | + by_fuel: Datum[]; | |
| 30 | + top_models: { label: string; n: number; avg_price: number | null; avg_km: number | null }[]; | |
| 31 | + top_dealers: { label: string; n: number; avg_price: number | null }[]; | |
| 32 | + price_drops: { | |
| 33 | + uid: string; title: string; year: number | null; price: number; | |
| 34 | + prev_price: number; dealer_name: string; city: string; | |
| 35 | + }[]; | |
| 36 | +} | |
| 37 | + | |
| 38 | +const fmtK = (n: number | null) => | |
| 39 | + n == null ? "—" : `${Math.round(n / 1000)}k km`; | |
| 40 | + | |
| 41 | +export default function StatsPage() { | |
| 42 | + const [s, setS] = useState<Detailed | null>(null); | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + fetch(`${API_BASE}/api/stats/detailed`) | |
| 46 | + .then((r) => r.json()) | |
| 47 | + .then(setS) | |
| 48 | + .catch(() => {}); | |
| 49 | + }, []); | |
| 50 | + | |
| 51 | + if (!s) | |
| 52 | + return ( | |
| 53 | + <div className="container page"> | |
| 54 | + <div className="skeleton" style={{ height: 120, marginBottom: 20 }} /> | |
| 55 | + <div className="skeleton" style={{ height: 340 }} /> | |
| 56 | + </div> | |
| 57 | + ); | |
| 58 | + | |
| 59 | + const withPrice = (rows: { label: string; n: number; avg_price: number | null }[]): Datum[] => | |
| 60 | + rows.map((r) => ({ | |
| 61 | + label: r.label, n: r.n, | |
| 62 | + extra: r.avg_price ? `moy. ${fmtPrice(r.avg_price)}` : undefined, | |
| 63 | + })); | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <div className="container page"> | |
| 67 | + <div className="stats-head"> | |
| 68 | + <div> | |
| 69 | + <span className="kicker">Marché</span> | |
| 70 | + <h1>Le marché de l'occasion, en direct</h1> | |
| 71 | + <p className="lead" style={{ marginBottom: 0 }}> | |
| 72 | + Calculé en temps réel sur l'inventaire actif de {s.sources} concessionnaires | |
| 73 | + dans {s.regions} régions du Québec. | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + <a className="btn pdf-btn" href={`${API_BASE}/api/stats/rapport.pdf`}> | |
| 77 | + ⬇ Télécharger le rapport PDF | |
| 78 | + </a> | |
| 79 | + </div> | |
| 80 | + | |
| 81 | + <div className="stat-grid" style={{ marginTop: 26 }}> | |
| 82 | + <div className="stat-tile hero-tile"> | |
| 83 | + <b>{s.total.toLocaleString("fr-CA")}</b> | |
| 84 | + <span>véhicules en vente</span> | |
| 85 | + </div> | |
| 86 | + <div className="stat-tile"> | |
| 87 | + <b>{fmtPrice(s.avg_price)}</b> | |
| 88 | + <span>prix moyen</span> | |
| 89 | + </div> | |
| 90 | + <div className="stat-tile"> | |
| 91 | + <b>{fmtPrice(s.median_price)}</b> | |
| 92 | + <span>prix médian</span> | |
| 93 | + </div> | |
| 94 | + <div className="stat-tile"> | |
| 95 | + <b>{fmtK(s.avg_km)}</b> | |
| 96 | + <span>km moyen</span> | |
| 97 | + </div> | |
| 98 | + <div className="stat-tile"> | |
| 99 | + <b>{s.new_7d.toLocaleString("fr-CA")}</b> | |
| 100 | + <span>arrivages · 7 jours</span> | |
| 101 | + </div> | |
| 102 | + <div className="stat-tile"> | |
| 103 | + <b>{s.electrified_pct} %</b> | |
| 104 | + <span>électrifiés (VÉ + hybrides)</span> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + | |
| 108 | + <div className="panel"> | |
| 109 | + <h3>💰 Distribution des prix</h3> | |
| 110 | + <VBars data={s.price_hist} color="#d94f1e" /> | |
| 111 | + </div> | |
| 112 | + | |
| 113 | + <div className="vd-cols"> | |
| 114 | + <div className="panel"> | |
| 115 | + <h3>🛣 Kilométrage (milliers de km)</h3> | |
| 116 | + <VBars data={s.km_hist} color="#1f6fb5" height={170} /> | |
| 117 | + </div> | |
| 118 | + <div className="panel"> | |
| 119 | + <h3>📅 Années-modèles</h3> | |
| 120 | + <VBars data={s.year_hist} color="#b58500" height={170} /> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <div className="vd-cols"> | |
| 125 | + <div className="panel"> | |
| 126 | + <h3>🏷 Top marques</h3> | |
| 127 | + <HBars data={withPrice(s.by_make)} color="#d94f1e" /> | |
| 128 | + </div> | |
| 129 | + <div className="panel"> | |
| 130 | + <h3>📍 Par région</h3> | |
| 131 | + <HBars data={withPrice(s.by_region)} color="#1f6fb5" /> | |
| 132 | + </div> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + <div className="vd-cols"> | |
| 136 | + <div className="panel"> | |
| 137 | + <h3>🚙 Carrosseries</h3> | |
| 138 | + <Donut data={s.by_body} /> | |
| 139 | + </div> | |
| 140 | + <div className="panel"> | |
| 141 | + <h3>⛽ Carburants</h3> | |
| 142 | + <Donut data={s.by_fuel} /> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + <div className="panel"> | |
| 147 | + <h3>🚗 Modèles les plus offerts</h3> | |
| 148 | + <HBars | |
| 149 | + data={s.top_models.map((m) => ({ | |
| 150 | + label: m.label, n: m.n, | |
| 151 | + extra: `moy. ${fmtPrice(m.avg_price)} · ${fmtK(m.avg_km)}`, | |
| 152 | + }))} | |
| 153 | + color="#1c7a4d" | |
| 154 | + /> | |
| 155 | + </div> | |
| 156 | + | |
| 157 | + {s.price_drops.length > 0 && ( | |
| 158 | + <section style={{ margin: "30px 0" }}> | |
| 159 | + <h2 style={{ marginBottom: 14 }}>📉 Baisses de prix récentes</h2> | |
| 160 | + <div className="src-grid"> | |
| 161 | + {s.price_drops.map((d) => ( | |
| 162 | + <Link key={d.uid} to={`/vehicule/${encodeURIComponent(d.uid)}`} className="src-card"> | |
| 163 | + <h3>{d.title}</h3> | |
| 164 | + <div className="meta">{d.dealer_name} · {d.city}</div> | |
| 165 | + <div className="n"> | |
| 166 | + {fmtPrice(d.price)}{" "} | |
| 167 | + <span style={{ fontSize: 13, color: "var(--ink-3)", textDecoration: "line-through" }}> | |
| 168 | + {fmtPrice(d.prev_price)} | |
| 169 | + </span> | |
| 170 | + </div> | |
| 171 | + <div className="meta" style={{ color: "var(--good)", fontWeight: 700 }}> | |
| 172 | + −{fmtPrice(d.prev_price - d.price)} | |
| 173 | + </div> | |
| 174 | + </Link> | |
| 175 | + ))} | |
| 176 | + </div> | |
| 177 | + </section> | |
| 178 | + )} | |
| 179 | + | |
| 180 | + <div className="panel"> | |
| 181 | + <h3>🏢 Plus grands inventaires</h3> | |
| 182 | + <HBars data={withPrice(s.top_dealers)} color="#7d4fc9" /> | |
| 183 | + </div> | |
| 184 | + </div> | |
| 185 | + ); | |
| 186 | +} | |
added
apps/auto/frontend/src/pages/Vehicle.tsx
+210 −0
@@ -0,0 +1,210 @@ | ||
| 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 | + ["Moteur", v.engine || "—"], | |
| 56 | + ["Couleur ext.", v.exterior_color || "—"], | |
| 57 | + ["Couleur int.", v.interior_color || "—"], | |
| 58 | + ["Portes", v.doors ? String(v.doors) : "—"], | |
| 59 | + ["Places", v.seats ? String(v.seats) : "—"], | |
| 60 | + ["NIV (VIN)", v.vin || "—"], | |
| 61 | + ["No de stock", v.stock_number || "—"], | |
| 62 | + ].filter(([, val]) => val !== "—") as [string, string][]; | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <div className="container vdetail"> | |
| 66 | + <p style={{ marginTop: 0 }}> | |
| 67 | + <Link to="/" className="mono" style={{ fontSize: 12 }}>← Retour à la recherche</Link> | |
| 68 | + </p> | |
| 69 | + <div className="vd-head"> | |
| 70 | + <div> | |
| 71 | + <span className="kicker">{v.dealer_name || v.source} — {v.city}{v.region ? ` · ${v.region}` : ""}</span> | |
| 72 | + <h1>{v.title}</h1> | |
| 73 | + </div> | |
| 74 | + <div className="vd-price"> | |
| 75 | + <div className="p">{fmtPrice(v.price)}</div> | |
| 76 | + {prevPrice != null && v.price != null && prevPrice > v.price && ( | |
| 77 | + <div className="was">{fmtPrice(prevPrice)}</div> | |
| 78 | + )} | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + <div className="vd-cols"> | |
| 83 | + <div> | |
| 84 | + <div className="gallery"> | |
| 85 | + <div className="main"> | |
| 86 | + {v.images.length > 0 ? ( | |
| 87 | + <a href={v.images[img]} target="_blank" rel="noreferrer"> | |
| 88 | + <img src={v.images[img]} alt={v.title} /> | |
| 89 | + </a> | |
| 90 | + ) : ( | |
| 91 | + <div className="nopic" style={{ display: "grid", placeItems: "center", height: "100%", fontSize: 60 }}>🚗</div> | |
| 92 | + )} | |
| 93 | + </div> | |
| 94 | + {v.images.length > 1 && ( | |
| 95 | + <div className="thumbs"> | |
| 96 | + {v.images.map((u, i) => ( | |
| 97 | + <img | |
| 98 | + key={u} | |
| 99 | + src={u} | |
| 100 | + alt={`photo ${i + 1}`} | |
| 101 | + loading="lazy" | |
| 102 | + className={i === img ? "sel" : ""} | |
| 103 | + onClick={() => setImg(i)} | |
| 104 | + /> | |
| 105 | + ))} | |
| 106 | + </div> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + | |
| 110 | + {v.description && ( | |
| 111 | + <div className="panel" style={{ marginTop: 18 }}> | |
| 112 | + <h3>📝 Description du concessionnaire</h3> | |
| 113 | + <div className="desc">{v.description}</div> | |
| 114 | + </div> | |
| 115 | + )} | |
| 116 | + </div> | |
| 117 | + | |
| 118 | + <div> | |
| 119 | + <div className="panel"> | |
| 120 | + <h3>🔧 Caractéristiques</h3> | |
| 121 | + <table className="spec-table"> | |
| 122 | + <tbody> | |
| 123 | + {specs.map(([k, val]) => ( | |
| 124 | + <tr key={k}> | |
| 125 | + <td>{k}</td> | |
| 126 | + <td>{val}</td> | |
| 127 | + </tr> | |
| 128 | + ))} | |
| 129 | + </tbody> | |
| 130 | + </table> | |
| 131 | + <a className="cta-source" href={v.url} target="_blank" rel="noreferrer"> | |
| 132 | + Voir chez {v.dealer_name || "le concessionnaire"} → | |
| 133 | + </a> | |
| 134 | + {v.carfax_url && ( | |
| 135 | + <a | |
| 136 | + className="cta-source" | |
| 137 | + style={{ background: "var(--surface)", color: "var(--ink)", marginTop: 10 }} | |
| 138 | + href={v.carfax_url} | |
| 139 | + target="_blank" | |
| 140 | + rel="noreferrer" | |
| 141 | + > | |
| 142 | + 📋 Rapport Carfax | |
| 143 | + </a> | |
| 144 | + )} | |
| 145 | + <div className="cta-note"> | |
| 146 | + annonce originale — prix et disponibilité confirmés à la source | |
| 147 | + </div> | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + {v.features.length > 0 && ( | |
| 151 | + <div className="panel"> | |
| 152 | + <h3>✨ Équipements ({v.features.length})</h3> | |
| 153 | + <div className="feat-list"> | |
| 154 | + {v.features.map((f) => ( | |
| 155 | + <span key={f} className="spec-chip">{f}</span> | |
| 156 | + ))} | |
| 157 | + </div> | |
| 158 | + </div> | |
| 159 | + )} | |
| 160 | + | |
| 161 | + {v.price_history.length > 1 && ( | |
| 162 | + <div className="panel"> | |
| 163 | + <h3>📉 Historique de prix</h3> | |
| 164 | + <div className="price-history"> | |
| 165 | + {v.price_history.map((h, i) => { | |
| 166 | + const next = v.price_history[i + 1]; | |
| 167 | + const dir = | |
| 168 | + next?.price != null && h.price != null | |
| 169 | + ? h.price < next.price ? "down" : h.price > next.price ? "up" : "" | |
| 170 | + : ""; | |
| 171 | + return ( | |
| 172 | + <div key={h.ts} className="row"> | |
| 173 | + <span>{fmtDate(h.ts)}</span> | |
| 174 | + <span className={dir}>{fmtPrice(h.price)}</span> | |
| 175 | + </div> | |
| 176 | + ); | |
| 177 | + })} | |
| 178 | + </div> | |
| 179 | + </div> | |
| 180 | + )} | |
| 181 | + | |
| 182 | + <div className="panel"> | |
| 183 | + <h3>ℹ️ Suivi Auto-Ka</h3> | |
| 184 | + <table className="spec-table"> | |
| 185 | + <tbody> | |
| 186 | + <tr><td>Repéré le</td><td>{fmtDate(v.first_seen)}</td></tr> | |
| 187 | + <tr><td>Vérifié le</td><td>{fmtDate(v.updated_at)}</td></tr> | |
| 188 | + <tr><td>Statut</td><td>{v.active ? "En vente" : "Retiré / vendu"}</td></tr> | |
| 189 | + </tbody> | |
| 190 | + </table> | |
| 191 | + </div> | |
| 192 | + </div> | |
| 193 | + </div> | |
| 194 | + | |
| 195 | + {v.similar.length > 0 && ( | |
| 196 | + <section style={{ marginTop: 40 }}> | |
| 197 | + <span className="kicker">Comparer</span> | |
| 198 | + <h2 style={{ margin: "8px 0 18px" }}> | |
| 199 | + {v.make} {v.model} similaires au Québec | |
| 200 | + </h2> | |
| 201 | + <div className="vgrid"> | |
| 202 | + {v.similar.map((s) => ( | |
| 203 | + <VehicleCard key={s.uid} v={s} /> | |
| 204 | + ))} | |
| 205 | + </div> | |
| 206 | + </section> | |
| 207 | + )} | |
| 208 | + </div> | |
| 209 | + ); | |
| 210 | +} | |
added
apps/auto/frontend/src/styles.css
+456 −0
@@ -0,0 +1,456 @@ | ||
| 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 | +.feat-list { | |
| 293 | + display: flex; flex-wrap: wrap; gap: 6px; max-height: 340px; overflow-y: auto; | |
| 294 | +} | |
| 295 | +.price-history { font-size: 13px; } | |
| 296 | +.price-history .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--line); } | |
| 297 | +.price-history .down { color: var(--good); font-weight: 700; } | |
| 298 | +.price-history .up { color: var(--danger); font-weight: 700; } | |
| 299 | + | |
| 300 | +/* ================= Sections / pages annexes ================= */ | |
| 301 | +.page { padding: 40px 0 70px; } | |
| 302 | +.page h1 { font-size: clamp(26px, 4vw, 40px); margin-bottom: 8px; } | |
| 303 | +.page .lead { color: var(--ink-2); max-width: 640px; margin-bottom: 28px; } | |
| 304 | + | |
| 305 | +.src-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } | |
| 306 | +.src-card { | |
| 307 | + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 308 | + padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; | |
| 309 | + transition: transform 0.14s ease, box-shadow 0.14s ease; box-shadow: var(--shadow-flat); | |
| 310 | +} | |
| 311 | +.src-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); } | |
| 312 | +.src-card h3 { font-size: 16px; } | |
| 313 | +.src-card .meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); } | |
| 314 | +.src-card .n { font-family: var(--font-display); font-weight: 700; font-size: 22px; color: var(--accent-deep); } | |
| 315 | + | |
| 316 | +.stat-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 30px; } | |
| 317 | +.stat-tile { | |
| 318 | + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 319 | + padding: 18px; box-shadow: var(--shadow-off-soft); | |
| 320 | +} | |
| 321 | +.stat-tile b { display: block; font-family: var(--font-display); font-size: 30px; letter-spacing: -0.02em; } | |
| 322 | +.stat-tile span { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); } | |
| 323 | + | |
| 324 | +.bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; font-size: 13.5px; } | |
| 325 | +.bar-row .lbl { width: 170px; flex: 0 0 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 326 | +.bar-row .bar { height: 16px; background: var(--accent); border: 1.5px solid var(--ink); border-radius: 3px; min-width: 2px; } | |
| 327 | +.bar-row .val { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-2); } | |
| 328 | + | |
| 329 | +/* ================= Graphiques (Charts.tsx) ================= */ | |
| 330 | +.stats-head { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; } | |
| 331 | +.pdf-btn { margin-left: auto; background: var(--accent); border-color: var(--ink); font-size: 15px; } | |
| 332 | +.pdf-btn:hover { box-shadow: 4px 4px 0 var(--ink); } | |
| 333 | +.stat-tile.hero-tile b { color: var(--accent-deep); } | |
| 334 | + | |
| 335 | +.chart-wrap { position: relative; } | |
| 336 | +.chart { width: 100%; height: auto; display: block; } | |
| 337 | +.chart .mark { transition: opacity 0.12s ease; cursor: default; } | |
| 338 | +.chart .mark:hover { opacity: 0.75; } | |
| 339 | +.c-val { font-family: var(--font-mono); font-size: 11px; font-weight: 600; fill: var(--ink); } | |
| 340 | +.c-lbl { font-family: var(--font-mono); font-size: 10px; fill: var(--ink-3); } | |
| 341 | +.c-cat { font-size: 12px; font-weight: 600; fill: var(--ink-2); } | |
| 342 | +.donut-total { font-family: var(--font-display); font-size: 22px; font-weight: 700; fill: var(--ink); } | |
| 343 | + | |
| 344 | +.chart-tip { | |
| 345 | + position: absolute; transform: translate(-50%, -100%); pointer-events: none; | |
| 346 | + background: var(--ink); color: #fff; font-size: 12.5px; line-height: 1.35; | |
| 347 | + padding: 6px 10px; border-radius: 6px; white-space: nowrap; z-index: 5; | |
| 348 | + box-shadow: 0 4px 14px rgba(23,24,28,0.25); | |
| 349 | +} | |
| 350 | +.chart-tip b { color: var(--accent); } | |
| 351 | + | |
| 352 | +.donut-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 14px; align-items: center; } | |
| 353 | +.donut-wrap .chart-data { grid-column: 1 / -1; } | |
| 354 | +@media (max-width: 560px) { .donut-wrap { grid-template-columns: 1fr; } } | |
| 355 | +.donut-legend { display: flex; flex-direction: column; gap: 6px; } | |
| 356 | +.dl-row { display: flex; align-items: center; gap: 8px; font-size: 13.5px; } | |
| 357 | +.dl-swatch { width: 13px; height: 13px; border-radius: 3px; border: 1px solid rgba(23,24,28,0.3); flex: 0 0 auto; } | |
| 358 | +.dl-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 359 | +.dl-val { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); } | |
| 360 | + | |
| 361 | +.chart-data { margin-top: 10px; font-size: 12.5px; } | |
| 362 | +.chart-data summary { | |
| 363 | + cursor: pointer; font-family: var(--font-mono); font-size: 11px; | |
| 364 | + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); | |
| 365 | +} | |
| 366 | +.chart-data table { width: 100%; border-collapse: collapse; margin-top: 8px; } | |
| 367 | +.chart-data th, .chart-data td { | |
| 368 | + text-align: left; padding: 4px 8px; border-bottom: 1px dashed var(--line); | |
| 369 | +} | |
| 370 | +.chart-data th { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; color: var(--ink-3); } | |
| 371 | + | |
| 372 | +/* ================= Cartes véhicules — raffinements ================= */ | |
| 373 | +.vcard .photo img { transition: transform 0.35s ease; } | |
| 374 | +.vcard:hover .photo img { transform: scale(1.04); } | |
| 375 | + | |
| 376 | +/* ================= Adaptatif téléphone ================= */ | |
| 377 | +@media (max-width: 640px) { | |
| 378 | + .hero { padding: 30px 0 4px; } | |
| 379 | + .hero p.sub { font-size: 15px; } | |
| 380 | + .hero-stats { gap: 18px; margin-top: 16px; } | |
| 381 | + .hstat b { font-size: 21px; } | |
| 382 | + | |
| 383 | + /* filtres : 2 colonnes compactes, recherche et actions pleine largeur */ | |
| 384 | + .filters { | |
| 385 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 386 | + padding: 12px; gap: 8px; | |
| 387 | + box-shadow: 4px 4px 0 var(--ink); | |
| 388 | + } | |
| 389 | + .f-actions { grid-column: 1 / -1; } | |
| 390 | + .f-actions .btn { width: 100%; } | |
| 391 | + .f-field select, .f-field input { font-size: 16px; } /* évite le zoom iOS */ | |
| 392 | + | |
| 393 | + .result-bar { gap: 8px; margin: 16px 0 12px; } | |
| 394 | + .result-bar h2 { font-size: 17px; } | |
| 395 | + .sort-box { margin-left: 0; width: 100%; } | |
| 396 | + .sort-box select { flex: 1; } | |
| 397 | + | |
| 398 | + .vgrid { gap: 14px; grid-template-columns: 1fr; } | |
| 399 | + .vcard:hover { transform: none; box-shadow: var(--shadow-flat); } | |
| 400 | + | |
| 401 | + /* fiche véhicule : entête et prix empilés */ | |
| 402 | + .vdetail { padding: 20px 0 40px; } | |
| 403 | + .vd-head { flex-direction: column; gap: 8px; } | |
| 404 | + .vd-head h1 { font-size: 23px; } | |
| 405 | + .vd-price { margin-left: 0; text-align: left; } | |
| 406 | + .vd-price .p { font-size: 28px; } | |
| 407 | + .gallery .thumbs img { width: 68px; height: 50px; } | |
| 408 | + .panel { padding: 14px 14px; } | |
| 409 | + .spec-table td:last-child { word-break: break-all; } /* VIN longs */ | |
| 410 | + .desc { font-size: 14px; } | |
| 411 | + .feat-list { max-height: 260px; } | |
| 412 | + | |
| 413 | + /* stats : bouton PDF pleine largeur, tuiles 2 colonnes */ | |
| 414 | + .stats-head { flex-direction: column; align-items: stretch; } | |
| 415 | + .pdf-btn { margin-left: 0; text-align: center; } | |
| 416 | + .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } | |
| 417 | + .stat-tile { padding: 13px; } | |
| 418 | + .stat-tile b { font-size: 22px; } | |
| 419 | + | |
| 420 | + /* graphiques : défilement horizontal plutôt que texte illisible */ | |
| 421 | + .chart-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } | |
| 422 | + .chart.vbars { min-width: 540px; } | |
| 423 | + .chart.hbars { min-width: 460px; } | |
| 424 | + .donut-wrap { overflow-x: visible; } | |
| 425 | + | |
| 426 | + .src-grid { grid-template-columns: 1fr; } | |
| 427 | + .page { padding: 26px 0 50px; } | |
| 428 | +} | |
| 429 | + | |
| 430 | +@media (max-width: 400px) { | |
| 431 | + .brand { font-size: 22px; } | |
| 432 | + .nav a { padding: 7px 9px; font-size: 12.5px; } | |
| 433 | + .header-inner { gap: 8px; } | |
| 434 | +} | |
| 435 | + | |
| 436 | +.notice { text-align: center; padding: 90px 20px; } | |
| 437 | +.notice .big { font-size: 52px; margin-bottom: 12px; } | |
| 438 | +.notice p { color: var(--ink-2); } | |
| 439 | + | |
| 440 | +.skeleton { | |
| 441 | + background: linear-gradient(100deg, var(--surface-2) 40%, #fff 50%, var(--surface-2) 60%); | |
| 442 | + background-size: 200% 100%; animation: sk 1.4s infinite; | |
| 443 | + border-radius: var(--r-card); border: 2px solid var(--line); | |
| 444 | +} | |
| 445 | +@keyframes sk { from { background-position: 130% 0; } to { background-position: -30% 0; } } | |
| 446 | + | |
| 447 | +/* ================= Footer ================= */ | |
| 448 | +.footer { | |
| 449 | + background: var(--ink); color: #c9cbd1; margin-top: 30px; | |
| 450 | + padding: 42px 0 30px; border-top: 3px solid var(--accent); | |
| 451 | +} | |
| 452 | +.fbrand { font-family: var(--font-display); font-weight: 700; font-size: 24px; color: #fff; margin-bottom: 14px; } | |
| 453 | +.fbrand .ka { background: var(--accent); color: var(--ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 2px; display: inline-block; transform: rotate(-2deg); } | |
| 454 | +.frow { font-size: 13.5px; max-width: 560px; line-height: 1.6; } | |
| 455 | +.fmono { font-family: var(--font-mono); font-size: 11px; color: #82858d; margin-top: 22px; } | |
| 456 | +.fmono a { text-decoration: underline; } | |
added
apps/auto/frontend/src/vite-env.d.ts
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +/// <reference types="vite/client" /> | |
added
apps/auto/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
apps/auto/frontend/tsconfig.tsbuildinfo
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/charts.tsx","./src/components/vehiclecard.tsx","./src/pages/home.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx","./src/pages/vehicle.tsx"],"version":"5.9.3"} | |
| \ No newline at end of file | ||
added
apps/auto/frontend/vite.config.ts
+16 −0
@@ -0,0 +1,16 @@ | ||
| 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 | + base: "/auto/", | |
| 10 | + plugins: [react()], | |
| 11 | + server: { | |
| 12 | + proxy: { | |
| 13 | + "/api": "http://localhost:8080", | |
| 14 | + }, | |
| 15 | + }, | |
| 16 | +}); | |
added
apps/fabri/data/stores.json
+6154 −0
@@ -0,0 +1,108144 @@ | ||
| 1 | +{ | |
| 2 | + "generated": "2026-08-12", | |
| 3 | + "count": 3224, | |
| 4 | + "stores": [ | |
| 5 | + { | |
| 6 | + "id": "1001fondues.com", | |
| 7 | + "name": "1001 Fondues", | |
| 8 | + "url": "https://1001fondues.com", | |
| 9 | + "platform": "woocommerce", | |
| 10 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 11 | + "city": "", | |
| 12 | + "region": "", | |
| 13 | + "postal_prefix": "G2K", | |
| 14 | + "phone": "418 829-0177", | |
| 15 | + "origin_class": "A", | |
| 16 | + "origin_confidence": 0.95, | |
| 17 | + "origin_evidence": "Fromagerie recensée par Fromages d'ici (fabrication au Québec)", | |
| 18 | + "categories": [ | |
| 19 | + "fromage_laitier" | |
| 20 | + ], | |
| 21 | + "socials": [ | |
| 22 | + "https://www.facebook.com/fromagesdici", | |
| 23 | + "https://www.youtube.com/channel/UCxSGDuWDPFOvNy7xP_LylcA", | |
| 24 | + "https://www.instagram.com/fromages.dici/", | |
| 25 | + "https://www.pinterest.ca/fromagesdici/" | |
| 26 | + ], | |
| 27 | + "discovery_sources": [ | |
| 28 | + "fromagesdici" | |
| 29 | + ], | |
| 30 | + "discovery_source_urls": [ | |
| 31 | + "https://www.fromagesdici.com/fr/fromageries/1001-fondues" | |
| 32 | + ], | |
| 33 | + "language": "fr", | |
| 34 | + "ecommerce": true, | |
| 35 | + "verification_date": "2026-08-12", | |
| 36 | + "status": "verified", | |
| 37 | + "enabled": true | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "id": "1001perles.ca", | |
| 41 | + "name": "1001 perles", | |
| 42 | + "url": "https://1001perles.ca", | |
| 43 | + "platform": "shopify", | |
| 44 | + "catalog_endpoint": "/products.json", | |
| 45 | + "city": "", | |
| 46 | + "region": "Montérégie", | |
| 47 | + "postal_prefix": "J3X", | |
| 48 | + "phone": "514 847-9799", | |
| 49 | + "origin_class": "A", | |
| 50 | + "origin_confidence": 0.9, | |
| 51 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 52 | + "categories": [], | |
| 53 | + "socials": [ | |
| 54 | + "https://www.facebook.com/lesproduitsduqc", | |
| 55 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 56 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 57 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 58 | + ], | |
| 59 | + "discovery_sources": [ | |
| 60 | + "lesproduitsduquebec_ent" | |
| 61 | + ], | |
| 62 | + "discovery_source_urls": [ | |
| 63 | + "https://lesproduitsduquebec.com/entreprises-adherentes/1001-perles" | |
| 64 | + ], | |
| 65 | + "language": "en", | |
| 66 | + "ecommerce": true, | |
| 67 | + "verification_date": "2026-08-12", | |
| 68 | + "status": "verified", | |
| 69 | + "enabled": true | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + "id": "123crepes.ca", | |
| 73 | + "name": "123 crêpes inc", | |
| 74 | + "url": "https://123crepes.ca", | |
| 75 | + "platform": "", | |
| 76 | + "catalog_endpoint": "", | |
| 77 | + "city": "", | |
| 78 | + "region": "Montréal", | |
| 79 | + "postal_prefix": "H1Z", | |
| 80 | + "phone": "5147819750", | |
| 81 | + "origin_class": "A", | |
| 82 | + "origin_confidence": 0.85, | |
| 83 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 84 | + "categories": [ | |
| 85 | + "epicerie" | |
| 86 | + ], | |
| 87 | + "socials": [ | |
| 88 | + "https://www.facebook.com/alimentsduquebec", | |
| 89 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 90 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 91 | + "https://www.tiktok.com/@alimentsqc" | |
| 92 | + ], | |
| 93 | + "discovery_sources": [ | |
| 94 | + "alimentsduquebec_ent" | |
| 95 | + ], | |
| 96 | + "discovery_source_urls": [ | |
| 97 | + "https://alimentsduquebec.com/entreprises/123-crepes-inc" | |
| 98 | + ], | |
| 99 | + "language": "fr", | |
| 100 | + "ecommerce": false, | |
| 101 | + "verification_date": "2026-08-12", | |
| 102 | + "status": "verified", | |
| 103 | + "enabled": false | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + "id": "13arpents.com", | |
| 107 | + "name": "Aux 13 Arpents - Goûtez-y!", | |
| 108 | + "url": "https://www.13arpents.com", | |
| 109 | + "platform": "wix", | |
| 110 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 111 | + "city": "", | |
| 112 | + "region": "Centre-du-Québec", | |
| 113 | + "postal_prefix": "G0P", | |
| 114 | + "phone": "(819) 293-7769", | |
| 115 | + "origin_class": "A", | |
| 116 | + "origin_confidence": 0.85, | |
| 117 | + "origin_evidence": "Entreprise membre de Goûtez-y! (agroalimentaire Centre-du-Québec)", | |
| 118 | + "categories": [ | |
| 119 | + "epicerie" | |
| 120 | + ], | |
| 121 | + "socials": [ | |
| 122 | + "https://www.facebook.com/NotreCentreDuQuebecGoutezY", | |
| 123 | + "https://www.instagram.com/goutez_y/", | |
| 124 | + "https://www.youtube.com/@goutezy/", | |
| 125 | + "https://www.facebook.com/Aux13arpents" | |
| 126 | + ], | |
| 127 | + "discovery_sources": [ | |
| 128 | + "goutezy" | |
| 129 | + ], | |
| 130 | + "discovery_source_urls": [ | |
| 131 | + "https://goutezy.com/entreprise/aux-13-arpents/" | |
| 132 | + ], | |
| 133 | + "language": null, | |
| 134 | + "ecommerce": true, | |
| 135 | + "verification_date": "2026-08-12", | |
| 136 | + "status": "verified", | |
| 137 | + "enabled": true | |
| 138 | + }, | |
| 139 | + { | |
| 140 | + "id": "1555marchepublic.com", | |
| 141 | + "name": "Marché de Noël de Saint-Hyacinthe", | |
| 142 | + "url": "https://1555marchepublic.com", | |
| 143 | + "platform": "wordpress", | |
| 144 | + "catalog_endpoint": "", | |
| 145 | + "city": "", | |
| 146 | + "region": "Montérégie", | |
| 147 | + "postal_prefix": "J2S", | |
| 148 | + "phone": null, | |
| 149 | + "origin_class": "E", | |
| 150 | + "origin_confidence": 0.4, | |
| 151 | + "origin_evidence": "Lien trouvé sur la fiche d'un marché public québécois (AMPQ) — nature du lien à vérifier", | |
| 152 | + "categories": [], | |
| 153 | + "socials": [ | |
| 154 | + "https://www.facebook.com/1555marchepublic", | |
| 155 | + "https://www.instagram.com/1555marchepublic/", | |
| 156 | + "https://www.facebook.com/marchespublicsqc", | |
| 157 | + "https://www.linkedin.com/company/marches-publics-du-quebec/" | |
| 158 | + ], | |
| 159 | + "discovery_sources": [ | |
| 160 | + "marchespublics" | |
| 161 | + ], | |
| 162 | + "discovery_source_urls": [ | |
| 163 | + "https://www.marchespublicsduquebec.ca/marches-publics/le-1555-marche-public-de-saint-hyacinthe/" | |
| 164 | + ], | |
| 165 | + "language": "fr", | |
| 166 | + "ecommerce": false, | |
| 167 | + "verification_date": "2026-08-12", | |
| 168 | + "status": "probable", | |
| 169 | + "enabled": false | |
| 170 | + }, | |
| 171 | + { | |
| 172 | + "id": "1642.ca", | |
| 173 | + "name": "1642 Sodas Inc.", | |
| 174 | + "url": "https://1642.ca", | |
| 175 | + "platform": "shopify", | |
| 176 | + "catalog_endpoint": "/products.json", | |
| 177 | + "city": "", | |
| 178 | + "region": "Montréal", | |
| 179 | + "postal_prefix": "H2K", | |
| 180 | + "phone": null, | |
| 181 | + "origin_class": "A", | |
| 182 | + "origin_confidence": 0.85, | |
| 183 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 184 | + "categories": [ | |
| 185 | + "epicerie" | |
| 186 | + ], | |
| 187 | + "socials": [ | |
| 188 | + "https://www.facebook.com/alimentsduquebec", | |
| 189 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 190 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 191 | + "https://www.tiktok.com/@alimentsqc" | |
| 192 | + ], | |
| 193 | + "discovery_sources": [ | |
| 194 | + "alimentsduquebec_ent" | |
| 195 | + ], | |
| 196 | + "discovery_source_urls": [ | |
| 197 | + "https://alimentsduquebec.com/entreprises/1642-sodas-inc" | |
| 198 | + ], | |
| 199 | + "language": "en", | |
| 200 | + "ecommerce": true, | |
| 201 | + "verification_date": "2026-08-12", | |
| 202 | + "status": "verified", | |
| 203 | + "enabled": true | |
| 204 | + }, | |
| 205 | + { | |
| 206 | + "id": "1chaletalouer.com", | |
| 207 | + "name": "Repère du Massif", | |
| 208 | + "url": "http://1chaletalouer.com", | |
| 209 | + "platform": "", | |
| 210 | + "catalog_endpoint": "", | |
| 211 | + "city": "", | |
| 212 | + "region": "Capitale-Nationale", | |
| 213 | + "postal_prefix": "G0T", | |
| 214 | + "phone": "514 891-6154", | |
| 215 | + "origin_class": "A", | |
| 216 | + "origin_confidence": 0.45, | |
| 217 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 218 | + "categories": [ | |
| 219 | + "epicerie" | |
| 220 | + ], | |
| 221 | + "socials": [ | |
| 222 | + "https://www.facebook.com/soleillevant20/?locale=fr_CA", | |
| 223 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 224 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 225 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 226 | + ], | |
| 227 | + "discovery_sources": [ | |
| 228 | + "charlevoix" | |
| 229 | + ], | |
| 230 | + "discovery_source_urls": [ | |
| 231 | + "https://www.tourisme-charlevoix.com/fr/entreprises/repere-du-massif" | |
| 232 | + ], | |
| 233 | + "language": null, | |
| 234 | + "ecommerce": false, | |
| 235 | + "verification_date": "2026-08-12", | |
| 236 | + "status": "probable", | |
| 237 | + "enabled": false | |
| 238 | + }, | |
| 239 | + { | |
| 240 | + "id": "3petitscochonsverts.com", | |
| 241 | + "name": "Aux Trois Petits Cochons Verts", | |
| 242 | + "url": "https://www.3petitscochonsverts.com", | |
| 243 | + "platform": "wix", | |
| 244 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 245 | + "city": "", | |
| 246 | + "region": "Lanaudière", | |
| 247 | + "postal_prefix": "J6Y", | |
| 248 | + "phone": "(450) 477-1477", | |
| 249 | + "origin_class": "A", | |
| 250 | + "origin_confidence": 0.9, | |
| 251 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 252 | + "categories": [ | |
| 253 | + "epicerie" | |
| 254 | + ], | |
| 255 | + "socials": [ | |
| 256 | + "https://www.facebook.com/alimentsduquebec", | |
| 257 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 258 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 259 | + "https://www.tiktok.com/@alimentsqc" | |
| 260 | + ], | |
| 261 | + "discovery_sources": [ | |
| 262 | + "alimentsduquebec_ent" | |
| 263 | + ], | |
| 264 | + "discovery_source_urls": [ | |
| 265 | + "https://alimentsduquebec.com/entreprises/aux-trois-petits-cochons-verts" | |
| 266 | + ], | |
| 267 | + "language": "fr", | |
| 268 | + "ecommerce": true, | |
| 269 | + "verification_date": "2026-08-12", | |
| 270 | + "status": "verified", | |
| 271 | + "enabled": true | |
| 272 | + }, | |
| 273 | + { | |
| 274 | + "id": "3samson.com", | |
| 275 | + "name": "Ferme des 3 Samson inc.", | |
| 276 | + "url": "https://3samson.com", | |
| 277 | + "platform": "woocommerce", | |
| 278 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 279 | + "city": "", | |
| 280 | + "region": "Montérégie", | |
| 281 | + "postal_prefix": "J2N", | |
| 282 | + "phone": "450 293-5015", | |
| 283 | + "origin_class": "A", | |
| 284 | + "origin_confidence": 0.95, | |
| 285 | + "origin_evidence": "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca)", | |
| 286 | + "categories": [ | |
| 287 | + "erable" | |
| 288 | + ], | |
| 289 | + "socials": [ | |
| 290 | + "https://www.facebook.com/Ferme-des-3-Samson-626267687506723/inbox/?mailbox_id=626267687506723&selected_item_id=588406610", | |
| 291 | + "https://www.facebook.com/Producteurs-et-productrices-ac%C3%A9ricoles-du-Qu%C3%A9bec-168610803187571/", | |
| 292 | + "https://x.com/AcericoleQc", | |
| 293 | + "https://www.youtube.com/channel/UCq8BHBMeMudLzspOaF9QMOg" | |
| 294 | + ], | |
| 295 | + "discovery_sources": [ | |
| 296 | + "erabledici" | |
| 297 | + ], | |
| 298 | + "discovery_source_urls": [ | |
| 299 | + "https://www.erabledici.ca/fr/erabliere/ferme-des-3-samson-inc/" | |
| 300 | + ], | |
| 301 | + "language": "en", | |
| 302 | + "ecommerce": true, | |
| 303 | + "verification_date": "2026-08-12", | |
| 304 | + "status": "verified", | |
| 305 | + "enabled": true | |
| 306 | + }, | |
| 307 | + { | |
| 308 | + "id": "4poches.com", | |
| 309 | + "name": "PRODUCTEURS/TRANSFORMATEURS", | |
| 310 | + "url": "https://4poches.com", | |
| 311 | + "platform": "", | |
| 312 | + "catalog_endpoint": "", | |
| 313 | + "city": "", | |
| 314 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 315 | + "postal_prefix": "G4V", | |
| 316 | + "phone": "418 763-4775", | |
| 317 | + "origin_class": "A", | |
| 318 | + "origin_confidence": 0.85, | |
| 319 | + "origin_evidence": "Producteur/transformateur membre de Gaspésie Gourmande", | |
| 320 | + "categories": [ | |
| 321 | + "epicerie" | |
| 322 | + ], | |
| 323 | + "socials": [ | |
| 324 | + "https://www.facebook.com/gaspesiegourmande/", | |
| 325 | + "https://www.instagram.com/gaspesiegourmande/" | |
| 326 | + ], | |
| 327 | + "discovery_sources": [ | |
| 328 | + "gaspesiegourmande" | |
| 329 | + ], | |
| 330 | + "discovery_source_urls": [ | |
| 331 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=553&FiltreSec=2", | |
| 332 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=553&Filtre=6&FiltreSec=2", | |
| 333 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=553&Filtre=7&FiltreSec=2" | |
| 334 | + ], | |
| 335 | + "language": "en", | |
| 336 | + "ecommerce": true, | |
| 337 | + "verification_date": "2026-08-12", | |
| 338 | + "status": "verified", | |
| 339 | + "enabled": false | |
| 340 | + }, | |
| 341 | + { | |
| 342 | + "id": "57vegane.com", | |
| 343 | + "name": "57Vegane", | |
| 344 | + "url": "https://57vegane.com", | |
| 345 | + "platform": "shopify", | |
| 346 | + "catalog_endpoint": "/products.json", | |
| 347 | + "city": "", | |
| 348 | + "region": "Laval", | |
| 349 | + "postal_prefix": "H7R", | |
| 350 | + "phone": null, | |
| 351 | + "origin_class": "A", | |
| 352 | + "origin_confidence": 0.9, | |
| 353 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 354 | + "categories": [ | |
| 355 | + "epicerie" | |
| 356 | + ], | |
| 357 | + "socials": [ | |
| 358 | + "https://www.facebook.com/alimentsduquebec", | |
| 359 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 360 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 361 | + "https://www.tiktok.com/@alimentsqc" | |
| 362 | + ], | |
| 363 | + "discovery_sources": [ | |
| 364 | + "alimentsduquebec_ent" | |
| 365 | + ], | |
| 366 | + "discovery_source_urls": [ | |
| 367 | + "https://alimentsduquebec.com/entreprises/57vegane" | |
| 368 | + ], | |
| 369 | + "language": "en", | |
| 370 | + "ecommerce": true, | |
| 371 | + "verification_date": "2026-08-12", | |
| 372 | + "status": "verified", | |
| 373 | + "enabled": true | |
| 374 | + }, | |
| 375 | + { | |
| 376 | + "id": "5ebaron.com", | |
| 377 | + "name": "Microbrasserie 5e Baron", | |
| 378 | + "url": "https://www.5ebaron.com", | |
| 379 | + "platform": "wix", | |
| 380 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 381 | + "city": "", | |
| 382 | + "region": "", | |
| 383 | + "postal_prefix": "J9H", | |
| 384 | + "phone": "(819) 557-1577", | |
| 385 | + "origin_class": "A", | |
| 386 | + "origin_confidence": 0.95, | |
| 387 | + "origin_evidence": "Microbrasserie membre de l'AMBQ (brassage au Québec)", | |
| 388 | + "categories": [ | |
| 389 | + "alcool" | |
| 390 | + ], | |
| 391 | + "socials": [ | |
| 392 | + "https://www.facebook.com/bieresdemicroqc", | |
| 393 | + "https://www.instagram.com/bieresdemicroqc/", | |
| 394 | + "https://www.youtube.com/channel/UCJuiS8h8Ing9rQHrdyKDJsQ", | |
| 395 | + "https://www.facebook.com/5ebaron" | |
| 396 | + ], | |
| 397 | + "discovery_sources": [ | |
| 398 | + "ambq_profils" | |
| 399 | + ], | |
| 400 | + "discovery_source_urls": [ | |
| 401 | + "https://ambq.ca/fr/microbrasserie/microbrasserie-5e-baron" | |
| 402 | + ], | |
| 403 | + "language": null, | |
| 404 | + "ecommerce": true, | |
| 405 | + "verification_date": "2026-08-12", | |
| 406 | + "status": "verified", | |
| 407 | + "enabled": true | |
| 408 | + }, | |
| 409 | + { | |
| 410 | + "id": "94celcius.com", | |
| 411 | + "name": "94 celcius", | |
| 412 | + "url": "https://94celcius.com", | |
| 413 | + "platform": "shopify", | |
| 414 | + "catalog_endpoint": "/products.json", | |
| 415 | + "city": "", | |
| 416 | + "region": "Montérégie", | |
| 417 | + "postal_prefix": "J3E", | |
| 418 | + "phone": null, | |
| 419 | + "origin_class": "A", | |
| 420 | + "origin_confidence": 0.9, | |
| 421 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 422 | + "categories": [ | |
| 423 | + "epicerie" | |
| 424 | + ], | |
| 425 | + "socials": [ | |
| 426 | + "https://www.facebook.com/alimentsduquebec", | |
| 427 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 428 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 429 | + "https://www.tiktok.com/@alimentsqc" | |
| 430 | + ], | |
| 431 | + "discovery_sources": [ | |
| 432 | + "alimentsduquebec_ent", | |
| 433 | + "search_sweep_urban" | |
| 434 | + ], | |
| 435 | + "discovery_source_urls": [ | |
| 436 | + "https://alimentsduquebec.com/entreprises/94-celcius", | |
| 437 | + "torréfacteur café Montréal boutique en ligne livraison" | |
| 438 | + ], | |
| 439 | + "language": "en", | |
| 440 | + "ecommerce": true, | |
| 441 | + "verification_date": "2026-08-12", | |
| 442 | + "status": "verified", | |
| 443 | + "enabled": true | |
| 444 | + }, | |
| 445 | + { | |
| 446 | + "id": "abaka.ca", | |
| 447 | + "name": "Abaka", | |
| 448 | + "url": "https://abaka.ca", | |
| 449 | + "platform": "shopify", | |
| 450 | + "catalog_endpoint": "/products.json", | |
| 451 | + "city": "", | |
| 452 | + "region": "", | |
| 453 | + "postal_prefix": null, | |
| 454 | + "phone": null, | |
| 455 | + "origin_class": "A", | |
| 456 | + "origin_confidence": 0.75, | |
| 457 | + "origin_evidence": "Marque québécoise vendue par Signé Local ou Boutique Métiers d'art — site officiel résolu ; le site affiche une mention de fabrication québécoise", | |
| 458 | + "categories": [], | |
| 459 | + "socials": [], | |
| 460 | + "discovery_sources": [ | |
| 461 | + "search_sweep_vendors" | |
| 462 | + ], | |
| 463 | + "discovery_source_urls": [ | |
| 464 | + "direct domain guess abaka.ca, verified via WebFetch" | |
| 465 | + ], | |
| 466 | + "language": "en", | |
| 467 | + "ecommerce": true, | |
| 468 | + "verification_date": "2026-08-12", | |
| 469 | + "status": "verified", | |
| 470 | + "enabled": true | |
| 471 | + }, | |
| 472 | + { | |
| 473 | + "id": "abbaye.boutiquequebec.co", | |
| 474 | + "name": "Fromagerie St-Benoit-du-Lac", | |
| 475 | + "url": "https://abbaye.boutiquequebec.co", | |
| 476 | + "platform": "woocommerce", | |
| 477 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 478 | + "city": "", | |
| 479 | + "region": "Estrie", | |
| 480 | + "postal_prefix": "J0B", | |
| 481 | + "phone": "(819) 843-2861", | |
| 482 | + "origin_class": "A", | |
| 483 | + "origin_confidence": 0.7, | |
| 484 | + "origin_evidence": "Fromagerie recensée par Fromages d'ici (fabrication au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 485 | + "categories": [ | |
| 486 | + "fromage_laitier" | |
| 487 | + ], | |
| 488 | + "socials": [ | |
| 489 | + "https://www.facebook.com/alimentsduquebec", | |
| 490 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 491 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 492 | + "https://www.tiktok.com/@alimentsqc" | |
| 493 | + ], | |
| 494 | + "discovery_sources": [ | |
| 495 | + "alimentsduquebec_ent", | |
| 496 | + "createursdesaveurs", | |
| 497 | + "fromagesdici" | |
| 498 | + ], | |
| 499 | + "discovery_source_urls": [ | |
| 500 | + "https://alimentsduquebec.com/entreprises/fromagerie-st-benoit-du-lac", | |
| 501 | + "https://createursdesaveurs.com/createurs/abbaye-de-saint-benoit-du-lac/", | |
| 502 | + "https://www.fromagesdici.com/fr/fromageries/abbaye-de-saint-benoit-du-lac" | |
| 503 | + ], | |
| 504 | + "language": "en", | |
| 505 | + "ecommerce": true, | |
| 506 | + "verification_date": "2026-08-12", | |
| 507 | + "status": "probable", | |
| 508 | + "enabled": true | |
| 509 | + }, | |
| 510 | + { | |
| 511 | + "id": "abbaye.ca", | |
| 512 | + "name": "Abbaye de Saint-Benoît-du-Lac – Les producteurs de cidre du Québec", | |
| 513 | + "url": "https://www.abbaye.ca", | |
| 514 | + "platform": "", | |
| 515 | + "catalog_endpoint": "", | |
| 516 | + "city": "", | |
| 517 | + "region": "Estrie", | |
| 518 | + "postal_prefix": "J0B", | |
| 519 | + "phone": "819 843-4080", | |
| 520 | + "origin_class": "A", | |
| 521 | + "origin_confidence": 0.95, | |
| 522 | + "origin_evidence": "Cidrerie membre des Producteurs de cidre du Québec", | |
| 523 | + "categories": [ | |
| 524 | + "alcool" | |
| 525 | + ], | |
| 526 | + "socials": [ | |
| 527 | + "https://www.facebook.com/cidreduquebec", | |
| 528 | + "https://www.instagram.com/cidreduquebec", | |
| 529 | + "https://www.facebook.com/stbenoitdulac", | |
| 530 | + "https://www.instagram.com/stbenoitdulac" | |
| 531 | + ], | |
| 532 | + "discovery_sources": [ | |
| 533 | + "cidreduquebec" | |
| 534 | + ], | |
| 535 | + "discovery_source_urls": [ | |
| 536 | + "https://cidreduquebec.com/cidreries/abbaye-saint-benoit-du-lac/" | |
| 537 | + ], | |
| 538 | + "language": "fr", | |
| 539 | + "ecommerce": false, | |
| 540 | + "verification_date": "2026-08-12", | |
| 541 | + "status": "verified", | |
| 542 | + "enabled": false | |
| 543 | + }, | |
| 544 | + { | |
| 545 | + "id": "abbayevalnotredame.ca", | |
| 546 | + "name": "Magasin de l'Abbaye inc.", | |
| 547 | + "url": "https://abbayevalnotredame.ca", | |
| 548 | + "platform": "shopify", | |
| 549 | + "catalog_endpoint": "", | |
| 550 | + "city": "", | |
| 551 | + "region": "Lanaudière", | |
| 552 | + "postal_prefix": "J0K", | |
| 553 | + "phone": "450-960-2889", | |
| 554 | + "origin_class": "A", | |
| 555 | + "origin_confidence": 0.9, | |
| 556 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 557 | + "categories": [ | |
| 558 | + "epicerie" | |
| 559 | + ], | |
| 560 | + "socials": [ | |
| 561 | + "https://www.facebook.com/alimentsduquebec", | |
| 562 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 563 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 564 | + "https://www.tiktok.com/@alimentsqc" | |
| 565 | + ], | |
| 566 | + "discovery_sources": [ | |
| 567 | + "alimentsduquebec_ent" | |
| 568 | + ], | |
| 569 | + "discovery_source_urls": [ | |
| 570 | + "https://alimentsduquebec.com/entreprises/magasin-de-labbaye-inc" | |
| 571 | + ], | |
| 572 | + "language": "en", | |
| 573 | + "ecommerce": true, | |
| 574 | + "verification_date": "2026-08-12", | |
| 575 | + "status": "verified", | |
| 576 | + "enabled": false | |
| 577 | + }, | |
| 578 | + { | |
| 579 | + "id": "abeillesdelacolline.com", | |
| 580 | + "name": "Abeilles de la Colline", | |
| 581 | + "url": "https://abeillesdelacolline.com", | |
| 582 | + "platform": "woocommerce", | |
| 583 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 584 | + "city": "", | |
| 585 | + "region": "Laurentides", | |
| 586 | + "postal_prefix": "J0R", | |
| 587 | + "phone": "514 603-9520", | |
| 588 | + "origin_class": "A", | |
| 589 | + "origin_confidence": 0.85, | |
| 590 | + "origin_evidence": "Membre Laurentides j'en mange (agroalimentaire Laurentides)", | |
| 591 | + "categories": [ | |
| 592 | + "epicerie" | |
| 593 | + ], | |
| 594 | + "socials": [ | |
| 595 | + "https://www.facebook.com/laurentidesjm/", | |
| 596 | + "https://www.instagram.com/laurentidesjenmange/", | |
| 597 | + "https://www.facebook.com/abeillesdelacolline/", | |
| 598 | + "https://www.instagram.com/abeillesdelacolline/" | |
| 599 | + ], | |
| 600 | + "discovery_sources": [ | |
| 601 | + "laurentidesjenmange", | |
| 602 | + "search_sweep_food" | |
| 603 | + ], | |
| 604 | + "discovery_source_urls": [ | |
| 605 | + "https://laurentidesjenmange.ca/membre/abeilles-de-la-colline", | |
| 606 | + "miel boutique en ligne Outaouais Laurentides apiculteur artisanal" | |
| 607 | + ], | |
| 608 | + "language": "en", | |
| 609 | + "ecommerce": true, | |
| 610 | + "verification_date": "2026-08-12", | |
| 611 | + "status": "verified", | |
| 612 | + "enabled": true | |
| 613 | + }, | |
| 614 | + { | |
| 615 | + "id": "abieze.com", | |
| 616 | + "name": "abieze Lab", | |
| 617 | + "url": "https://abieze.com", | |
| 618 | + "platform": "shopify", | |
| 619 | + "catalog_endpoint": "/products.json", | |
| 620 | + "city": "", | |
| 621 | + "region": "", | |
| 622 | + "postal_prefix": "J7Y", | |
| 623 | + "phone": "514 847-9799", | |
| 624 | + "origin_class": "A", | |
| 625 | + "origin_confidence": 0.6, | |
| 626 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 627 | + "categories": [], | |
| 628 | + "socials": [ | |
| 629 | + "https://www.facebook.com/lesproduitsduqc", | |
| 630 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 631 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 632 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 633 | + ], | |
| 634 | + "discovery_sources": [ | |
| 635 | + "lesproduitsduquebec_ent" | |
| 636 | + ], | |
| 637 | + "discovery_source_urls": [ | |
| 638 | + "https://lesproduitsduquebec.com/entreprises-adherentes/abieze-lab" | |
| 639 | + ], | |
| 640 | + "language": "en", | |
| 641 | + "ecommerce": true, | |
| 642 | + "verification_date": "2026-08-12", | |
| 643 | + "status": "probable", | |
| 644 | + "enabled": true | |
| 645 | + }, | |
| 646 | + { | |
| 647 | + "id": "abmauri.com", | |
| 648 | + "name": "ACH Food Companies Inc.", | |
| 649 | + "url": "https://www.abmauri.com", | |
| 650 | + "platform": "", | |
| 651 | + "catalog_endpoint": "", | |
| 652 | + "city": "", | |
| 653 | + "region": "Montréal", | |
| 654 | + "postal_prefix": "H8R", | |
| 655 | + "phone": null, | |
| 656 | + "origin_class": "A", | |
| 657 | + "origin_confidence": 0.6, | |
| 658 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 659 | + "categories": [ | |
| 660 | + "epicerie" | |
| 661 | + ], | |
| 662 | + "socials": [ | |
| 663 | + "https://www.facebook.com/alimentsduquebec", | |
| 664 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 665 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 666 | + "https://www.tiktok.com/@alimentsqc" | |
| 667 | + ], | |
| 668 | + "discovery_sources": [ | |
| 669 | + "alimentsduquebec_ent" | |
| 670 | + ], | |
| 671 | + "discovery_source_urls": [ | |
| 672 | + "https://alimentsduquebec.com/entreprises/ach-food-companies-inc" | |
| 673 | + ], | |
| 674 | + "language": "en", | |
| 675 | + "ecommerce": false, | |
| 676 | + "verification_date": "2026-08-12", | |
| 677 | + "status": "probable", | |
| 678 | + "enabled": false | |
| 679 | + }, | |
| 680 | + { | |
| 681 | + "id": "abrisquebec.com", | |
| 682 | + "name": "Abris Québec", | |
| 683 | + "url": "https://abrisquebec.com", | |
| 684 | + "platform": "woocommerce", | |
| 685 | + "catalog_endpoint": "", | |
| 686 | + "city": "", | |
| 687 | + "region": "", | |
| 688 | + "postal_prefix": "G3G", | |
| 689 | + "phone": "418-476-8919", | |
| 690 | + "origin_class": "A", | |
| 691 | + "origin_confidence": 0.5, | |
| 692 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 693 | + "categories": [ | |
| 694 | + "industriel" | |
| 695 | + ], | |
| 696 | + "socials": [ | |
| 697 | + "facebook.com", | |
| 698 | + "instagram.com" | |
| 699 | + ], | |
| 700 | + "discovery_sources": [ | |
| 701 | + "search_sweep_manuf" | |
| 702 | + ], | |
| 703 | + "discovery_source_urls": [ | |
| 704 | + "fabricant québécois abris garages de toile boutique en ligne" | |
| 705 | + ], | |
| 706 | + "language": "fr", | |
| 707 | + "ecommerce": true, | |
| 708 | + "verification_date": "2026-08-12", | |
| 709 | + "status": "probable", | |
| 710 | + "enabled": false | |
| 711 | + }, | |
| 712 | + { | |
| 713 | + "id": "absinthequebec.com", | |
| 714 | + "name": "Absintherie des Cantons", | |
| 715 | + "url": "https://www.absinthequebec.com", | |
| 716 | + "platform": "wix", | |
| 717 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 718 | + "city": "", | |
| 719 | + "region": "Estrie", | |
| 720 | + "postal_prefix": "J2J", | |
| 721 | + "phone": "450 776-1811", | |
| 722 | + "origin_class": "A", | |
| 723 | + "origin_confidence": 0.9, | |
| 724 | + "origin_evidence": "Distillerie recensée par Distilleries du Québec", | |
| 725 | + "categories": [ | |
| 726 | + "alcool" | |
| 727 | + ], | |
| 728 | + "socials": [ | |
| 729 | + "https://www.facebook.com/Absintheriedescantons", | |
| 730 | + "https://www.instagram.com/accounts/login/?next=%2Fabsintheriedescantons%2F&source=omni_redirect", | |
| 731 | + "https://www.facebook.com/distilleriedequebec/", | |
| 732 | + "https://www.instagram.com/distilleriedequebec/" | |
| 733 | + ], | |
| 734 | + "discovery_sources": [ | |
| 735 | + "distilleriesduquebec" | |
| 736 | + ], | |
| 737 | + "discovery_source_urls": [ | |
| 738 | + "https://distilleriesduquebec.com/fr/le-repertoire-des-distilleries/absintherie-des-cantons" | |
| 739 | + ], | |
| 740 | + "language": "en", | |
| 741 | + "ecommerce": true, | |
| 742 | + "verification_date": "2026-08-12", | |
| 743 | + "status": "verified", | |
| 744 | + "enabled": true | |
| 745 | + }, | |
| 746 | + { | |
| 747 | + "id": "accespsy.ca", | |
| 748 | + "name": "Casquette brodée - Acheter québécois", | |
| 749 | + "url": "https://accespsy.ca", | |
| 750 | + "platform": "", | |
| 751 | + "catalog_endpoint": "", | |
| 752 | + "city": "", | |
| 753 | + "region": "", | |
| 754 | + "postal_prefix": "J7N", | |
| 755 | + "phone": "(450) 403-0603", | |
| 756 | + "origin_class": "A", | |
| 757 | + "origin_confidence": 0.3, | |
| 758 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 759 | + "categories": [], | |
| 760 | + "socials": [ | |
| 761 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 762 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 763 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 764 | + "https://www.facebook.com/acheterquebecois/" | |
| 765 | + ], | |
| 766 | + "discovery_sources": [ | |
| 767 | + "acheterquebecois" | |
| 768 | + ], | |
| 769 | + "discovery_source_urls": [ | |
| 770 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/accessoires-mode/casquette-brodee/" | |
| 771 | + ], | |
| 772 | + "language": null, | |
| 773 | + "ecommerce": false, | |
| 774 | + "verification_date": "2026-08-12", | |
| 775 | + "status": "probable", | |
| 776 | + "enabled": false | |
| 777 | + }, | |
| 778 | + { | |
| 779 | + "id": "achillea-boutique.com", | |
| 780 | + "name": "L’Achillée", | |
| 781 | + "url": "https://www.achillea-boutique.com", | |
| 782 | + "platform": "square", | |
| 783 | + "catalog_endpoint": "", | |
| 784 | + "city": "", | |
| 785 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 786 | + "postal_prefix": null, | |
| 787 | + "phone": null, | |
| 788 | + "origin_class": "A", | |
| 789 | + "origin_confidence": 0.85, | |
| 790 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 791 | + "categories": [ | |
| 792 | + "epicerie" | |
| 793 | + ], | |
| 794 | + "socials": [ | |
| 795 | + "https://www.facebook.com/zoneboreale", | |
| 796 | + "https://www.instagram.com/zone_boreale/", | |
| 797 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 798 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 799 | + ], | |
| 800 | + "discovery_sources": [ | |
| 801 | + "zoneboreale" | |
| 802 | + ], | |
| 803 | + "discovery_source_urls": [ | |
| 804 | + "https://zoneboreale.com/entreprises/lachillee/" | |
| 805 | + ], | |
| 806 | + "language": "fr", | |
| 807 | + "ecommerce": true, | |
| 808 | + "verification_date": "2026-08-12", | |
| 809 | + "status": "verified", | |
| 810 | + "enabled": false | |
| 811 | + }, | |
| 812 | + { | |
| 813 | + "id": "acti-sol.ca", | |
| 814 | + "name": "Acti-Sol inc.", | |
| 815 | + "url": "https://acti-sol.ca", | |
| 816 | + "platform": "woocommerce", | |
| 817 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 818 | + "city": "", | |
| 819 | + "region": "Centre-du-Québec", | |
| 820 | + "postal_prefix": "J0C", | |
| 821 | + "phone": "514 847-9799", | |
| 822 | + "origin_class": "A", | |
| 823 | + "origin_confidence": 0.85, | |
| 824 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 825 | + "categories": [], | |
| 826 | + "socials": [ | |
| 827 | + "https://www.facebook.com/lesproduitsduqc", | |
| 828 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 829 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 830 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 831 | + ], | |
| 832 | + "discovery_sources": [ | |
| 833 | + "lesproduitsduquebec_ent" | |
| 834 | + ], | |
| 835 | + "discovery_source_urls": [ | |
| 836 | + "https://lesproduitsduquebec.com/entreprises-adherentes/acti-sol-inc" | |
| 837 | + ], | |
| 838 | + "language": "fr", | |
| 839 | + "ecommerce": true, | |
| 840 | + "verification_date": "2026-08-12", | |
| 841 | + "status": "verified", | |
| 842 | + "enabled": true | |
| 843 | + }, | |
| 844 | + { | |
| 845 | + "id": "adafresh.shop", | |
| 846 | + "name": "Ada Fresh", | |
| 847 | + "url": "https://adafresh.shop", | |
| 848 | + "platform": "woocommerce", | |
| 849 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 850 | + "city": "", | |
| 851 | + "region": "", | |
| 852 | + "postal_prefix": null, | |
| 853 | + "phone": "5819905848", | |
| 854 | + "origin_class": "A", | |
| 855 | + "origin_confidence": 0.85, | |
| 856 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 857 | + "categories": [ | |
| 858 | + "epicerie" | |
| 859 | + ], | |
| 860 | + "socials": [ | |
| 861 | + "https://www.facebook.com/alimentsduquebec", | |
| 862 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 863 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 864 | + "https://www.tiktok.com/@alimentsqc" | |
| 865 | + ], | |
| 866 | + "discovery_sources": [ | |
| 867 | + "alimentsduquebec_ent" | |
| 868 | + ], | |
| 869 | + "discovery_source_urls": [ | |
| 870 | + "https://alimentsduquebec.com/entreprises/ada-fresh" | |
| 871 | + ], | |
| 872 | + "language": "en", | |
| 873 | + "ecommerce": true, | |
| 874 | + "verification_date": "2026-08-12", | |
| 875 | + "status": "verified", | |
| 876 | + "enabled": true | |
| 877 | + }, | |
| 878 | + { | |
| 879 | + "id": "adfast.store", | |
| 880 | + "name": "Adfast", | |
| 881 | + "url": "https://adfast.store", | |
| 882 | + "platform": "", | |
| 883 | + "catalog_endpoint": "", | |
| 884 | + "city": "", | |
| 885 | + "region": "Montréal", | |
| 886 | + "postal_prefix": "H4S", | |
| 887 | + "phone": "514 847-9799", | |
| 888 | + "origin_class": "A", | |
| 889 | + "origin_confidence": 0.6, | |
| 890 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 891 | + "categories": [], | |
| 892 | + "socials": [ | |
| 893 | + "https://www.facebook.com/lesproduitsduqc", | |
| 894 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 895 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 896 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 897 | + ], | |
| 898 | + "discovery_sources": [ | |
| 899 | + "lesproduitsduquebec_ent" | |
| 900 | + ], | |
| 901 | + "discovery_source_urls": [ | |
| 902 | + "https://lesproduitsduquebec.com/entreprises-adherentes/adfast" | |
| 903 | + ], | |
| 904 | + "language": null, | |
| 905 | + "ecommerce": false, | |
| 906 | + "verification_date": "2026-08-12", | |
| 907 | + "status": "probable", | |
| 908 | + "enabled": false | |
| 909 | + }, | |
| 910 | + { | |
| 911 | + "id": "adriengagnon.com", | |
| 912 | + "name": "Adrien Gagnon", | |
| 913 | + "url": "https://adriengagnon.com", | |
| 914 | + "platform": "shopify", | |
| 915 | + "catalog_endpoint": "/products.json", | |
| 916 | + "city": "", | |
| 917 | + "region": "", | |
| 918 | + "postal_prefix": "J4Y", | |
| 919 | + "phone": "514 847-9799", | |
| 920 | + "origin_class": "A", | |
| 921 | + "origin_confidence": 0.85, | |
| 922 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 923 | + "categories": [], | |
| 924 | + "socials": [ | |
| 925 | + "https://www.facebook.com/lesproduitsduqc", | |
| 926 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 927 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 928 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 929 | + ], | |
| 930 | + "discovery_sources": [ | |
| 931 | + "lesproduitsduquebec_ent" | |
| 932 | + ], | |
| 933 | + "discovery_source_urls": [ | |
| 934 | + "https://lesproduitsduquebec.com/entreprises-adherentes/adrien-gagnon" | |
| 935 | + ], | |
| 936 | + "language": "en", | |
| 937 | + "ecommerce": true, | |
| 938 | + "verification_date": "2026-08-12", | |
| 939 | + "status": "verified", | |
| 940 | + "enabled": true | |
| 941 | + }, | |
| 942 | + { | |
| 943 | + "id": "affiliationfocus.com", | |
| 944 | + "name": "Alcyon", | |
| 945 | + "url": "https://affiliationfocus.com", | |
| 946 | + "platform": "wordpress", | |
| 947 | + "catalog_endpoint": "", | |
| 948 | + "city": "", | |
| 949 | + "region": "", | |
| 950 | + "postal_prefix": null, | |
| 951 | + "phone": null, | |
| 952 | + "origin_class": "A", | |
| 953 | + "origin_confidence": 0.55, | |
| 954 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec", | |
| 955 | + "categories": [], | |
| 956 | + "socials": [ | |
| 957 | + "https://www.instagram.com/ideecadeauquebec/", | |
| 958 | + "https://www.facebook.com/IdeeCadeauQuebec/", | |
| 959 | + "https://pinterest.com/IdeeCadeauQC", | |
| 960 | + "https://twitter.com/IdeeCadeauQC" | |
| 961 | + ], | |
| 962 | + "discovery_sources": [ | |
| 963 | + "ideecadeauquebec" | |
| 964 | + ], | |
| 965 | + "discovery_source_urls": [ | |
| 966 | + "https://www.ideecadeauquebec.com/boutique/alcyonsel/", | |
| 967 | + "https://www.ideecadeauquebec.com/boutique/allosimonne/", | |
| 968 | + "https://www.ideecadeauquebec.com/boutique/amareebasse/", | |
| 969 | + "https://www.ideecadeauquebec.com/boutique/aperoazero/", | |
| 970 | + "https://www.ideecadeauquebec.com/boutique/aquaovo/" | |
| 971 | + ], | |
| 972 | + "language": "fr", | |
| 973 | + "ecommerce": true, | |
| 974 | + "verification_date": "2026-08-12", | |
| 975 | + "status": "probable", | |
| 976 | + "enabled": false | |
| 977 | + }, | |
| 978 | + { | |
| 979 | + "id": "agenceoasis.ca", | |
| 980 | + "name": "Oasis Communication Marketing", | |
| 981 | + "url": "https://agenceoasis.ca", | |
| 982 | + "platform": "wordpress", | |
| 983 | + "catalog_endpoint": "", | |
| 984 | + "city": "", | |
| 985 | + "region": "Capitale-Nationale", | |
| 986 | + "postal_prefix": "G1K", | |
| 987 | + "phone": "581 981-3355", | |
| 988 | + "origin_class": "A", | |
| 989 | + "origin_confidence": 0.7, | |
| 990 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 991 | + "categories": [ | |
| 992 | + "epicerie" | |
| 993 | + ], | |
| 994 | + "socials": [ | |
| 995 | + "https://www.instagram.com/agence_oasis/?hl=fr", | |
| 996 | + "https://www.facebook.com/agenceoasiscommunication/", | |
| 997 | + "https://www.linkedin.com/uas/login?session_redirect=%2Fcompany%2F598380", | |
| 998 | + "https://www.youtube.com/channel/UCPo5U_5zwrP11ZC8tbDR7CQ" | |
| 999 | + ], | |
| 1000 | + "discovery_sources": [ | |
| 1001 | + "charlevoix" | |
| 1002 | + ], | |
| 1003 | + "discovery_source_urls": [ | |
| 1004 | + "https://www.tourisme-charlevoix.com/fr/entreprises/oasis-communication-marketing" | |
| 1005 | + ], | |
| 1006 | + "language": "en", | |
| 1007 | + "ecommerce": false, | |
| 1008 | + "verification_date": "2026-08-12", | |
| 1009 | + "status": "verified", | |
| 1010 | + "enabled": false | |
| 1011 | + }, | |
| 1012 | + { | |
| 1013 | + "id": "agencepixel.ca", | |
| 1014 | + "name": "Marché Centre-Ville de Maniwaki", | |
| 1015 | + "url": "https://agencepixel.ca", | |
| 1016 | + "platform": "wordpress", | |
| 1017 | + "catalog_endpoint": "", | |
| 1018 | + "city": "", | |
| 1019 | + "region": "Outaouais", | |
| 1020 | + "postal_prefix": "J9E", | |
| 1021 | + "phone": "819-449-2800", | |
| 1022 | + "origin_class": "A", | |
| 1023 | + "origin_confidence": 0.85, | |
| 1024 | + "origin_evidence": "Entreprise membre de Croquez Outaouais (agroalimentaire Outaouais)", | |
| 1025 | + "categories": [ | |
| 1026 | + "epicerie" | |
| 1027 | + ], | |
| 1028 | + "socials": [ | |
| 1029 | + "https://www.facebook.com/croquezloutaouais/", | |
| 1030 | + "https://www.instagram.com/croquezoutaouais", | |
| 1031 | + "https://web.facebook.com/croquezloutaouais/?_rdc=1&_rdr", | |
| 1032 | + "https://www.instagram.com/croquezoutaouais/" | |
| 1033 | + ], | |
| 1034 | + "discovery_sources": [ | |
| 1035 | + "croquezoutaouais" | |
| 1036 | + ], | |
| 1037 | + "discovery_source_urls": [ | |
| 1038 | + "https://www.croquezoutaouais.com/entreprises/marche-centre-ville-de-maniwaki/", | |
| 1039 | + "https://www.croquezoutaouais.com/entreprises/marche-lac-sainte-marie-market/", | |
| 1040 | + "https://www.croquezoutaouais.com/entreprises/marche-chelsea-market/", | |
| 1041 | + "https://www.croquezoutaouais.com/entreprises/marche-de-val-des-monts/", | |
| 1042 | + "https://www.croquezoutaouais.com/entreprises/marche-fermier-de-buckingham/" | |
| 1043 | + ], | |
| 1044 | + "language": "fr", | |
| 1045 | + "ecommerce": false, | |
| 1046 | + "verification_date": "2026-08-12", | |
| 1047 | + "status": "verified", | |
| 1048 | + "enabled": false | |
| 1049 | + }, | |
| 1050 | + { | |
| 1051 | + "id": "agitforesterie.ca", | |
| 1052 | + "name": "AGIT Foresterie inc", | |
| 1053 | + "url": "https://agitforesterie.ca", | |
| 1054 | + "platform": "", | |
| 1055 | + "catalog_endpoint": "", | |
| 1056 | + "city": "", | |
| 1057 | + "region": "Mauricie", | |
| 1058 | + "postal_prefix": "J0K", | |
| 1059 | + "phone": "819 228-2462", | |
| 1060 | + "origin_class": "A", | |
| 1061 | + "origin_confidence": 0.7, | |
| 1062 | + "origin_evidence": "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 1063 | + "categories": [ | |
| 1064 | + "erable" | |
| 1065 | + ], | |
| 1066 | + "socials": [ | |
| 1067 | + "https://m.facebook.com/AGITforesterie/", | |
| 1068 | + "https://www.facebook.com/Producteurs-et-productrices-ac%C3%A9ricoles-du-Qu%C3%A9bec-168610803187571/", | |
| 1069 | + "https://x.com/AcericoleQc", | |
| 1070 | + "https://www.youtube.com/channel/UCq8BHBMeMudLzspOaF9QMOg" | |
| 1071 | + ], | |
| 1072 | + "discovery_sources": [ | |
| 1073 | + "erabledici" | |
| 1074 | + ], | |
| 1075 | + "discovery_source_urls": [ | |
| 1076 | + "https://www.erabledici.ca/fr/erabliere/agit-foresterie-inc/" | |
| 1077 | + ], | |
| 1078 | + "language": "fr", | |
| 1079 | + "ecommerce": false, | |
| 1080 | + "verification_date": "2026-08-12", | |
| 1081 | + "status": "probable", | |
| 1082 | + "enabled": false | |
| 1083 | + }, | |
| 1084 | + { | |
| 1085 | + "id": "agneauauxalgues.ca", | |
| 1086 | + "name": "PRODUCTEURS/TRANSFORMATEURS", | |
| 1087 | + "url": "https://agneauauxalgues.ca", | |
| 1088 | + "platform": "woocommerce", | |
| 1089 | + "catalog_endpoint": "", | |
| 1090 | + "city": "", | |
| 1091 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 1092 | + "postal_prefix": "G0C", | |
| 1093 | + "phone": "581 886-0555", | |
| 1094 | + "origin_class": "A", | |
| 1095 | + "origin_confidence": 0.85, | |
| 1096 | + "origin_evidence": "Producteur/transformateur membre de Gaspésie Gourmande", | |
| 1097 | + "categories": [ | |
| 1098 | + "epicerie" | |
| 1099 | + ], | |
| 1100 | + "socials": [ | |
| 1101 | + "https://www.facebook.com/gaspesiegourmande/", | |
| 1102 | + "https://www.instagram.com/gaspesiegourmande/" | |
| 1103 | + ], | |
| 1104 | + "discovery_sources": [ | |
| 1105 | + "gaspesiegourmande" | |
| 1106 | + ], | |
| 1107 | + "discovery_source_urls": [ | |
| 1108 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=611&FiltreSec=4", | |
| 1109 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=611&Filtre=10&FiltreSec=4" | |
| 1110 | + ], | |
| 1111 | + "language": "en", | |
| 1112 | + "ecommerce": true, | |
| 1113 | + "verification_date": "2026-08-12", | |
| 1114 | + "status": "verified", | |
| 1115 | + "enabled": false | |
| 1116 | + }, | |
| 1117 | + { | |
| 1118 | + "id": "agneaudufjord.com", | |
| 1119 | + "name": "La Vieille Ferme", | |
| 1120 | + "url": "https://agneaudufjord.com", | |
| 1121 | + "platform": "shopify", | |
| 1122 | + "catalog_endpoint": "", | |
| 1123 | + "city": "", | |
| 1124 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 1125 | + "postal_prefix": "G0V", | |
| 1126 | + "phone": "418 674-1237", | |
| 1127 | + "origin_class": "A", | |
| 1128 | + "origin_confidence": 0.9, | |
| 1129 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean) ; le site affiche une mention de fabrication québécoise", | |
| 1130 | + "categories": [ | |
| 1131 | + "epicerie" | |
| 1132 | + ], | |
| 1133 | + "socials": [ | |
| 1134 | + "https://www.facebook.com/zoneboreale", | |
| 1135 | + "https://www.instagram.com/zone_boreale/", | |
| 1136 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 1137 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 1138 | + ], | |
| 1139 | + "discovery_sources": [ | |
| 1140 | + "zoneboreale" | |
| 1141 | + ], | |
| 1142 | + "discovery_source_urls": [ | |
| 1143 | + "https://zoneboreale.com/entreprises/bergerie-la-vieille-ferme/" | |
| 1144 | + ], | |
| 1145 | + "language": "fr", | |
| 1146 | + "ecommerce": false, | |
| 1147 | + "verification_date": "2026-08-12", | |
| 1148 | + "status": "verified", | |
| 1149 | + "enabled": false | |
| 1150 | + }, | |
| 1151 | + { | |
| 1152 | + "id": "agropur.com", | |
| 1153 | + "name": "Fromagerie d’Oka - Agropur", | |
| 1154 | + "url": "https://www.agropur.com", | |
| 1155 | + "platform": "", | |
| 1156 | + "catalog_endpoint": "", | |
| 1157 | + "city": "", | |
| 1158 | + "region": "Laurentides", | |
| 1159 | + "postal_prefix": "J0N", | |
| 1160 | + "phone": "450 530-2436", | |
| 1161 | + "origin_class": "A", | |
| 1162 | + "origin_confidence": 0.7, | |
| 1163 | + "origin_evidence": "Fromagerie recensée par Fromages d'ici (fabrication au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 1164 | + "categories": [ | |
| 1165 | + "fromage_laitier" | |
| 1166 | + ], | |
| 1167 | + "socials": [ | |
| 1168 | + "https://www.facebook.com/fromagesdici", | |
| 1169 | + "https://www.youtube.com/channel/UCxSGDuWDPFOvNy7xP_LylcA", | |
| 1170 | + "https://www.instagram.com/fromages.dici/", | |
| 1171 | + "https://www.pinterest.ca/fromagesdici/" | |
| 1172 | + ], | |
| 1173 | + "discovery_sources": [ | |
| 1174 | + "fromagesdici" | |
| 1175 | + ], | |
| 1176 | + "discovery_source_urls": [ | |
| 1177 | + "https://www.fromagesdici.com/fr/fromageries/fromagerie-oka", | |
| 1178 | + "https://www.fromagesdici.com/fr/fromageries/agropur", | |
| 1179 | + "https://www.fromagesdici.com/fr/fromageries/fromagerie-de-granby-agropur", | |
| 1180 | + "https://www.fromagesdici.com/fr/fromageries/fromagerie-de-beauceville-agropur" | |
| 1181 | + ], | |
| 1182 | + "language": "fr", | |
| 1183 | + "ecommerce": false, | |
| 1184 | + "verification_date": "2026-08-12", | |
| 1185 | + "status": "probable", | |
| 1186 | + "enabled": false | |
| 1187 | + }, | |
| 1188 | + { | |
| 1189 | + "id": "ahlavacheresto.com", | |
| 1190 | + "name": "Ah la Vache!", | |
| 1191 | + "url": "https://ahlavacheresto.com", | |
| 1192 | + "platform": "wordpress", | |
| 1193 | + "catalog_endpoint": "", | |
| 1194 | + "city": "", | |
| 1195 | + "region": "Capitale-Nationale", | |
| 1196 | + "postal_prefix": "G3Z", | |
| 1197 | + "phone": "418 760-8383", | |
| 1198 | + "origin_class": "A", | |
| 1199 | + "origin_confidence": 0.7, | |
| 1200 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 1201 | + "categories": [ | |
| 1202 | + "epicerie" | |
| 1203 | + ], | |
| 1204 | + "socials": [ | |
| 1205 | + "https://www.instagram.com/ahlavachefondue/", | |
| 1206 | + "https://www.facebook.com/ahlavachefondue", | |
| 1207 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 1208 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 1209 | + ], | |
| 1210 | + "discovery_sources": [ | |
| 1211 | + "charlevoix" | |
| 1212 | + ], | |
| 1213 | + "discovery_source_urls": [ | |
| 1214 | + "https://www.tourisme-charlevoix.com/fr/entreprises/ah-la-vache" | |
| 1215 | + ], | |
| 1216 | + "language": "fr", | |
| 1217 | + "ecommerce": false, | |
| 1218 | + "verification_date": "2026-08-12", | |
| 1219 | + "status": "probable", | |
| 1220 | + "enabled": false | |
| 1221 | + }, | |
| 1222 | + { | |
| 1223 | + "id": "crunature.com", | |
| 1224 | + "name": "Ah ! Tout cru dans le bec…", | |
| 1225 | + "url": "https://crunature.com", | |
| 1226 | + "platform": "shopify", | |
| 1227 | + "catalog_endpoint": "/products.json", | |
| 1228 | + "city": "", | |
| 1229 | + "region": "Laurentides", | |
| 1230 | + "postal_prefix": "J0R", | |
| 1231 | + "phone": "514 232-1481", | |
| 1232 | + "origin_class": "A", | |
| 1233 | + "origin_confidence": 0.9, | |
| 1234 | + "origin_evidence": "Membre Laurentides j'en mange (agroalimentaire Laurentides) ; le site affiche une mention de fabrication québécoise", | |
| 1235 | + "categories": [ | |
| 1236 | + "epicerie" | |
| 1237 | + ], | |
| 1238 | + "socials": [ | |
| 1239 | + "https://www.facebook.com/laurentidesjm/", | |
| 1240 | + "https://www.instagram.com/laurentidesjenmange/", | |
| 1241 | + "https://www.facebook.com/crudanslebec/" | |
| 1242 | + ], | |
| 1243 | + "discovery_sources": [ | |
| 1244 | + "laurentidesjenmange", | |
| 1245 | + "alimentsduquebec_ent" | |
| 1246 | + ], | |
| 1247 | + "discovery_source_urls": [ | |
| 1248 | + "https://laurentidesjenmange.ca/membre/ah%e2%80%89-tout-cru-dans-le-bec" | |
| 1249 | + ], | |
| 1250 | + "language": "en", | |
| 1251 | + "ecommerce": true, | |
| 1252 | + "verification_date": "2026-08-12", | |
| 1253 | + "status": "verified", | |
| 1254 | + "enabled": true | |
| 1255 | + }, | |
| 1256 | + { | |
| 1257 | + "id": "aildumoulin.com", | |
| 1258 | + "name": "Ail du Moulin", | |
| 1259 | + "url": "https://aildumoulin.com", | |
| 1260 | + "platform": "shopify", | |
| 1261 | + "catalog_endpoint": "/products.json", | |
| 1262 | + "city": "", | |
| 1263 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 1264 | + "postal_prefix": "G7H", | |
| 1265 | + "phone": "581 306-7244", | |
| 1266 | + "origin_class": "A", | |
| 1267 | + "origin_confidence": 0.85, | |
| 1268 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 1269 | + "categories": [ | |
| 1270 | + "epicerie" | |
| 1271 | + ], | |
| 1272 | + "socials": [ | |
| 1273 | + "https://www.facebook.com/zoneboreale", | |
| 1274 | + "https://www.instagram.com/zone_boreale/", | |
| 1275 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 1276 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 1277 | + ], | |
| 1278 | + "discovery_sources": [ | |
| 1279 | + "zoneboreale" | |
| 1280 | + ], | |
| 1281 | + "discovery_source_urls": [ | |
| 1282 | + "https://zoneboreale.com/entreprises/ail-du-moulin/" | |
| 1283 | + ], | |
| 1284 | + "language": "en", | |
| 1285 | + "ecommerce": true, | |
| 1286 | + "verification_date": "2026-08-12", | |
| 1287 | + "status": "verified", | |
| 1288 | + "enabled": true | |
| 1289 | + }, | |
| 1290 | + { | |
| 1291 | + "id": "airdistillerie.com", | |
| 1292 | + "name": "ARTIST IN RESIDENCE DISTILLERIE", | |
| 1293 | + "url": "https://airdistillerie.com", | |
| 1294 | + "platform": "woocommerce", | |
| 1295 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 1296 | + "city": "", | |
| 1297 | + "region": "Outaouais", | |
| 1298 | + "postal_prefix": "J8R", | |
| 1299 | + "phone": "819-281-7676", | |
| 1300 | + "origin_class": "A", | |
| 1301 | + "origin_confidence": 0.85, | |
| 1302 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 1303 | + "categories": [ | |
| 1304 | + "epicerie" | |
| 1305 | + ], | |
| 1306 | + "socials": [ | |
| 1307 | + "https://www.facebook.com/alimentsduquebec", | |
| 1308 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1309 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1310 | + "https://www.tiktok.com/@alimentsqc" | |
| 1311 | + ], | |
| 1312 | + "discovery_sources": [ | |
| 1313 | + "alimentsduquebec_ent", | |
| 1314 | + "croquezoutaouais" | |
| 1315 | + ], | |
| 1316 | + "discovery_source_urls": [ | |
| 1317 | + "https://alimentsduquebec.com/entreprises/artist-in-residence-distillerie", | |
| 1318 | + "https://www.croquezoutaouais.com/entreprises/distillerie-artist-in-residence/" | |
| 1319 | + ], | |
| 1320 | + "language": "en", | |
| 1321 | + "ecommerce": true, | |
| 1322 | + "verification_date": "2026-08-12", | |
| 1323 | + "status": "verified", | |
| 1324 | + "enabled": true | |
| 1325 | + }, | |
| 1326 | + { | |
| 1327 | + "id": "airellesdesfreres.com", | |
| 1328 | + "name": "Les Airelles des Frères", | |
| 1329 | + "url": "https://airellesdesfreres.com", | |
| 1330 | + "platform": "woocommerce", | |
| 1331 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 1332 | + "city": "", | |
| 1333 | + "region": "Chaudière-Appalaches", | |
| 1334 | + "postal_prefix": "G0R", | |
| 1335 | + "phone": null, | |
| 1336 | + "origin_class": "A", | |
| 1337 | + "origin_confidence": 0.85, | |
| 1338 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 1339 | + "categories": [ | |
| 1340 | + "epicerie" | |
| 1341 | + ], | |
| 1342 | + "socials": [ | |
| 1343 | + "https://www.facebook.com/alimentsduquebec", | |
| 1344 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1345 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1346 | + "https://www.tiktok.com/@alimentsqc" | |
| 1347 | + ], | |
| 1348 | + "discovery_sources": [ | |
| 1349 | + "alimentsduquebec_ent" | |
| 1350 | + ], | |
| 1351 | + "discovery_source_urls": [ | |
| 1352 | + "https://alimentsduquebec.com/entreprises/les-airelles-des-freres" | |
| 1353 | + ], | |
| 1354 | + "language": "en", | |
| 1355 | + "ecommerce": true, | |
| 1356 | + "verification_date": "2026-08-12", | |
| 1357 | + "status": "verified", | |
| 1358 | + "enabled": true | |
| 1359 | + }, | |
| 1360 | + { | |
| 1361 | + "id": "airsolutionconfort.com", | |
| 1362 | + "name": "Les meilleurs installateurs de thermopompes dans Lanaudière en 2026", | |
| 1363 | + "url": "https://airsolutionconfort.com", | |
| 1364 | + "platform": "woocommerce", | |
| 1365 | + "catalog_endpoint": "", | |
| 1366 | + "city": "", | |
| 1367 | + "region": "", | |
| 1368 | + "postal_prefix": null, | |
| 1369 | + "phone": "(450) 403-0603", | |
| 1370 | + "origin_class": "A", | |
| 1371 | + "origin_confidence": 0.55, | |
| 1372 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca", | |
| 1373 | + "categories": [], | |
| 1374 | + "socials": [ | |
| 1375 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 1376 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 1377 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 1378 | + "https://www.facebook.com/acheterquebecois/" | |
| 1379 | + ], | |
| 1380 | + "discovery_sources": [ | |
| 1381 | + "acheterquebecois" | |
| 1382 | + ], | |
| 1383 | + "discovery_source_urls": [ | |
| 1384 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/services-aux-particuliers/installation-de-thermopompes-dans-lanaudiere/" | |
| 1385 | + ], | |
| 1386 | + "language": "fr", | |
| 1387 | + "ecommerce": false, | |
| 1388 | + "verification_date": "2026-08-12", | |
| 1389 | + "status": "probable", | |
| 1390 | + "enabled": false | |
| 1391 | + }, | |
| 1392 | + { | |
| 1393 | + "id": "akiraboisetdesign.com", | |
| 1394 | + "name": "Akira Bois et Design", | |
| 1395 | + "url": "https://akiraboisetdesign.com", | |
| 1396 | + "platform": "", | |
| 1397 | + "catalog_endpoint": "", | |
| 1398 | + "city": "", | |
| 1399 | + "region": "Laurentides", | |
| 1400 | + "postal_prefix": "J7J", | |
| 1401 | + "phone": "514-702-9129", | |
| 1402 | + "origin_class": "A", | |
| 1403 | + "origin_confidence": 0.5, | |
| 1404 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC)", | |
| 1405 | + "categories": [], | |
| 1406 | + "socials": [ | |
| 1407 | + "facebook.com" | |
| 1408 | + ], | |
| 1409 | + "discovery_sources": [ | |
| 1410 | + "search_sweep_nonfood" | |
| 1411 | + ], | |
| 1412 | + "discovery_source_urls": [ | |
| 1413 | + "meubles bois massif fabricant Laurentides boutique en ligne" | |
| 1414 | + ], | |
| 1415 | + "language": "fr", | |
| 1416 | + "ecommerce": false, | |
| 1417 | + "verification_date": "2026-08-12", | |
| 1418 | + "status": "probable", | |
| 1419 | + "enabled": false | |
| 1420 | + }, | |
| 1421 | + { | |
| 1422 | + "id": "akisushi.ca", | |
| 1423 | + "name": "Les Aliments Saveur du Monde inc. - Aki Sushi", | |
| 1424 | + "url": "https://akisushi.ca", | |
| 1425 | + "platform": "woocommerce", | |
| 1426 | + "catalog_endpoint": "", | |
| 1427 | + "city": "", | |
| 1428 | + "region": "Capitale-Nationale", | |
| 1429 | + "postal_prefix": "G1R", | |
| 1430 | + "phone": "418-306-2282", | |
| 1431 | + "origin_class": "A", | |
| 1432 | + "origin_confidence": 0.85, | |
| 1433 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 1434 | + "categories": [ | |
| 1435 | + "epicerie" | |
| 1436 | + ], | |
| 1437 | + "socials": [ | |
| 1438 | + "https://www.facebook.com/alimentsduquebec", | |
| 1439 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1440 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1441 | + "https://www.tiktok.com/@alimentsqc" | |
| 1442 | + ], | |
| 1443 | + "discovery_sources": [ | |
| 1444 | + "alimentsduquebec_ent" | |
| 1445 | + ], | |
| 1446 | + "discovery_source_urls": [ | |
| 1447 | + "https://alimentsduquebec.com/entreprises/les-aliments-saveur-du-monde-inc-aki-sushi" | |
| 1448 | + ], | |
| 1449 | + "language": "fr", | |
| 1450 | + "ecommerce": false, | |
| 1451 | + "verification_date": "2026-08-12", | |
| 1452 | + "status": "verified", | |
| 1453 | + "enabled": false | |
| 1454 | + }, | |
| 1455 | + { | |
| 1456 | + "id": "akufen.ca", | |
| 1457 | + "name": "Notre mission : défendre et représenter les distilleries d'ici", | |
| 1458 | + "url": "https://www.akufen.ca", | |
| 1459 | + "platform": "", | |
| 1460 | + "catalog_endpoint": "", | |
| 1461 | + "city": "", | |
| 1462 | + "region": "", | |
| 1463 | + "postal_prefix": "J2J", | |
| 1464 | + "phone": "450 776-1811", | |
| 1465 | + "origin_class": "A", | |
| 1466 | + "origin_confidence": 0.9, | |
| 1467 | + "origin_evidence": "Distillerie recensée par Distilleries du Québec", | |
| 1468 | + "categories": [ | |
| 1469 | + "alcool" | |
| 1470 | + ], | |
| 1471 | + "socials": [ | |
| 1472 | + "https://www.facebook.com/distilleriedequebec/", | |
| 1473 | + "https://www.instagram.com/distilleriedequebec/", | |
| 1474 | + "https://www.linkedin.com/company/union-québécoise-des-microdistilleries/?originalSubdomain=ca", | |
| 1475 | + "https://www.youtube.com/@distilleriesqc" | |
| 1476 | + ], | |
| 1477 | + "discovery_sources": [ | |
| 1478 | + "distilleriesduquebec" | |
| 1479 | + ], | |
| 1480 | + "discovery_source_urls": [ | |
| 1481 | + "https://distilleriesduquebec.com/fr/a-propos-de-lassociation", | |
| 1482 | + "https://distilleriesduquebec.com/fr/conseil-dadministration", | |
| 1483 | + "https://distilleriesduquebec.com/fr/journeedistilleries", | |
| 1484 | + "https://distilleriesduquebec.com/fr/le-repertoire-des-distilleries/absintherie-des-cantons", | |
| 1485 | + "https://distilleriesduquebec.com/fr/le-repertoire-des-distilleries/aurel-et-compagnie" | |
| 1486 | + ], | |
| 1487 | + "language": "fr", | |
| 1488 | + "ecommerce": false, | |
| 1489 | + "verification_date": "2026-08-12", | |
| 1490 | + "status": "verified", | |
| 1491 | + "enabled": false | |
| 1492 | + }, | |
| 1493 | + { | |
| 1494 | + "id": "alabonnevotre.ca", | |
| 1495 | + "name": "À la Bonne Vôtre - Goûtez-y!", | |
| 1496 | + "url": "https://www.alabonnevotre.ca", | |
| 1497 | + "platform": "woocommerce", | |
| 1498 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 1499 | + "city": "", | |
| 1500 | + "region": "Centre-du-Québec", | |
| 1501 | + "postal_prefix": "J2C", | |
| 1502 | + "phone": "(819) 474-0008", | |
| 1503 | + "origin_class": "A", | |
| 1504 | + "origin_confidence": 0.85, | |
| 1505 | + "origin_evidence": "Entreprise membre de Goûtez-y! (agroalimentaire Centre-du-Québec)", | |
| 1506 | + "categories": [ | |
| 1507 | + "epicerie" | |
| 1508 | + ], | |
| 1509 | + "socials": [ | |
| 1510 | + "https://www.facebook.com/NotreCentreDuQuebecGoutezY", | |
| 1511 | + "https://www.instagram.com/goutez_y/", | |
| 1512 | + "https://www.youtube.com/@goutezy/", | |
| 1513 | + "https://www.facebook.com/ALaBonneVotre" | |
| 1514 | + ], | |
| 1515 | + "discovery_sources": [ | |
| 1516 | + "goutezy" | |
| 1517 | + ], | |
| 1518 | + "discovery_source_urls": [ | |
| 1519 | + "https://goutezy.com/entreprise/a-la-bonne-votre/" | |
| 1520 | + ], | |
| 1521 | + "language": "fr", | |
| 1522 | + "ecommerce": true, | |
| 1523 | + "verification_date": "2026-08-12", | |
| 1524 | + "status": "verified", | |
| 1525 | + "enabled": true | |
| 1526 | + }, | |
| 1527 | + { | |
| 1528 | + "id": "alabordage.beer", | |
| 1529 | + "name": "À L'Abordage Microbrasserie", | |
| 1530 | + "url": "https://alabordage.beer", | |
| 1531 | + "platform": "wordpress", | |
| 1532 | + "catalog_endpoint": "", | |
| 1533 | + "city": "", | |
| 1534 | + "region": "Estrie", | |
| 1535 | + "postal_prefix": "J2L", | |
| 1536 | + "phone": "(450) 919-9338", | |
| 1537 | + "origin_class": "A", | |
| 1538 | + "origin_confidence": 0.95, | |
| 1539 | + "origin_evidence": "Microbrasserie membre de l'AMBQ (brassage au Québec)", | |
| 1540 | + "categories": [ | |
| 1541 | + "alcool" | |
| 1542 | + ], | |
| 1543 | + "socials": [ | |
| 1544 | + "https://www.facebook.com/bieresdemicroqc", | |
| 1545 | + "https://www.instagram.com/bieresdemicroqc/", | |
| 1546 | + "https://www.youtube.com/channel/UCJuiS8h8Ing9rQHrdyKDJsQ", | |
| 1547 | + "https://www.facebook.com/alabordagebrasserie/" | |
| 1548 | + ], | |
| 1549 | + "discovery_sources": [ | |
| 1550 | + "ambq_profils", | |
| 1551 | + "createursdesaveurs" | |
| 1552 | + ], | |
| 1553 | + "discovery_source_urls": [ | |
| 1554 | + "https://ambq.ca/fr/microbrasserie/a-l-abordage-microbrasserie", | |
| 1555 | + "https://createursdesaveurs.com/createurs/a-labordage-microbrasserie/" | |
| 1556 | + ], | |
| 1557 | + "language": null, | |
| 1558 | + "ecommerce": false, | |
| 1559 | + "verification_date": "2026-08-12", | |
| 1560 | + "status": "probable", | |
| 1561 | + "enabled": false | |
| 1562 | + }, | |
| 1563 | + { | |
| 1564 | + "id": "alabridelatempete.com", | |
| 1565 | + "name": "À l'abri de la Tempête", | |
| 1566 | + "url": "https://alabridelatempete.com", | |
| 1567 | + "platform": "shopify", | |
| 1568 | + "catalog_endpoint": "/products.json", | |
| 1569 | + "city": "", | |
| 1570 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 1571 | + "postal_prefix": "G4T", | |
| 1572 | + "phone": "418 986-5005", | |
| 1573 | + "origin_class": "A", | |
| 1574 | + "origin_confidence": 0.95, | |
| 1575 | + "origin_evidence": "Microbrasserie membre de l'AMBQ (brassage au Québec)", | |
| 1576 | + "categories": [ | |
| 1577 | + "alcool" | |
| 1578 | + ], | |
| 1579 | + "socials": [ | |
| 1580 | + "https://www.facebook.com/bieresdemicroqc", | |
| 1581 | + "https://www.instagram.com/bieresdemicroqc/", | |
| 1582 | + "https://www.youtube.com/channel/UCJuiS8h8Ing9rQHrdyKDJsQ", | |
| 1583 | + "https://www.facebook.com/alabridelatempete" | |
| 1584 | + ], | |
| 1585 | + "discovery_sources": [ | |
| 1586 | + "ambq_profils" | |
| 1587 | + ], | |
| 1588 | + "discovery_source_urls": [ | |
| 1589 | + "https://ambq.ca/fr/microbrasserie/-l-abri-de-la-temp-te" | |
| 1590 | + ], | |
| 1591 | + "language": null, | |
| 1592 | + "ecommerce": true, | |
| 1593 | + "verification_date": "2026-08-12", | |
| 1594 | + "status": "verified", | |
| 1595 | + "enabled": true | |
| 1596 | + }, | |
| 1597 | + { | |
| 1598 | + "id": "alacanneblanche.com", | |
| 1599 | + "name": "A la Canne Blanche Inc.", | |
| 1600 | + "url": "https://alacanneblanche.com", | |
| 1601 | + "platform": "woocommerce", | |
| 1602 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 1603 | + "city": "", | |
| 1604 | + "region": "Estrie", | |
| 1605 | + "postal_prefix": "J0E", | |
| 1606 | + "phone": "(450) 521-4105", | |
| 1607 | + "origin_class": "A", | |
| 1608 | + "origin_confidence": 0.85, | |
| 1609 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 1610 | + "categories": [ | |
| 1611 | + "epicerie" | |
| 1612 | + ], | |
| 1613 | + "socials": [ | |
| 1614 | + "https://www.facebook.com/alimentsduquebec", | |
| 1615 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1616 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1617 | + "https://www.tiktok.com/@alimentsqc" | |
| 1618 | + ], | |
| 1619 | + "discovery_sources": [ | |
| 1620 | + "alimentsduquebec_ent", | |
| 1621 | + "createursdesaveurs" | |
| 1622 | + ], | |
| 1623 | + "discovery_source_urls": [ | |
| 1624 | + "https://alimentsduquebec.com/entreprises/a-la-canne-blanche-inc", | |
| 1625 | + "https://createursdesaveurs.com/createurs/a-la-canne-blanche-inc/" | |
| 1626 | + ], | |
| 1627 | + "language": "en", | |
| 1628 | + "ecommerce": true, | |
| 1629 | + "verification_date": "2026-08-12", | |
| 1630 | + "status": "verified", | |
| 1631 | + "enabled": true | |
| 1632 | + }, | |
| 1633 | + { | |
| 1634 | + "id": "alachouette.com", | |
| 1635 | + "name": "Ã La Chouette", | |
| 1636 | + "url": "https://alachouette.com", | |
| 1637 | + "platform": "", | |
| 1638 | + "catalog_endpoint": "", | |
| 1639 | + "city": "", | |
| 1640 | + "region": "Capitale-Nationale", | |
| 1641 | + "postal_prefix": "G3Z", | |
| 1642 | + "phone": "418 435-3217", | |
| 1643 | + "origin_class": "A", | |
| 1644 | + "origin_confidence": 0.7, | |
| 1645 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 1646 | + "categories": [ | |
| 1647 | + "epicerie" | |
| 1648 | + ], | |
| 1649 | + "socials": [ | |
| 1650 | + "https://www.facebook.com/alachouette", | |
| 1651 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 1652 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 1653 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 1654 | + ], | |
| 1655 | + "discovery_sources": [ | |
| 1656 | + "charlevoix" | |
| 1657 | + ], | |
| 1658 | + "discovery_source_urls": [ | |
| 1659 | + "https://www.tourisme-charlevoix.com/fr/entreprises/a-la-chouette", | |
| 1660 | + "https://www.tourisme-charlevoix.com/fr/entreprises/a-la-chouette-maison" | |
| 1661 | + ], | |
| 1662 | + "language": "fr", | |
| 1663 | + "ecommerce": false, | |
| 1664 | + "verification_date": "2026-08-12", | |
| 1665 | + "status": "verified", | |
| 1666 | + "enabled": false | |
| 1667 | + }, | |
| 1668 | + { | |
| 1669 | + "id": "alafut.qc.ca", | |
| 1670 | + "name": "À la Fût", | |
| 1671 | + "url": "https://www.alafut.qc.ca", | |
| 1672 | + "platform": "snipcart", | |
| 1673 | + "catalog_endpoint": "", | |
| 1674 | + "city": "", | |
| 1675 | + "region": "Mauricie", | |
| 1676 | + "postal_prefix": "G0X", | |
| 1677 | + "phone": "418 365-4370", | |
| 1678 | + "origin_class": "A", | |
| 1679 | + "origin_confidence": 0.98, | |
| 1680 | + "origin_evidence": "Microbrasserie membre de l'AMBQ (brassage au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 1681 | + "categories": [ | |
| 1682 | + "alcool" | |
| 1683 | + ], | |
| 1684 | + "socials": [ | |
| 1685 | + "https://www.facebook.com/alimentsduquebec", | |
| 1686 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1687 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1688 | + "https://www.tiktok.com/@alimentsqc" | |
| 1689 | + ], | |
| 1690 | + "discovery_sources": [ | |
| 1691 | + "alimentsduquebec_ent", | |
| 1692 | + "ambq_profils" | |
| 1693 | + ], | |
| 1694 | + "discovery_source_urls": [ | |
| 1695 | + "https://alimentsduquebec.com/entreprises/a-la-fut", | |
| 1696 | + "https://ambq.ca/fr/microbrasserie/a-la-f-t-microbrasserie" | |
| 1697 | + ], | |
| 1698 | + "language": "fr", | |
| 1699 | + "ecommerce": true, | |
| 1700 | + "verification_date": "2026-08-12", | |
| 1701 | + "status": "verified", | |
| 1702 | + "enabled": false | |
| 1703 | + }, | |
| 1704 | + { | |
| 1705 | + "id": "alchimiste.ca", | |
| 1706 | + "name": "Alchimiste Microbrasserie", | |
| 1707 | + "url": "https://alchimiste.ca", | |
| 1708 | + "platform": "", | |
| 1709 | + "catalog_endpoint": "", | |
| 1710 | + "city": "", | |
| 1711 | + "region": "Lanaudière", | |
| 1712 | + "postal_prefix": "J6E", | |
| 1713 | + "phone": "450 760-2945", | |
| 1714 | + "origin_class": "A", | |
| 1715 | + "origin_confidence": 0.85, | |
| 1716 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 1717 | + "categories": [ | |
| 1718 | + "epicerie" | |
| 1719 | + ], | |
| 1720 | + "socials": [ | |
| 1721 | + "https://www.facebook.com/alimentsduquebec", | |
| 1722 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1723 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1724 | + "https://www.tiktok.com/@alimentsqc" | |
| 1725 | + ], | |
| 1726 | + "discovery_sources": [ | |
| 1727 | + "alimentsduquebec_ent" | |
| 1728 | + ], | |
| 1729 | + "discovery_source_urls": [ | |
| 1730 | + "https://alimentsduquebec.com/entreprises/alchimiste-microbrasserie" | |
| 1731 | + ], | |
| 1732 | + "language": "fr", | |
| 1733 | + "ecommerce": false, | |
| 1734 | + "verification_date": "2026-08-12", | |
| 1735 | + "status": "probable", | |
| 1736 | + "enabled": false | |
| 1737 | + }, | |
| 1738 | + { | |
| 1739 | + "id": "alcideparent.net", | |
| 1740 | + "name": "Cabane à sucre Alcide Parent", | |
| 1741 | + "url": "https://alcideparent.net", | |
| 1742 | + "platform": "wordpress", | |
| 1743 | + "catalog_endpoint": "", | |
| 1744 | + "city": "", | |
| 1745 | + "region": "Lanaudière", | |
| 1746 | + "postal_prefix": "J0K", | |
| 1747 | + "phone": "450 756-2878", | |
| 1748 | + "origin_class": "A", | |
| 1749 | + "origin_confidence": 0.95, | |
| 1750 | + "origin_evidence": "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca)", | |
| 1751 | + "categories": [ | |
| 1752 | + "erable" | |
| 1753 | + ], | |
| 1754 | + "socials": [ | |
| 1755 | + "https://www.facebook.com/Cabane-%C3%A0-sucre-Alcide-Parent-177127409005697/", | |
| 1756 | + "https://www.facebook.com/Producteurs-et-productrices-ac%C3%A9ricoles-du-Qu%C3%A9bec-168610803187571/", | |
| 1757 | + "https://x.com/AcericoleQc", | |
| 1758 | + "https://www.youtube.com/channel/UCq8BHBMeMudLzspOaF9QMOg" | |
| 1759 | + ], | |
| 1760 | + "discovery_sources": [ | |
| 1761 | + "erabledici" | |
| 1762 | + ], | |
| 1763 | + "discovery_source_urls": [ | |
| 1764 | + "https://www.erabledici.ca/fr/erabliere/cabane-a-sucre-alcide-parent/" | |
| 1765 | + ], | |
| 1766 | + "language": "en", | |
| 1767 | + "ecommerce": false, | |
| 1768 | + "verification_date": "2026-08-12", | |
| 1769 | + "status": "probable", | |
| 1770 | + "enabled": false | |
| 1771 | + }, | |
| 1772 | + { | |
| 1773 | + "id": "alcyonsel.com", | |
| 1774 | + "name": "Alcyon", | |
| 1775 | + "url": "https://alcyonsel.com", | |
| 1776 | + "platform": "shopify", | |
| 1777 | + "catalog_endpoint": "/products.json", | |
| 1778 | + "city": "", | |
| 1779 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 1780 | + "postal_prefix": "G4T", | |
| 1781 | + "phone": null, | |
| 1782 | + "origin_class": "A", | |
| 1783 | + "origin_confidence": 0.55, | |
| 1784 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec", | |
| 1785 | + "categories": [], | |
| 1786 | + "socials": [], | |
| 1787 | + "discovery_sources": [ | |
| 1788 | + "icq_resolved" | |
| 1789 | + ], | |
| 1790 | + "discovery_source_urls": [ | |
| 1791 | + "https://www.ideecadeauquebec.com/boutique/alcyonsel/" | |
| 1792 | + ], | |
| 1793 | + "language": null, | |
| 1794 | + "ecommerce": true, | |
| 1795 | + "verification_date": "2026-08-12", | |
| 1796 | + "status": "probable", | |
| 1797 | + "enabled": true | |
| 1798 | + }, | |
| 1799 | + { | |
| 1800 | + "id": "aleaprotection.com", | |
| 1801 | + "name": "Alea", | |
| 1802 | + "url": "https://www.aleaprotection.com", | |
| 1803 | + "platform": "shopify", | |
| 1804 | + "catalog_endpoint": "/products.json", | |
| 1805 | + "city": "", | |
| 1806 | + "region": "", | |
| 1807 | + "postal_prefix": "J4Y", | |
| 1808 | + "phone": "514 847-9799", | |
| 1809 | + "origin_class": "A", | |
| 1810 | + "origin_confidence": 0.6, | |
| 1811 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 1812 | + "categories": [], | |
| 1813 | + "socials": [ | |
| 1814 | + "https://www.facebook.com/lesproduitsduqc", | |
| 1815 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 1816 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 1817 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 1818 | + ], | |
| 1819 | + "discovery_sources": [ | |
| 1820 | + "lesproduitsduquebec_ent" | |
| 1821 | + ], | |
| 1822 | + "discovery_source_urls": [ | |
| 1823 | + "https://lesproduitsduquebec.com/entreprises-adherentes/alea" | |
| 1824 | + ], | |
| 1825 | + "language": "en", | |
| 1826 | + "ecommerce": true, | |
| 1827 | + "verification_date": "2026-08-12", | |
| 1828 | + "status": "probable", | |
| 1829 | + "enabled": true | |
| 1830 | + }, | |
| 1831 | + { | |
| 1832 | + "id": "alexisdeportneuf.com", | |
| 1833 | + "name": "Fromagerie Alexis de Portneuf – Saint-Raymond", | |
| 1834 | + "url": "https://www.alexisdeportneuf.com", | |
| 1835 | + "platform": "", | |
| 1836 | + "catalog_endpoint": "", | |
| 1837 | + "city": "", | |
| 1838 | + "region": "Capitale-Nationale", | |
| 1839 | + "postal_prefix": "G3L", | |
| 1840 | + "phone": "(418) 337-4287", | |
| 1841 | + "origin_class": "A", | |
| 1842 | + "origin_confidence": 0.7, | |
| 1843 | + "origin_evidence": "Fromagerie recensée par Fromages d'ici (fabrication au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 1844 | + "categories": [ | |
| 1845 | + "fromage_laitier" | |
| 1846 | + ], | |
| 1847 | + "socials": [ | |
| 1848 | + "https://www.facebook.com/fromagesdici", | |
| 1849 | + "https://www.youtube.com/channel/UCxSGDuWDPFOvNy7xP_LylcA", | |
| 1850 | + "https://www.instagram.com/fromages.dici/", | |
| 1851 | + "https://www.pinterest.ca/fromagesdici/" | |
| 1852 | + ], | |
| 1853 | + "discovery_sources": [ | |
| 1854 | + "fromagesdici" | |
| 1855 | + ], | |
| 1856 | + "discovery_source_urls": [ | |
| 1857 | + "https://www.fromagesdici.com/fr/fromageries/fromagerie-alexis-de-portneuf-saint-raymond", | |
| 1858 | + "https://www.fromagesdici.com/fr/fromageries/fromagerie-alexis-de-portneuf-mont-laurier" | |
| 1859 | + ], | |
| 1860 | + "language": "en", | |
| 1861 | + "ecommerce": false, | |
| 1862 | + "verification_date": "2026-08-12", | |
| 1863 | + "status": "probable", | |
| 1864 | + "enabled": false | |
| 1865 | + }, | |
| 1866 | + { | |
| 1867 | + "id": "ali-monde.com", | |
| 1868 | + "name": "Ali-Monde Ltée", | |
| 1869 | + "url": "https://www.ali-monde.com", | |
| 1870 | + "platform": "wix", | |
| 1871 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 1872 | + "city": "", | |
| 1873 | + "region": "Montérégie", | |
| 1874 | + "postal_prefix": "J3G", | |
| 1875 | + "phone": null, | |
| 1876 | + "origin_class": "A", | |
| 1877 | + "origin_confidence": 0.85, | |
| 1878 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 1879 | + "categories": [ | |
| 1880 | + "epicerie" | |
| 1881 | + ], | |
| 1882 | + "socials": [ | |
| 1883 | + "https://www.facebook.com/alimentsduquebec", | |
| 1884 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1885 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1886 | + "https://www.tiktok.com/@alimentsqc" | |
| 1887 | + ], | |
| 1888 | + "discovery_sources": [ | |
| 1889 | + "alimentsduquebec_ent" | |
| 1890 | + ], | |
| 1891 | + "discovery_source_urls": [ | |
| 1892 | + "https://alimentsduquebec.com/entreprises/ali-monde-ltee" | |
| 1893 | + ], | |
| 1894 | + "language": "fr", | |
| 1895 | + "ecommerce": true, | |
| 1896 | + "verification_date": "2026-08-12", | |
| 1897 | + "status": "verified", | |
| 1898 | + "enabled": true | |
| 1899 | + }, | |
| 1900 | + { | |
| 1901 | + "id": "aliciasanchez.ca", | |
| 1902 | + "name": "Alicia Sanchez", | |
| 1903 | + "url": "https://www.aliciasanchez.ca", | |
| 1904 | + "platform": "", | |
| 1905 | + "catalog_endpoint": "", | |
| 1906 | + "city": "", | |
| 1907 | + "region": "Chaudière-Appalaches", | |
| 1908 | + "postal_prefix": "G7A", | |
| 1909 | + "phone": "(418) 831-1456", | |
| 1910 | + "origin_class": "A", | |
| 1911 | + "origin_confidence": 0.85, | |
| 1912 | + "origin_evidence": "Arrêt gourmand certifié (Chaudière-Appalaches) ; le site affiche une mention de fabrication québécoise", | |
| 1913 | + "categories": [ | |
| 1914 | + "epicerie" | |
| 1915 | + ], | |
| 1916 | + "socials": [ | |
| 1917 | + "https://www.facebook.com/profile.php?id=100076246641012", | |
| 1918 | + "https://www.facebook.com/arretsgourmands/", | |
| 1919 | + "https://www.instagram.com/arretsgourmandsca/", | |
| 1920 | + "https://www.youtube.com/@arretsgourmandsca" | |
| 1921 | + ], | |
| 1922 | + "discovery_sources": [ | |
| 1923 | + "arretsgourmands" | |
| 1924 | + ], | |
| 1925 | + "discovery_source_urls": [ | |
| 1926 | + "https://arretsgourmands.ca/arrets-gourmands/alicia-sanchez" | |
| 1927 | + ], | |
| 1928 | + "language": "fr", | |
| 1929 | + "ecommerce": false, | |
| 1930 | + "verification_date": "2026-08-12", | |
| 1931 | + "status": "verified", | |
| 1932 | + "enabled": false | |
| 1933 | + }, | |
| 1934 | + { | |
| 1935 | + "id": "alimentationdulac.com", | |
| 1936 | + "name": "Alimentation du Lac - Goûtez-y!", | |
| 1937 | + "url": "https://www.alimentationdulac.com", | |
| 1938 | + "platform": "", | |
| 1939 | + "catalog_endpoint": "", | |
| 1940 | + "city": "", | |
| 1941 | + "region": "Centre-du-Québec", | |
| 1942 | + "postal_prefix": "G6P", | |
| 1943 | + "phone": "(819) 795-4999", | |
| 1944 | + "origin_class": "A", | |
| 1945 | + "origin_confidence": 0.6, | |
| 1946 | + "origin_evidence": "Entreprise membre de Goûtez-y! (agroalimentaire Centre-du-Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 1947 | + "categories": [ | |
| 1948 | + "epicerie" | |
| 1949 | + ], | |
| 1950 | + "socials": [ | |
| 1951 | + "https://www.facebook.com/NotreCentreDuQuebecGoutezY", | |
| 1952 | + "https://www.instagram.com/goutez_y/", | |
| 1953 | + "https://www.youtube.com/@goutezy/", | |
| 1954 | + "https://www.facebook.com/alimentationdulac/" | |
| 1955 | + ], | |
| 1956 | + "discovery_sources": [ | |
| 1957 | + "goutezy" | |
| 1958 | + ], | |
| 1959 | + "discovery_source_urls": [ | |
| 1960 | + "https://goutezy.com/entreprise/alimentation-du-lac/" | |
| 1961 | + ], | |
| 1962 | + "language": "fr", | |
| 1963 | + "ecommerce": false, | |
| 1964 | + "verification_date": "2026-08-12", | |
| 1965 | + "status": "probable", | |
| 1966 | + "enabled": false | |
| 1967 | + }, | |
| 1968 | + { | |
| 1969 | + "id": "alimentomo.com", | |
| 1970 | + "name": "Alimentomo inc.", | |
| 1971 | + "url": "https://alimentomo.com", | |
| 1972 | + "platform": "shopify", | |
| 1973 | + "catalog_endpoint": "/products.json", | |
| 1974 | + "city": "", | |
| 1975 | + "region": "Montérégie", | |
| 1976 | + "postal_prefix": "J3M", | |
| 1977 | + "phone": null, | |
| 1978 | + "origin_class": "A", | |
| 1979 | + "origin_confidence": 0.6, | |
| 1980 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 1981 | + "categories": [ | |
| 1982 | + "epicerie" | |
| 1983 | + ], | |
| 1984 | + "socials": [ | |
| 1985 | + "https://www.facebook.com/alimentsduquebec", | |
| 1986 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 1987 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 1988 | + "https://www.tiktok.com/@alimentsqc" | |
| 1989 | + ], | |
| 1990 | + "discovery_sources": [ | |
| 1991 | + "alimentsduquebec_ent" | |
| 1992 | + ], | |
| 1993 | + "discovery_source_urls": [ | |
| 1994 | + "https://alimentsduquebec.com/entreprises/alimentomo-inc" | |
| 1995 | + ], | |
| 1996 | + "language": "en", | |
| 1997 | + "ecommerce": true, | |
| 1998 | + "verification_date": "2026-08-12", | |
| 1999 | + "status": "probable", | |
| 2000 | + "enabled": true | |
| 2001 | + }, | |
| 2002 | + { | |
| 2003 | + "id": "aliments-ruoff.com", | |
| 2004 | + "name": "Aliments Ruoff", | |
| 2005 | + "url": "https://www.aliments-ruoff.com", | |
| 2006 | + "platform": "", | |
| 2007 | + "catalog_endpoint": "", | |
| 2008 | + "city": "", | |
| 2009 | + "region": "Montréal", | |
| 2010 | + "postal_prefix": null, | |
| 2011 | + "phone": "8199999928", | |
| 2012 | + "origin_class": "A", | |
| 2013 | + "origin_confidence": 0.85, | |
| 2014 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2015 | + "categories": [ | |
| 2016 | + "epicerie" | |
| 2017 | + ], | |
| 2018 | + "socials": [ | |
| 2019 | + "https://www.facebook.com/alimentsduquebec", | |
| 2020 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2021 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2022 | + "https://www.tiktok.com/@alimentsqc" | |
| 2023 | + ], | |
| 2024 | + "discovery_sources": [ | |
| 2025 | + "alimentsduquebec_ent" | |
| 2026 | + ], | |
| 2027 | + "discovery_source_urls": [ | |
| 2028 | + "https://alimentsduquebec.com/entreprises/aliments-ruoff" | |
| 2029 | + ], | |
| 2030 | + "language": "fr", | |
| 2031 | + "ecommerce": false, | |
| 2032 | + "verification_date": "2026-08-12", | |
| 2033 | + "status": "verified", | |
| 2034 | + "enabled": false | |
| 2035 | + }, | |
| 2036 | + { | |
| 2037 | + "id": "alimentsbaril.ca", | |
| 2038 | + "name": "LES ALIMENTS BARI INC.", | |
| 2039 | + "url": "https://alimentsbaril.ca", | |
| 2040 | + "platform": "wordpress", | |
| 2041 | + "catalog_endpoint": "", | |
| 2042 | + "city": "", | |
| 2043 | + "region": "Centre-du-Québec", | |
| 2044 | + "postal_prefix": "J0C", | |
| 2045 | + "phone": "819-399-2277", | |
| 2046 | + "origin_class": "A", | |
| 2047 | + "origin_confidence": 0.85, | |
| 2048 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2049 | + "categories": [ | |
| 2050 | + "epicerie" | |
| 2051 | + ], | |
| 2052 | + "socials": [ | |
| 2053 | + "https://www.facebook.com/alimentsduquebec", | |
| 2054 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2055 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2056 | + "https://www.tiktok.com/@alimentsqc" | |
| 2057 | + ], | |
| 2058 | + "discovery_sources": [ | |
| 2059 | + "alimentsduquebec_ent" | |
| 2060 | + ], | |
| 2061 | + "discovery_source_urls": [ | |
| 2062 | + "https://alimentsduquebec.com/entreprises/les-aliments-bari-inc" | |
| 2063 | + ], | |
| 2064 | + "language": "fr", | |
| 2065 | + "ecommerce": false, | |
| 2066 | + "verification_date": "2026-08-12", | |
| 2067 | + "status": "verified", | |
| 2068 | + "enabled": false | |
| 2069 | + }, | |
| 2070 | + { | |
| 2071 | + "id": "alimentsbleumer.com", | |
| 2072 | + "name": "Fumoir la Fée des Grèves", | |
| 2073 | + "url": "https://alimentsbleumer.com", | |
| 2074 | + "platform": "", | |
| 2075 | + "catalog_endpoint": "", | |
| 2076 | + "city": "", | |
| 2077 | + "region": "Capitale-Nationale", | |
| 2078 | + "postal_prefix": "G1E", | |
| 2079 | + "phone": null, | |
| 2080 | + "origin_class": "A", | |
| 2081 | + "origin_confidence": 0.6, | |
| 2082 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 2083 | + "categories": [ | |
| 2084 | + "epicerie" | |
| 2085 | + ], | |
| 2086 | + "socials": [ | |
| 2087 | + "https://www.facebook.com/alimentsduquebec", | |
| 2088 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2089 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2090 | + "https://www.tiktok.com/@alimentsqc" | |
| 2091 | + ], | |
| 2092 | + "discovery_sources": [ | |
| 2093 | + "alimentsduquebec_ent" | |
| 2094 | + ], | |
| 2095 | + "discovery_source_urls": [ | |
| 2096 | + "https://alimentsduquebec.com/entreprises/fumoir-la-fee-des-greves" | |
| 2097 | + ], | |
| 2098 | + "language": null, | |
| 2099 | + "ecommerce": false, | |
| 2100 | + "verification_date": "2026-08-12", | |
| 2101 | + "status": "probable", | |
| 2102 | + "enabled": false | |
| 2103 | + }, | |
| 2104 | + { | |
| 2105 | + "id": "alimentsblouin.com", | |
| 2106 | + "name": "FARINE GRILLEE BLOUIN INC", | |
| 2107 | + "url": "https://www.alimentsblouin.com", | |
| 2108 | + "platform": "wordpress", | |
| 2109 | + "catalog_endpoint": "", | |
| 2110 | + "city": "", | |
| 2111 | + "region": "Chaudière-Appalaches", | |
| 2112 | + "postal_prefix": "G7A", | |
| 2113 | + "phone": "4188363637", | |
| 2114 | + "origin_class": "A", | |
| 2115 | + "origin_confidence": 0.85, | |
| 2116 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2117 | + "categories": [ | |
| 2118 | + "epicerie" | |
| 2119 | + ], | |
| 2120 | + "socials": [ | |
| 2121 | + "https://www.facebook.com/alimentsduquebec", | |
| 2122 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2123 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2124 | + "https://www.tiktok.com/@alimentsqc" | |
| 2125 | + ], | |
| 2126 | + "discovery_sources": [ | |
| 2127 | + "alimentsduquebec_ent" | |
| 2128 | + ], | |
| 2129 | + "discovery_source_urls": [ | |
| 2130 | + "https://alimentsduquebec.com/entreprises/farine-grillee-blouin-inc" | |
| 2131 | + ], | |
| 2132 | + "language": "en", | |
| 2133 | + "ecommerce": false, | |
| 2134 | + "verification_date": "2026-08-12", | |
| 2135 | + "status": "verified", | |
| 2136 | + "enabled": false | |
| 2137 | + }, | |
| 2138 | + { | |
| 2139 | + "id": "alimentsfabbrica.com", | |
| 2140 | + "name": "Aliments Fabbrica", | |
| 2141 | + "url": "https://www.alimentsfabbrica.com", | |
| 2142 | + "platform": "wix", | |
| 2143 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 2144 | + "city": "", | |
| 2145 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 2146 | + "postal_prefix": null, | |
| 2147 | + "phone": "418 812-5115", | |
| 2148 | + "origin_class": "A", | |
| 2149 | + "origin_confidence": 0.6, | |
| 2150 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 2151 | + "categories": [ | |
| 2152 | + "epicerie" | |
| 2153 | + ], | |
| 2154 | + "socials": [ | |
| 2155 | + "https://www.facebook.com/zoneboreale", | |
| 2156 | + "https://www.instagram.com/zone_boreale/", | |
| 2157 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 2158 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 2159 | + ], | |
| 2160 | + "discovery_sources": [ | |
| 2161 | + "zoneboreale" | |
| 2162 | + ], | |
| 2163 | + "discovery_source_urls": [ | |
| 2164 | + "https://zoneboreale.com/entreprises/aliments-fabbrica/" | |
| 2165 | + ], | |
| 2166 | + "language": "fr", | |
| 2167 | + "ecommerce": true, | |
| 2168 | + "verification_date": "2026-08-12", | |
| 2169 | + "status": "probable", | |
| 2170 | + "enabled": true | |
| 2171 | + }, | |
| 2172 | + { | |
| 2173 | + "id": "alimentsmeretmonde.com", | |
| 2174 | + "name": "Aliments Mer & Monde", | |
| 2175 | + "url": "https://alimentsmeretmonde.com", | |
| 2176 | + "platform": "woocommerce", | |
| 2177 | + "catalog_endpoint": "", | |
| 2178 | + "city": "", | |
| 2179 | + "region": "Lanaudière", | |
| 2180 | + "postal_prefix": "J5Z", | |
| 2181 | + "phone": null, | |
| 2182 | + "origin_class": "A", | |
| 2183 | + "origin_confidence": 0.85, | |
| 2184 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2185 | + "categories": [ | |
| 2186 | + "epicerie" | |
| 2187 | + ], | |
| 2188 | + "socials": [ | |
| 2189 | + "https://www.facebook.com/alimentsduquebec", | |
| 2190 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2191 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2192 | + "https://www.tiktok.com/@alimentsqc" | |
| 2193 | + ], | |
| 2194 | + "discovery_sources": [ | |
| 2195 | + "alimentsduquebec_ent" | |
| 2196 | + ], | |
| 2197 | + "discovery_source_urls": [ | |
| 2198 | + "https://alimentsduquebec.com/entreprises/aliments-mer-monde" | |
| 2199 | + ], | |
| 2200 | + "language": "en", | |
| 2201 | + "ecommerce": false, | |
| 2202 | + "verification_date": "2026-08-12", | |
| 2203 | + "status": "verified", | |
| 2204 | + "enabled": false | |
| 2205 | + }, | |
| 2206 | + { | |
| 2207 | + "id": "alimentsparador.com", | |
| 2208 | + "name": "Les Aliments Parador Inc.", | |
| 2209 | + "url": "https://alimentsparador.com", | |
| 2210 | + "platform": "wordpress", | |
| 2211 | + "catalog_endpoint": "", | |
| 2212 | + "city": "", | |
| 2213 | + "region": "Laurentides", | |
| 2214 | + "postal_prefix": "J7C", | |
| 2215 | + "phone": "450 430-7751", | |
| 2216 | + "origin_class": "A", | |
| 2217 | + "origin_confidence": 0.85, | |
| 2218 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2219 | + "categories": [ | |
| 2220 | + "epicerie" | |
| 2221 | + ], | |
| 2222 | + "socials": [ | |
| 2223 | + "https://www.facebook.com/alimentsduquebec", | |
| 2224 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2225 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2226 | + "https://www.tiktok.com/@alimentsqc" | |
| 2227 | + ], | |
| 2228 | + "discovery_sources": [ | |
| 2229 | + "alimentsduquebec_ent", | |
| 2230 | + "laurentidesjenmange" | |
| 2231 | + ], | |
| 2232 | + "discovery_source_urls": [ | |
| 2233 | + "https://alimentsduquebec.com/entreprises/les-aliments-parador-inc", | |
| 2234 | + "https://laurentidesjenmange.ca/membre/les-aliments-parador-foods-inc" | |
| 2235 | + ], | |
| 2236 | + "language": "fr", | |
| 2237 | + "ecommerce": false, | |
| 2238 | + "verification_date": "2026-08-12", | |
| 2239 | + "status": "verified", | |
| 2240 | + "enabled": false | |
| 2241 | + }, | |
| 2242 | + { | |
| 2243 | + "id": "alimentspaysanne.com", | |
| 2244 | + "name": "Les Aliments Fondue Paysanne", | |
| 2245 | + "url": "https://alimentspaysanne.com", | |
| 2246 | + "platform": "", | |
| 2247 | + "catalog_endpoint": "", | |
| 2248 | + "city": "", | |
| 2249 | + "region": "Chaudière-Appalaches", | |
| 2250 | + "postal_prefix": "G7A", | |
| 2251 | + "phone": null, | |
| 2252 | + "origin_class": "A", | |
| 2253 | + "origin_confidence": 0.6, | |
| 2254 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 2255 | + "categories": [ | |
| 2256 | + "epicerie" | |
| 2257 | + ], | |
| 2258 | + "socials": [ | |
| 2259 | + "https://www.facebook.com/alimentsduquebec", | |
| 2260 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2261 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2262 | + "https://www.tiktok.com/@alimentsqc" | |
| 2263 | + ], | |
| 2264 | + "discovery_sources": [ | |
| 2265 | + "alimentsduquebec_ent" | |
| 2266 | + ], | |
| 2267 | + "discovery_source_urls": [ | |
| 2268 | + "https://alimentsduquebec.com/entreprises/les-aliments-fondue-paysanne" | |
| 2269 | + ], | |
| 2270 | + "language": null, | |
| 2271 | + "ecommerce": false, | |
| 2272 | + "verification_date": "2026-08-12", | |
| 2273 | + "status": "probable", | |
| 2274 | + "enabled": false | |
| 2275 | + }, | |
| 2276 | + { | |
| 2277 | + "id": "alimentsroma.com", | |
| 2278 | + "name": "Aliments Roma Food Products Ltd", | |
| 2279 | + "url": "https://alimentsroma.com", | |
| 2280 | + "platform": "", | |
| 2281 | + "catalog_endpoint": "", | |
| 2282 | + "city": "", | |
| 2283 | + "region": "Montréal", | |
| 2284 | + "postal_prefix": "H4N", | |
| 2285 | + "phone": null, | |
| 2286 | + "origin_class": "A", | |
| 2287 | + "origin_confidence": 0.9, | |
| 2288 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 2289 | + "categories": [ | |
| 2290 | + "epicerie" | |
| 2291 | + ], | |
| 2292 | + "socials": [ | |
| 2293 | + "https://www.facebook.com/alimentsduquebec", | |
| 2294 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2295 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2296 | + "https://www.tiktok.com/@alimentsqc" | |
| 2297 | + ], | |
| 2298 | + "discovery_sources": [ | |
| 2299 | + "alimentsduquebec_ent" | |
| 2300 | + ], | |
| 2301 | + "discovery_source_urls": [ | |
| 2302 | + "https://alimentsduquebec.com/entreprises/aliments-roma-food-products-ltd" | |
| 2303 | + ], | |
| 2304 | + "language": "fr", | |
| 2305 | + "ecommerce": false, | |
| 2306 | + "verification_date": "2026-08-12", | |
| 2307 | + "status": "verified", | |
| 2308 | + "enabled": false | |
| 2309 | + }, | |
| 2310 | + { | |
| 2311 | + "id": "alimentstristan.com", | |
| 2312 | + "name": "Aliments Tristan", | |
| 2313 | + "url": "https://alimentstristan.com", | |
| 2314 | + "platform": "shopify", | |
| 2315 | + "catalog_endpoint": "/products.json", | |
| 2316 | + "city": "", | |
| 2317 | + "region": "Centre-du-Québec", | |
| 2318 | + "postal_prefix": "J2B", | |
| 2319 | + "phone": null, | |
| 2320 | + "origin_class": "A", | |
| 2321 | + "origin_confidence": 0.85, | |
| 2322 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2323 | + "categories": [ | |
| 2324 | + "epicerie" | |
| 2325 | + ], | |
| 2326 | + "socials": [ | |
| 2327 | + "https://www.facebook.com/alimentsduquebec", | |
| 2328 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2329 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2330 | + "https://www.tiktok.com/@alimentsqc" | |
| 2331 | + ], | |
| 2332 | + "discovery_sources": [ | |
| 2333 | + "alimentsduquebec_ent" | |
| 2334 | + ], | |
| 2335 | + "discovery_source_urls": [ | |
| 2336 | + "https://alimentsduquebec.com/entreprises/aliments-tristan" | |
| 2337 | + ], | |
| 2338 | + "language": "en", | |
| 2339 | + "ecommerce": true, | |
| 2340 | + "verification_date": "2026-08-12", | |
| 2341 | + "status": "verified", | |
| 2342 | + "enabled": true | |
| 2343 | + }, | |
| 2344 | + { | |
| 2345 | + "id": "alimenttradition.com", | |
| 2346 | + "name": "Aliment tradition", | |
| 2347 | + "url": "https://www.alimenttradition.com", | |
| 2348 | + "platform": "wix", | |
| 2349 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 2350 | + "city": "", | |
| 2351 | + "region": "Laurentides", | |
| 2352 | + "postal_prefix": "J5N", | |
| 2353 | + "phone": "450-478-4581", | |
| 2354 | + "origin_class": "A", | |
| 2355 | + "origin_confidence": 0.85, | |
| 2356 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2357 | + "categories": [ | |
| 2358 | + "epicerie" | |
| 2359 | + ], | |
| 2360 | + "socials": [ | |
| 2361 | + "https://www.facebook.com/alimentsduquebec", | |
| 2362 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2363 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2364 | + "https://www.tiktok.com/@alimentsqc" | |
| 2365 | + ], | |
| 2366 | + "discovery_sources": [ | |
| 2367 | + "alimentsduquebec_ent" | |
| 2368 | + ], | |
| 2369 | + "discovery_source_urls": [ | |
| 2370 | + "https://alimentsduquebec.com/entreprises/aliment-tradition" | |
| 2371 | + ], | |
| 2372 | + "language": "fr", | |
| 2373 | + "ecommerce": true, | |
| 2374 | + "verification_date": "2026-08-12", | |
| 2375 | + "status": "verified", | |
| 2376 | + "enabled": true | |
| 2377 | + }, | |
| 2378 | + { | |
| 2379 | + "id": "alimexi.com", | |
| 2380 | + "name": "Les Aliments ALIMEXI Foods, Inc.", | |
| 2381 | + "url": "https://www.alimexi.com", | |
| 2382 | + "platform": "", | |
| 2383 | + "catalog_endpoint": "", | |
| 2384 | + "city": "", | |
| 2385 | + "region": "Montréal", | |
| 2386 | + "postal_prefix": "H9P", | |
| 2387 | + "phone": "(514) 400-4759", | |
| 2388 | + "origin_class": "A", | |
| 2389 | + "origin_confidence": 0.85, | |
| 2390 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2391 | + "categories": [ | |
| 2392 | + "epicerie" | |
| 2393 | + ], | |
| 2394 | + "socials": [ | |
| 2395 | + "https://www.facebook.com/alimentsduquebec", | |
| 2396 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2397 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2398 | + "https://www.tiktok.com/@alimentsqc" | |
| 2399 | + ], | |
| 2400 | + "discovery_sources": [ | |
| 2401 | + "alimentsduquebec_ent" | |
| 2402 | + ], | |
| 2403 | + "discovery_source_urls": [ | |
| 2404 | + "https://alimentsduquebec.com/entreprises/les-aliments-alimexi-foods-inc" | |
| 2405 | + ], | |
| 2406 | + "language": null, | |
| 2407 | + "ecommerce": false, | |
| 2408 | + "verification_date": "2026-08-12", | |
| 2409 | + "status": "verified", | |
| 2410 | + "enabled": false | |
| 2411 | + }, | |
| 2412 | + { | |
| 2413 | + "id": "allosimonne.com", | |
| 2414 | + "name": "Allo Simonne", | |
| 2415 | + "url": "https://allosimonne.com", | |
| 2416 | + "platform": "shopify", | |
| 2417 | + "catalog_endpoint": "/products.json", | |
| 2418 | + "city": "", | |
| 2419 | + "region": "Montréal", | |
| 2420 | + "postal_prefix": "H2G", | |
| 2421 | + "phone": null, | |
| 2422 | + "origin_class": "A", | |
| 2423 | + "origin_confidence": 0.85, | |
| 2424 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 2425 | + "categories": [ | |
| 2426 | + "epicerie" | |
| 2427 | + ], | |
| 2428 | + "socials": [ | |
| 2429 | + "https://www.facebook.com/alimentsduquebec", | |
| 2430 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2431 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2432 | + "https://www.tiktok.com/@alimentsqc" | |
| 2433 | + ], | |
| 2434 | + "discovery_sources": [ | |
| 2435 | + "alimentsduquebec_ent", | |
| 2436 | + "icq_resolved", | |
| 2437 | + "search_sweep_vendors" | |
| 2438 | + ], | |
| 2439 | + "discovery_source_urls": [ | |
| 2440 | + "https://alimentsduquebec.com/entreprises/allo-simonne", | |
| 2441 | + "https://www.ideecadeauquebec.com/boutique/allosimonne/", | |
| 2442 | + "direct domain guess allosimonne.com, verified via WebFetch" | |
| 2443 | + ], | |
| 2444 | + "language": "en", | |
| 2445 | + "ecommerce": true, | |
| 2446 | + "verification_date": "2026-08-12", | |
| 2447 | + "status": "verified", | |
| 2448 | + "enabled": true | |
| 2449 | + }, | |
| 2450 | + { | |
| 2451 | + "id": "alma.alapero.ca", | |
| 2452 | + "name": "À l’Apéro Alma", | |
| 2453 | + "url": "https://alma.alapero.ca", | |
| 2454 | + "platform": "", | |
| 2455 | + "catalog_endpoint": "", | |
| 2456 | + "city": "", | |
| 2457 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 2458 | + "postal_prefix": "G8B", | |
| 2459 | + "phone": "418 719-6383", | |
| 2460 | + "origin_class": "A", | |
| 2461 | + "origin_confidence": 0.85, | |
| 2462 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 2463 | + "categories": [ | |
| 2464 | + "epicerie" | |
| 2465 | + ], | |
| 2466 | + "socials": [ | |
| 2467 | + "https://www.facebook.com/zoneboreale", | |
| 2468 | + "https://www.instagram.com/zone_boreale/", | |
| 2469 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 2470 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 2471 | + ], | |
| 2472 | + "discovery_sources": [ | |
| 2473 | + "zoneboreale" | |
| 2474 | + ], | |
| 2475 | + "discovery_source_urls": [ | |
| 2476 | + "https://zoneboreale.com/entreprises/a-lapero-alma/" | |
| 2477 | + ], | |
| 2478 | + "language": "fr", | |
| 2479 | + "ecommerce": true, | |
| 2480 | + "verification_date": "2026-08-12", | |
| 2481 | + "status": "verified", | |
| 2482 | + "enabled": false | |
| 2483 | + }, | |
| 2484 | + { | |
| 2485 | + "id": "almaxcanada.com", | |
| 2486 | + "name": "Almax Canada", | |
| 2487 | + "url": "https://www.almaxcanada.com", | |
| 2488 | + "platform": "", | |
| 2489 | + "catalog_endpoint": "", | |
| 2490 | + "city": "", | |
| 2491 | + "region": "", | |
| 2492 | + "postal_prefix": "H8N", | |
| 2493 | + "phone": "514 762-5629", | |
| 2494 | + "origin_class": "A", | |
| 2495 | + "origin_confidence": 0.5, | |
| 2496 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 2497 | + "categories": [ | |
| 2498 | + "industriel" | |
| 2499 | + ], | |
| 2500 | + "socials": [], | |
| 2501 | + "discovery_sources": [ | |
| 2502 | + "search_sweep_manuf" | |
| 2503 | + ], | |
| 2504 | + "discovery_source_urls": [ | |
| 2505 | + "fabricant québécois escaliers rampes aluminium boutique en ligne" | |
| 2506 | + ], | |
| 2507 | + "language": "fr", | |
| 2508 | + "ecommerce": false, | |
| 2509 | + "verification_date": "2026-08-12", | |
| 2510 | + "status": "probable", | |
| 2511 | + "enabled": false | |
| 2512 | + }, | |
| 2513 | + { | |
| 2514 | + "id": "aloreedeschamps.com", | |
| 2515 | + "name": "ÉCONOMUSÉE® de la charcutière d’agneau", | |
| 2516 | + "url": "https://www.aloreedeschamps.com", | |
| 2517 | + "platform": "", | |
| 2518 | + "catalog_endpoint": "", | |
| 2519 | + "city": "", | |
| 2520 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 2521 | + "postal_prefix": "G0W", | |
| 2522 | + "phone": "418 669 3038", | |
| 2523 | + "origin_class": "A", | |
| 2524 | + "origin_confidence": 0.9, | |
| 2525 | + "origin_evidence": "Artisan certifié du réseau Économusée / Artisans à l'œuvre (production artisanale sur place)", | |
| 2526 | + "categories": [ | |
| 2527 | + "art" | |
| 2528 | + ], | |
| 2529 | + "socials": [ | |
| 2530 | + "https://www.facebook.com/Artisansaloeuvre", | |
| 2531 | + "https://www.instagram.com/artisansaloeuvreqc/", | |
| 2532 | + "https://www.youtube.com/@artisansaloeuvre", | |
| 2533 | + "https://www.linkedin.com/company/societe-du-reseau-economusee/" | |
| 2534 | + ], | |
| 2535 | + "discovery_sources": [ | |
| 2536 | + "artisansaloeuvre", | |
| 2537 | + "zoneboreale" | |
| 2538 | + ], | |
| 2539 | + "discovery_source_urls": [ | |
| 2540 | + "https://artisansaloeuvre.com/artisans/economusee-de-la-charcutiere-dagneau/", | |
| 2541 | + "https://zoneboreale.com/entreprises/a-loree-des-champs/" | |
| 2542 | + ], | |
| 2543 | + "language": "fr", | |
| 2544 | + "ecommerce": false, | |
| 2545 | + "verification_date": "2026-08-12", | |
| 2546 | + "status": "verified", | |
| 2547 | + "enabled": false | |
| 2548 | + }, | |
| 2549 | + { | |
| 2550 | + "id": "alpagabersi.ca", | |
| 2551 | + "name": "Alpaga Bersi", | |
| 2552 | + "url": "https://alpagabersi.ca", | |
| 2553 | + "platform": "woocommerce", | |
| 2554 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 2555 | + "city": "", | |
| 2556 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 2557 | + "postal_prefix": "G7H", | |
| 2558 | + "phone": "418 690-0896", | |
| 2559 | + "origin_class": "A", | |
| 2560 | + "origin_confidence": 0.85, | |
| 2561 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 2562 | + "categories": [ | |
| 2563 | + "epicerie" | |
| 2564 | + ], | |
| 2565 | + "socials": [ | |
| 2566 | + "https://www.facebook.com/zoneboreale", | |
| 2567 | + "https://www.instagram.com/zone_boreale/", | |
| 2568 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 2569 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 2570 | + ], | |
| 2571 | + "discovery_sources": [ | |
| 2572 | + "zoneboreale" | |
| 2573 | + ], | |
| 2574 | + "discovery_source_urls": [ | |
| 2575 | + "https://zoneboreale.com/entreprises/alpaga-bersi/" | |
| 2576 | + ], | |
| 2577 | + "language": "fr", | |
| 2578 | + "ecommerce": true, | |
| 2579 | + "verification_date": "2026-08-12", | |
| 2580 | + "status": "verified", | |
| 2581 | + "enabled": true | |
| 2582 | + }, | |
| 2583 | + { | |
| 2584 | + "id": "alpagadore.com", | |
| 2585 | + "name": "AlpagAdore", | |
| 2586 | + "url": "https://alpagadore.com", | |
| 2587 | + "platform": "", | |
| 2588 | + "catalog_endpoint": "", | |
| 2589 | + "city": "", | |
| 2590 | + "region": "Montérégie", | |
| 2591 | + "postal_prefix": "J0S", | |
| 2592 | + "phone": "514 847-9799", | |
| 2593 | + "origin_class": "A", | |
| 2594 | + "origin_confidence": 0.9, | |
| 2595 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 2596 | + "categories": [], | |
| 2597 | + "socials": [ | |
| 2598 | + "https://www.facebook.com/lesproduitsduqc", | |
| 2599 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 2600 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 2601 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 2602 | + ], | |
| 2603 | + "discovery_sources": [ | |
| 2604 | + "lesproduitsduquebec_ent" | |
| 2605 | + ], | |
| 2606 | + "discovery_source_urls": [ | |
| 2607 | + "https://lesproduitsduquebec.com/entreprises-adherentes/alpagadore" | |
| 2608 | + ], | |
| 2609 | + "language": "fr", | |
| 2610 | + "ecommerce": true, | |
| 2611 | + "verification_date": "2026-08-12", | |
| 2612 | + "status": "verified", | |
| 2613 | + "enabled": false | |
| 2614 | + }, | |
| 2615 | + { | |
| 2616 | + "id": "alpagaselect.ca", | |
| 2617 | + "name": "Alpagaselect - CMAQ", | |
| 2618 | + "url": "https://www.alpagaselect.ca", | |
| 2619 | + "platform": "woocommerce", | |
| 2620 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 2621 | + "city": "", | |
| 2622 | + "region": "", | |
| 2623 | + "postal_prefix": "G6J", | |
| 2624 | + "phone": "514 847-9799", | |
| 2625 | + "origin_class": "A", | |
| 2626 | + "origin_confidence": 0.85, | |
| 2627 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 2628 | + "categories": [ | |
| 2629 | + "art" | |
| 2630 | + ], | |
| 2631 | + "socials": [ | |
| 2632 | + "https://www.facebook.com/lesproduitsduqc", | |
| 2633 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 2634 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 2635 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 2636 | + ], | |
| 2637 | + "discovery_sources": [ | |
| 2638 | + "lesproduitsduquebec_ent", | |
| 2639 | + "search_sweep_cmaq" | |
| 2640 | + ], | |
| 2641 | + "discovery_source_urls": [ | |
| 2642 | + "https://lesproduitsduquebec.com/entreprises-adherentes/alpagaselect-cmaq", | |
| 2643 | + "Alpaga Select Linda Gagné feutrier québec" | |
| 2644 | + ], | |
| 2645 | + "language": "fr", | |
| 2646 | + "ecommerce": true, | |
| 2647 | + "verification_date": "2026-08-12", | |
| 2648 | + "status": "verified", | |
| 2649 | + "enabled": true | |
| 2650 | + }, | |
| 2651 | + { | |
| 2652 | + "id": "alpagasfibrefine.com", | |
| 2653 | + "name": "Alpagas Fibrefine", | |
| 2654 | + "url": "https://www.alpagasfibrefine.com", | |
| 2655 | + "platform": "woocommerce", | |
| 2656 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 2657 | + "city": "", | |
| 2658 | + "region": "Centre-du-Québec", | |
| 2659 | + "postal_prefix": "G0Z", | |
| 2660 | + "phone": "819-817-3395", | |
| 2661 | + "origin_class": "A", | |
| 2662 | + "origin_confidence": 0.55, | |
| 2663 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 2664 | + "categories": [], | |
| 2665 | + "socials": [ | |
| 2666 | + "facebook.com", | |
| 2667 | + "instagram.com" | |
| 2668 | + ], | |
| 2669 | + "discovery_sources": [ | |
| 2670 | + "search_sweep_nonfood" | |
| 2671 | + ], | |
| 2672 | + "discovery_source_urls": [ | |
| 2673 | + "Alpagas Fibrefine Sainte-Eulalie textile alpaga boutique en ligne" | |
| 2674 | + ], | |
| 2675 | + "language": "fr", | |
| 2676 | + "ecommerce": true, | |
| 2677 | + "verification_date": "2026-08-12", | |
| 2678 | + "status": "probable", | |
| 2679 | + "enabled": true | |
| 2680 | + }, | |
| 2681 | + { | |
| 2682 | + "id": "alphasigna.com", | |
| 2683 | + "name": "Alpha Signa", | |
| 2684 | + "url": "https://alphasigna.com", | |
| 2685 | + "platform": "prestashop", | |
| 2686 | + "catalog_endpoint": "__generic__", | |
| 2687 | + "city": "", | |
| 2688 | + "region": "Montérégie", | |
| 2689 | + "postal_prefix": "J4B", | |
| 2690 | + "phone": "450 356-1909", | |
| 2691 | + "origin_class": "A", | |
| 2692 | + "origin_confidence": 0.5, | |
| 2693 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 2694 | + "categories": [ | |
| 2695 | + "industriel" | |
| 2696 | + ], | |
| 2697 | + "socials": [ | |
| 2698 | + "facebook.com" | |
| 2699 | + ], | |
| 2700 | + "discovery_sources": [ | |
| 2701 | + "search_sweep_manuf" | |
| 2702 | + ], | |
| 2703 | + "discovery_source_urls": [ | |
| 2704 | + "fabricant québécois signalisation boutique en ligne" | |
| 2705 | + ], | |
| 2706 | + "language": "en", | |
| 2707 | + "ecommerce": true, | |
| 2708 | + "verification_date": "2026-08-12", | |
| 2709 | + "status": "probable", | |
| 2710 | + "enabled": true | |
| 2711 | + }, | |
| 2712 | + { | |
| 2713 | + "id": "alphatango.ca", | |
| 2714 | + "name": "Spiritueux Alpha Tango", | |
| 2715 | + "url": "https://www.alphatango.ca", | |
| 2716 | + "platform": "ecwid", | |
| 2717 | + "catalog_endpoint": "", | |
| 2718 | + "city": "", | |
| 2719 | + "region": "Abitibi-Témiscamingue", | |
| 2720 | + "postal_prefix": "J9P", | |
| 2721 | + "phone": "819 824-2212", | |
| 2722 | + "origin_class": "A", | |
| 2723 | + "origin_confidence": 0.95, | |
| 2724 | + "origin_evidence": "Distillerie recensée par Distilleries du Québec ; le site affiche une mention de fabrication québécoise", | |
| 2725 | + "categories": [ | |
| 2726 | + "alcool" | |
| 2727 | + ], | |
| 2728 | + "socials": [ | |
| 2729 | + "https://www.facebook.com/alphatango.ca/", | |
| 2730 | + "http://instagram.com/alphatango.ca/", | |
| 2731 | + "https://www.facebook.com/distilleriedequebec/", | |
| 2732 | + "https://www.instagram.com/distilleriedequebec/" | |
| 2733 | + ], | |
| 2734 | + "discovery_sources": [ | |
| 2735 | + "distilleriesduquebec" | |
| 2736 | + ], | |
| 2737 | + "discovery_source_urls": [ | |
| 2738 | + "https://distilleriesduquebec.com/fr/le-repertoire-des-distilleries/spiritueux-alpha-tango" | |
| 2739 | + ], | |
| 2740 | + "language": "en", | |
| 2741 | + "ecommerce": true, | |
| 2742 | + "verification_date": "2026-08-12", | |
| 2743 | + "status": "verified", | |
| 2744 | + "enabled": false | |
| 2745 | + }, | |
| 2746 | + { | |
| 2747 | + "id": "alternaeco.com", | |
| 2748 | + "name": "Alterna éco-solutions", | |
| 2749 | + "url": "https://alternaeco.com", | |
| 2750 | + "platform": "woocommerce", | |
| 2751 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 2752 | + "city": "", | |
| 2753 | + "region": "", | |
| 2754 | + "postal_prefix": "G2K", | |
| 2755 | + "phone": "514 847-9799", | |
| 2756 | + "origin_class": "A", | |
| 2757 | + "origin_confidence": 0.9, | |
| 2758 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 2759 | + "categories": [], | |
| 2760 | + "socials": [ | |
| 2761 | + "https://www.facebook.com/lesproduitsduqc", | |
| 2762 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 2763 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 2764 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 2765 | + ], | |
| 2766 | + "discovery_sources": [ | |
| 2767 | + "lesproduitsduquebec_ent" | |
| 2768 | + ], | |
| 2769 | + "discovery_source_urls": [ | |
| 2770 | + "https://lesproduitsduquebec.com/entreprises-adherentes/alterna-eco-solutions" | |
| 2771 | + ], | |
| 2772 | + "language": "fr", | |
| 2773 | + "ecommerce": true, | |
| 2774 | + "verification_date": "2026-08-12", | |
| 2775 | + "status": "verified", | |
| 2776 | + "enabled": true | |
| 2777 | + }, | |
| 2778 | + { | |
| 2779 | + "id": "alumix.ca", | |
| 2780 | + "name": "Alumix Aluminium", | |
| 2781 | + "url": "https://alumix.ca", | |
| 2782 | + "platform": "woocommerce", | |
| 2783 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 2784 | + "city": "", | |
| 2785 | + "region": "", | |
| 2786 | + "postal_prefix": "H4S", | |
| 2787 | + "phone": "514-335-0555", | |
| 2788 | + "origin_class": "A", | |
| 2789 | + "origin_confidence": 0.5, | |
| 2790 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 2791 | + "categories": [ | |
| 2792 | + "industriel" | |
| 2793 | + ], | |
| 2794 | + "socials": [ | |
| 2795 | + "facebook.com", | |
| 2796 | + "instagram.com" | |
| 2797 | + ], | |
| 2798 | + "discovery_sources": [ | |
| 2799 | + "search_sweep_manuf" | |
| 2800 | + ], | |
| 2801 | + "discovery_source_urls": [ | |
| 2802 | + "fabricant québécois escaliers rampes aluminium boutique en ligne" | |
| 2803 | + ], | |
| 2804 | + "language": "en", | |
| 2805 | + "ecommerce": true, | |
| 2806 | + "verification_date": "2026-08-12", | |
| 2807 | + "status": "probable", | |
| 2808 | + "enabled": true | |
| 2809 | + }, | |
| 2810 | + { | |
| 2811 | + "id": "alyssaartistepeintre.ca", | |
| 2812 | + "name": "Alyssa Gagné", | |
| 2813 | + "url": "https://www.alyssaartistepeintre.ca", | |
| 2814 | + "platform": "wix", | |
| 2815 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 2816 | + "city": "", | |
| 2817 | + "region": "", | |
| 2818 | + "postal_prefix": null, | |
| 2819 | + "phone": null, | |
| 2820 | + "origin_class": "A", | |
| 2821 | + "origin_confidence": 0.7, | |
| 2822 | + "origin_evidence": "Marque québécoise vendue par Signé Local ou Boutique Métiers d'art — site officiel résolu", | |
| 2823 | + "categories": [], | |
| 2824 | + "socials": [ | |
| 2825 | + "facebook.com", | |
| 2826 | + "instagram.com" | |
| 2827 | + ], | |
| 2828 | + "discovery_sources": [ | |
| 2829 | + "search_sweep_vendors" | |
| 2830 | + ], | |
| 2831 | + "discovery_source_urls": [ | |
| 2832 | + "\"Alyssa Gagné\" affiche art illustration Québec boutique" | |
| 2833 | + ], | |
| 2834 | + "language": "en", | |
| 2835 | + "ecommerce": true, | |
| 2836 | + "verification_date": "2026-08-12", | |
| 2837 | + "status": "verified", | |
| 2838 | + "enabled": true | |
| 2839 | + }, | |
| 2840 | + { | |
| 2841 | + "id": "amareebasse.com", | |
| 2842 | + "name": "À Marée Basse", | |
| 2843 | + "url": "https://amareebasse.com", | |
| 2844 | + "platform": "shopify", | |
| 2845 | + "catalog_endpoint": "/products.json", | |
| 2846 | + "city": "", | |
| 2847 | + "region": "", | |
| 2848 | + "postal_prefix": null, | |
| 2849 | + "phone": null, | |
| 2850 | + "origin_class": "A", | |
| 2851 | + "origin_confidence": 0.55, | |
| 2852 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec", | |
| 2853 | + "categories": [], | |
| 2854 | + "socials": [ | |
| 2855 | + "facebook.com", | |
| 2856 | + "instagram.com" | |
| 2857 | + ], | |
| 2858 | + "discovery_sources": [ | |
| 2859 | + "icq_resolved" | |
| 2860 | + ], | |
| 2861 | + "discovery_source_urls": [ | |
| 2862 | + "https://www.ideecadeauquebec.com/boutique/amareebasse/" | |
| 2863 | + ], | |
| 2864 | + "language": null, | |
| 2865 | + "ecommerce": true, | |
| 2866 | + "verification_date": "2026-08-12", | |
| 2867 | + "status": "probable", | |
| 2868 | + "enabled": true | |
| 2869 | + }, | |
| 2870 | + { | |
| 2871 | + "id": "amaro.ca", | |
| 2872 | + "name": "Amaro Inc.", | |
| 2873 | + "url": "https://amaro.ca", | |
| 2874 | + "platform": "wordpress", | |
| 2875 | + "catalog_endpoint": "", | |
| 2876 | + "city": "", | |
| 2877 | + "region": "Lanaudière", | |
| 2878 | + "postal_prefix": "J0K", | |
| 2879 | + "phone": "5145935144", | |
| 2880 | + "origin_class": "A", | |
| 2881 | + "origin_confidence": 0.9, | |
| 2882 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 2883 | + "categories": [ | |
| 2884 | + "epicerie" | |
| 2885 | + ], | |
| 2886 | + "socials": [ | |
| 2887 | + "https://www.facebook.com/alimentsduquebec", | |
| 2888 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 2889 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 2890 | + "https://www.tiktok.com/@alimentsqc" | |
| 2891 | + ], | |
| 2892 | + "discovery_sources": [ | |
| 2893 | + "alimentsduquebec_ent" | |
| 2894 | + ], | |
| 2895 | + "discovery_source_urls": [ | |
| 2896 | + "https://alimentsduquebec.com/entreprises/amaro-inc" | |
| 2897 | + ], | |
| 2898 | + "language": "fr", | |
| 2899 | + "ecommerce": false, | |
| 2900 | + "verification_date": "2026-08-12", | |
| 2901 | + "status": "verified", | |
| 2902 | + "enabled": false | |
| 2903 | + }, | |
| 2904 | + { | |
| 2905 | + "id": "ambroisemaria.com", | |
| 2906 | + "name": "PRODUCTEURS/TRANSFORMATEURS", | |
| 2907 | + "url": "https://ambroisemaria.com", | |
| 2908 | + "platform": "", | |
| 2909 | + "catalog_endpoint": "", | |
| 2910 | + "city": "", | |
| 2911 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 2912 | + "postal_prefix": "G0C", | |
| 2913 | + "phone": "418 759-3487", | |
| 2914 | + "origin_class": "A", | |
| 2915 | + "origin_confidence": 0.85, | |
| 2916 | + "origin_evidence": "Producteur/transformateur membre de Gaspésie Gourmande", | |
| 2917 | + "categories": [ | |
| 2918 | + "epicerie" | |
| 2919 | + ], | |
| 2920 | + "socials": [ | |
| 2921 | + "https://www.facebook.com/gaspesiegourmande/", | |
| 2922 | + "https://www.instagram.com/gaspesiegourmande/" | |
| 2923 | + ], | |
| 2924 | + "discovery_sources": [ | |
| 2925 | + "gaspesiegourmande" | |
| 2926 | + ], | |
| 2927 | + "discovery_source_urls": [ | |
| 2928 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=552&FiltreSec=4", | |
| 2929 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=552&Filtre=7&FiltreSec=4" | |
| 2930 | + ], | |
| 2931 | + "language": "fr", | |
| 2932 | + "ecommerce": true, | |
| 2933 | + "verification_date": "2026-08-12", | |
| 2934 | + "status": "verified", | |
| 2935 | + "enabled": false | |
| 2936 | + }, | |
| 2937 | + { | |
| 2938 | + "id": "amelielegault.com", | |
| 2939 | + "name": "Sardines Studio", | |
| 2940 | + "url": "https://sardinesstudio.com", | |
| 2941 | + "platform": "shopify", | |
| 2942 | + "catalog_endpoint": "/products.json", | |
| 2943 | + "city": "", | |
| 2944 | + "region": "", | |
| 2945 | + "postal_prefix": null, | |
| 2946 | + "phone": "514.294.1081", | |
| 2947 | + "origin_class": "A", | |
| 2948 | + "origin_confidence": 0.6, | |
| 2949 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec ; le site affiche une mention de fabrication québécoise", | |
| 2950 | + "categories": [], | |
| 2951 | + "socials": [ | |
| 2952 | + "facebook.com", | |
| 2953 | + "instagram.com" | |
| 2954 | + ], | |
| 2955 | + "discovery_sources": [ | |
| 2956 | + "icq_resolved" | |
| 2957 | + ], | |
| 2958 | + "discovery_source_urls": [ | |
| 2959 | + "https://www.ideecadeauquebec.com/boutique/sardinesstudio/" | |
| 2960 | + ], | |
| 2961 | + "language": null, | |
| 2962 | + "ecommerce": true, | |
| 2963 | + "verification_date": "2026-08-12", | |
| 2964 | + "status": "verified", | |
| 2965 | + "enabled": true | |
| 2966 | + }, | |
| 2967 | + { | |
| 2968 | + "id": "amenagementpaysagerav.ca", | |
| 2969 | + "name": "Aménager sa cour arrière : par où commencer ?", | |
| 2970 | + "url": "https://amenagementpaysagerav.ca", | |
| 2971 | + "platform": "woocommerce", | |
| 2972 | + "catalog_endpoint": "", | |
| 2973 | + "city": "", | |
| 2974 | + "region": "Laurentides", | |
| 2975 | + "postal_prefix": "J7R", | |
| 2976 | + "phone": "(450) 403-0603", | |
| 2977 | + "origin_class": "A", | |
| 2978 | + "origin_confidence": 0.55, | |
| 2979 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca", | |
| 2980 | + "categories": [], | |
| 2981 | + "socials": [ | |
| 2982 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 2983 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 2984 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 2985 | + "https://www.facebook.com/acheterquebecois/" | |
| 2986 | + ], | |
| 2987 | + "discovery_sources": [ | |
| 2988 | + "acheterquebecois" | |
| 2989 | + ], | |
| 2990 | + "discovery_source_urls": [ | |
| 2991 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/decoration-et-maison/comment-reussir-lamenagement-paysager-de-sa-cour-arriere/" | |
| 2992 | + ], | |
| 2993 | + "language": "en", | |
| 2994 | + "ecommerce": false, | |
| 2995 | + "verification_date": "2026-08-12", | |
| 2996 | + "status": "probable", | |
| 2997 | + "enabled": false | |
| 2998 | + }, | |
| 2999 | + { | |
| 3000 | + "id": "amereaboire.com", | |
| 3001 | + "name": "Brasserie Artisanale L'Amère à Boire", | |
| 3002 | + "url": "https://amereaboire.com", | |
| 3003 | + "platform": "wordpress", | |
| 3004 | + "catalog_endpoint": "", | |
| 3005 | + "city": "", | |
| 3006 | + "region": "Montréal", | |
| 3007 | + "postal_prefix": "H2X", | |
| 3008 | + "phone": "514 282-7448", | |
| 3009 | + "origin_class": "A", | |
| 3010 | + "origin_confidence": 0.98, | |
| 3011 | + "origin_evidence": "Microbrasserie membre de l'AMBQ (brassage au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 3012 | + "categories": [ | |
| 3013 | + "alcool" | |
| 3014 | + ], | |
| 3015 | + "socials": [ | |
| 3016 | + "https://www.facebook.com/bieresdemicroqc", | |
| 3017 | + "https://www.instagram.com/bieresdemicroqc/", | |
| 3018 | + "https://www.youtube.com/channel/UCJuiS8h8Ing9rQHrdyKDJsQ", | |
| 3019 | + "https://www.facebook.com/amereaboire" | |
| 3020 | + ], | |
| 3021 | + "discovery_sources": [ | |
| 3022 | + "ambq_profils" | |
| 3023 | + ], | |
| 3024 | + "discovery_source_urls": [ | |
| 3025 | + "https://ambq.ca/fr/microbrasserie/brasserie-artisanale-l-am-re-boire" | |
| 3026 | + ], | |
| 3027 | + "language": "fr", | |
| 3028 | + "ecommerce": false, | |
| 3029 | + "verification_date": "2026-08-12", | |
| 3030 | + "status": "probable", | |
| 3031 | + "enabled": false | |
| 3032 | + }, | |
| 3033 | + { | |
| 3034 | + "id": "ameroquois.ca", | |
| 3035 | + "name": "Améroquois inc.", | |
| 3036 | + "url": "https://ameroquois.ca", | |
| 3037 | + "platform": "woocommerce", | |
| 3038 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 3039 | + "city": "", | |
| 3040 | + "region": "Montérégie", | |
| 3041 | + "postal_prefix": "J0H", | |
| 3042 | + "phone": "819 479-4788", | |
| 3043 | + "origin_class": "A", | |
| 3044 | + "origin_confidence": 0.9, | |
| 3045 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 3046 | + "categories": [ | |
| 3047 | + "epicerie" | |
| 3048 | + ], | |
| 3049 | + "socials": [ | |
| 3050 | + "https://www.facebook.com/alimentsduquebec", | |
| 3051 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3052 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3053 | + "https://www.tiktok.com/@alimentsqc" | |
| 3054 | + ], | |
| 3055 | + "discovery_sources": [ | |
| 3056 | + "alimentsduquebec_ent" | |
| 3057 | + ], | |
| 3058 | + "discovery_source_urls": [ | |
| 3059 | + "https://alimentsduquebec.com/entreprises/ameroquois-inc" | |
| 3060 | + ], | |
| 3061 | + "language": "fr", | |
| 3062 | + "ecommerce": true, | |
| 3063 | + "verification_date": "2026-08-12", | |
| 3064 | + "status": "verified", | |
| 3065 | + "enabled": true | |
| 3066 | + }, | |
| 3067 | + { | |
| 3068 | + "id": "ammatherapie.ca", | |
| 3069 | + "name": "Amma Thérapie", | |
| 3070 | + "url": "https://ammatherapie.ca", | |
| 3071 | + "platform": "woocommerce", | |
| 3072 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 3073 | + "city": "", | |
| 3074 | + "region": "", | |
| 3075 | + "postal_prefix": null, | |
| 3076 | + "phone": null, | |
| 3077 | + "origin_class": "A", | |
| 3078 | + "origin_confidence": 0.75, | |
| 3079 | + "origin_evidence": "Marque québécoise vendue par Signé Local ou Boutique Métiers d'art — site officiel résolu ; le site affiche une mention de fabrication québécoise", | |
| 3080 | + "categories": [], | |
| 3081 | + "socials": [ | |
| 3082 | + "facebook.com", | |
| 3083 | + "instagram.com" | |
| 3084 | + ], | |
| 3085 | + "discovery_sources": [ | |
| 3086 | + "search_sweep_vendors" | |
| 3087 | + ], | |
| 3088 | + "discovery_source_urls": [ | |
| 3089 | + "direct domain guess ammatherapie.ca, verified via WebFetch" | |
| 3090 | + ], | |
| 3091 | + "language": "en", | |
| 3092 | + "ecommerce": true, | |
| 3093 | + "verification_date": "2026-08-12", | |
| 3094 | + "status": "verified", | |
| 3095 | + "enabled": true | |
| 3096 | + }, | |
| 3097 | + { | |
| 3098 | + "id": "amourliquide.com", | |
| 3099 | + "name": "Amour liquide", | |
| 3100 | + "url": "https://amourliquide.com", | |
| 3101 | + "platform": "wordpress", | |
| 3102 | + "catalog_endpoint": "", | |
| 3103 | + "city": "", | |
| 3104 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 3105 | + "postal_prefix": "G0V", | |
| 3106 | + "phone": null, | |
| 3107 | + "origin_class": "A", | |
| 3108 | + "origin_confidence": 0.6, | |
| 3109 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3110 | + "categories": [ | |
| 3111 | + "epicerie" | |
| 3112 | + ], | |
| 3113 | + "socials": [ | |
| 3114 | + "https://www.facebook.com/zoneboreale", | |
| 3115 | + "https://www.instagram.com/zone_boreale/", | |
| 3116 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 3117 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 3118 | + ], | |
| 3119 | + "discovery_sources": [ | |
| 3120 | + "zoneboreale" | |
| 3121 | + ], | |
| 3122 | + "discovery_source_urls": [ | |
| 3123 | + "https://zoneboreale.com/entreprises/amour-liquide/" | |
| 3124 | + ], | |
| 3125 | + "language": "fr", | |
| 3126 | + "ecommerce": false, | |
| 3127 | + "verification_date": "2026-08-12", | |
| 3128 | + "status": "probable", | |
| 3129 | + "enabled": false | |
| 3130 | + }, | |
| 3131 | + { | |
| 3132 | + "id": "amuletteboutique.com", | |
| 3133 | + "name": "Joaillerie L'Amulette", | |
| 3134 | + "url": "https://www.amuletteboutique.com", | |
| 3135 | + "platform": "woocommerce", | |
| 3136 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 3137 | + "city": "", | |
| 3138 | + "region": "Capitale-Nationale", | |
| 3139 | + "postal_prefix": "G5A", | |
| 3140 | + "phone": "418 665-7995", | |
| 3141 | + "origin_class": "A", | |
| 3142 | + "origin_confidence": 0.7, | |
| 3143 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 3144 | + "categories": [ | |
| 3145 | + "epicerie" | |
| 3146 | + ], | |
| 3147 | + "socials": [ | |
| 3148 | + "https://www.facebook.com/joaillerie.amulette/", | |
| 3149 | + "https://www.instagram.com/joaillerie.amulette/?hl=fr", | |
| 3150 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 3151 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 3152 | + ], | |
| 3153 | + "discovery_sources": [ | |
| 3154 | + "charlevoix" | |
| 3155 | + ], | |
| 3156 | + "discovery_source_urls": [ | |
| 3157 | + "https://www.tourisme-charlevoix.com/fr/entreprises/joaillerie-lamulette" | |
| 3158 | + ], | |
| 3159 | + "language": "en", | |
| 3160 | + "ecommerce": true, | |
| 3161 | + "verification_date": "2026-08-12", | |
| 3162 | + "status": "verified", | |
| 3163 | + "enabled": true | |
| 3164 | + }, | |
| 3165 | + { | |
| 3166 | + "id": "aniello-atelier.com", | |
| 3167 | + "name": "Aniello Atelier", | |
| 3168 | + "url": "https://www.aniello-atelier.com", | |
| 3169 | + "platform": "shopify", | |
| 3170 | + "catalog_endpoint": "/products.json", | |
| 3171 | + "city": "", | |
| 3172 | + "region": "", | |
| 3173 | + "postal_prefix": null, | |
| 3174 | + "phone": null, | |
| 3175 | + "origin_class": "A", | |
| 3176 | + "origin_confidence": 0.85, | |
| 3177 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 3178 | + "categories": [ | |
| 3179 | + "art" | |
| 3180 | + ], | |
| 3181 | + "socials": [ | |
| 3182 | + "facebook.com", | |
| 3183 | + "instagram.com" | |
| 3184 | + ], | |
| 3185 | + "discovery_sources": [ | |
| 3186 | + "search_sweep_cmaq" | |
| 3187 | + ], | |
| 3188 | + "discovery_source_urls": [ | |
| 3189 | + "Aniello Atelier Emilie Dell'Aniello joaillier québec" | |
| 3190 | + ], | |
| 3191 | + "language": "en", | |
| 3192 | + "ecommerce": true, | |
| 3193 | + "verification_date": "2026-08-12", | |
| 3194 | + "status": "verified", | |
| 3195 | + "enabled": true | |
| 3196 | + }, | |
| 3197 | + { | |
| 3198 | + "id": "annemariechagnon.com", | |
| 3199 | + "name": "Anne-Marie Chagnon", | |
| 3200 | + "url": "https://annemariechagnon.com", | |
| 3201 | + "platform": "shopify", | |
| 3202 | + "catalog_endpoint": "/products.json", | |
| 3203 | + "city": "", | |
| 3204 | + "region": "Montréal", | |
| 3205 | + "postal_prefix": "H2T", | |
| 3206 | + "phone": "514 844-0499", | |
| 3207 | + "origin_class": "A", | |
| 3208 | + "origin_confidence": 0.55, | |
| 3209 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 3210 | + "categories": [], | |
| 3211 | + "socials": [ | |
| 3212 | + "facebook.com", | |
| 3213 | + "instagram.com" | |
| 3214 | + ], | |
| 3215 | + "discovery_sources": [ | |
| 3216 | + "search_sweep_nonfood", | |
| 3217 | + "search_sweep_urban" | |
| 3218 | + ], | |
| 3219 | + "discovery_source_urls": [ | |
| 3220 | + "bijoux faits main Québec boutique en ligne", | |
| 3221 | + "bijoux fabriqués Montréal designer boutique en ligne bijouterie créateur" | |
| 3222 | + ], | |
| 3223 | + "language": null, | |
| 3224 | + "ecommerce": true, | |
| 3225 | + "verification_date": "2026-08-12", | |
| 3226 | + "status": "probable", | |
| 3227 | + "enabled": true | |
| 3228 | + }, | |
| 3229 | + { | |
| 3230 | + "id": "annerenedecotret.com", | |
| 3231 | + "name": "Airs bucoliques - Acheter québécois", | |
| 3232 | + "url": "https://www.annerenedecotret.com", | |
| 3233 | + "platform": "wix", | |
| 3234 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 3235 | + "city": "", | |
| 3236 | + "region": "Centre-du-Québec", | |
| 3237 | + "postal_prefix": "J3T", | |
| 3238 | + "phone": "(450) 403-0603", | |
| 3239 | + "origin_class": "A", | |
| 3240 | + "origin_confidence": 0.55, | |
| 3241 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca", | |
| 3242 | + "categories": [], | |
| 3243 | + "socials": [ | |
| 3244 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 3245 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 3246 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 3247 | + "https://www.facebook.com/acheterquebecois/" | |
| 3248 | + ], | |
| 3249 | + "discovery_sources": [ | |
| 3250 | + "acheterquebecois" | |
| 3251 | + ], | |
| 3252 | + "discovery_source_urls": [ | |
| 3253 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/artistes-peintre/airs-bucoliques/", | |
| 3254 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/artistes-peintre/azur/", | |
| 3255 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/artistes-peintre/dans-le-sous-bois/", | |
| 3256 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/artistes-peintre/eaux-soleil-couchant/", | |
| 3257 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/artistes-peintre/flores-rojas/" | |
| 3258 | + ], | |
| 3259 | + "language": "en", | |
| 3260 | + "ecommerce": true, | |
| 3261 | + "verification_date": "2026-08-12", | |
| 3262 | + "status": "probable", | |
| 3263 | + "enabled": true | |
| 3264 | + }, | |
| 3265 | + { | |
| 3266 | + "id": "aoutrance.com", | |
| 3267 | + "name": "À Outrance", | |
| 3268 | + "url": "https://www.aoutrance.com", | |
| 3269 | + "platform": "square", | |
| 3270 | + "catalog_endpoint": "", | |
| 3271 | + "city": "", | |
| 3272 | + "region": "Outaouais", | |
| 3273 | + "postal_prefix": "J8L", | |
| 3274 | + "phone": "819-281-1987", | |
| 3275 | + "origin_class": "A", | |
| 3276 | + "origin_confidence": 0.85, | |
| 3277 | + "origin_evidence": "Entreprise membre de Croquez Outaouais (agroalimentaire Outaouais)", | |
| 3278 | + "categories": [ | |
| 3279 | + "epicerie" | |
| 3280 | + ], | |
| 3281 | + "socials": [ | |
| 3282 | + "https://www.facebook.com/croquezloutaouais/", | |
| 3283 | + "https://www.instagram.com/croquezoutaouais", | |
| 3284 | + "https://web.facebook.com/croquezloutaouais/?_rdc=1&_rdr", | |
| 3285 | + "https://www.instagram.com/croquezoutaouais/" | |
| 3286 | + ], | |
| 3287 | + "discovery_sources": [ | |
| 3288 | + "croquezoutaouais" | |
| 3289 | + ], | |
| 3290 | + "discovery_source_urls": [ | |
| 3291 | + "https://www.croquezoutaouais.com/entreprises/a-outrance/" | |
| 3292 | + ], | |
| 3293 | + "language": "en", | |
| 3294 | + "ecommerce": true, | |
| 3295 | + "verification_date": "2026-08-12", | |
| 3296 | + "status": "verified", | |
| 3297 | + "enabled": false | |
| 3298 | + }, | |
| 3299 | + { | |
| 3300 | + "id": "aperoazero.ca", | |
| 3301 | + "name": "Apéro à zéro", | |
| 3302 | + "url": "https://www.aperoazero.ca", | |
| 3303 | + "platform": "squarespace", | |
| 3304 | + "catalog_endpoint": "__generic__", | |
| 3305 | + "city": "", | |
| 3306 | + "region": "", | |
| 3307 | + "postal_prefix": "H1W", | |
| 3308 | + "phone": "5143941197", | |
| 3309 | + "origin_class": "A", | |
| 3310 | + "origin_confidence": 0.6, | |
| 3311 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec ; le site affiche une mention de fabrication québécoise", | |
| 3312 | + "categories": [], | |
| 3313 | + "socials": [ | |
| 3314 | + "facebook.com", | |
| 3315 | + "instagram.com" | |
| 3316 | + ], | |
| 3317 | + "discovery_sources": [ | |
| 3318 | + "icq_resolved" | |
| 3319 | + ], | |
| 3320 | + "discovery_source_urls": [ | |
| 3321 | + "https://www.ideecadeauquebec.com/boutique/aperoazero/" | |
| 3322 | + ], | |
| 3323 | + "language": "en", | |
| 3324 | + "ecommerce": true, | |
| 3325 | + "verification_date": "2026-08-12", | |
| 3326 | + "status": "verified", | |
| 3327 | + "enabled": true | |
| 3328 | + }, | |
| 3329 | + { | |
| 3330 | + "id": "apicurieux.ca", | |
| 3331 | + "name": "Miellerie Apicurieux S.E.N.C.", | |
| 3332 | + "url": "https://www.apicurieux.ca", | |
| 3333 | + "platform": "square", | |
| 3334 | + "catalog_endpoint": "", | |
| 3335 | + "city": "", | |
| 3336 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 3337 | + "postal_prefix": "G0V", | |
| 3338 | + "phone": "819 631-1388", | |
| 3339 | + "origin_class": "A", | |
| 3340 | + "origin_confidence": 0.6, | |
| 3341 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3342 | + "categories": [ | |
| 3343 | + "epicerie" | |
| 3344 | + ], | |
| 3345 | + "socials": [ | |
| 3346 | + "https://www.facebook.com/alimentsduquebec", | |
| 3347 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3348 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3349 | + "https://www.tiktok.com/@alimentsqc" | |
| 3350 | + ], | |
| 3351 | + "discovery_sources": [ | |
| 3352 | + "alimentsduquebec_ent", | |
| 3353 | + "zoneboreale" | |
| 3354 | + ], | |
| 3355 | + "discovery_source_urls": [ | |
| 3356 | + "https://alimentsduquebec.com/entreprises/miellerie-apicurieux-s-e-n-c", | |
| 3357 | + "https://zoneboreale.com/entreprises/apicurieux/" | |
| 3358 | + ], | |
| 3359 | + "language": "fr", | |
| 3360 | + "ecommerce": true, | |
| 3361 | + "verification_date": "2026-08-12", | |
| 3362 | + "status": "probable", | |
| 3363 | + "enabled": false | |
| 3364 | + }, | |
| 3365 | + { | |
| 3366 | + "id": "apikol.ca", | |
| 3367 | + "name": "Apikol", | |
| 3368 | + "url": "https://apikol.ca", | |
| 3369 | + "platform": "woocommerce", | |
| 3370 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 3371 | + "city": "", | |
| 3372 | + "region": "Capitale-Nationale", | |
| 3373 | + "postal_prefix": "G1K", | |
| 3374 | + "phone": "(418) 522-7000", | |
| 3375 | + "origin_class": "A", | |
| 3376 | + "origin_confidence": 0.9, | |
| 3377 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 3378 | + "categories": [ | |
| 3379 | + "epicerie" | |
| 3380 | + ], | |
| 3381 | + "socials": [ | |
| 3382 | + "https://www.facebook.com/alimentsduquebec", | |
| 3383 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3384 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3385 | + "https://www.tiktok.com/@alimentsqc" | |
| 3386 | + ], | |
| 3387 | + "discovery_sources": [ | |
| 3388 | + "alimentsduquebec_ent" | |
| 3389 | + ], | |
| 3390 | + "discovery_source_urls": [ | |
| 3391 | + "https://alimentsduquebec.com/entreprises/apikol" | |
| 3392 | + ], | |
| 3393 | + "language": "fr", | |
| 3394 | + "ecommerce": true, | |
| 3395 | + "verification_date": "2026-08-12", | |
| 3396 | + "status": "verified", | |
| 3397 | + "enabled": true | |
| 3398 | + }, | |
| 3399 | + { | |
| 3400 | + "id": "apiverte.ca", | |
| 3401 | + "name": "Apiverte Inc.", | |
| 3402 | + "url": "https://apiverte.ca", | |
| 3403 | + "platform": "shopify", | |
| 3404 | + "catalog_endpoint": "/products.json", | |
| 3405 | + "city": "", | |
| 3406 | + "region": "Outaouais", | |
| 3407 | + "postal_prefix": "J0X", | |
| 3408 | + "phone": null, | |
| 3409 | + "origin_class": "A", | |
| 3410 | + "origin_confidence": 0.55, | |
| 3411 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (producteur alimentaire QC) ; le site affiche une mention de fabrication québécoise", | |
| 3412 | + "categories": [], | |
| 3413 | + "socials": [ | |
| 3414 | + "facebook.com", | |
| 3415 | + "instagram.com" | |
| 3416 | + ], | |
| 3417 | + "discovery_sources": [ | |
| 3418 | + "search_sweep_food" | |
| 3419 | + ], | |
| 3420 | + "discovery_source_urls": [ | |
| 3421 | + "miel boutique en ligne Outaouais Laurentides apiculteur artisanal" | |
| 3422 | + ], | |
| 3423 | + "language": null, | |
| 3424 | + "ecommerce": true, | |
| 3425 | + "verification_date": "2026-08-12", | |
| 3426 | + "status": "probable", | |
| 3427 | + "enabled": true | |
| 3428 | + }, | |
| 3429 | + { | |
| 3430 | + "id": "app.cyberimpact.com", | |
| 3431 | + "name": "10530573 Canada inc.", | |
| 3432 | + "url": "https://app.cyberimpact.com", | |
| 3433 | + "platform": "", | |
| 3434 | + "catalog_endpoint": "", | |
| 3435 | + "city": "", | |
| 3436 | + "region": "", | |
| 3437 | + "postal_prefix": "J4R", | |
| 3438 | + "phone": null, | |
| 3439 | + "origin_class": "A", | |
| 3440 | + "origin_confidence": 0.6, | |
| 3441 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3442 | + "categories": [ | |
| 3443 | + "epicerie" | |
| 3444 | + ], | |
| 3445 | + "socials": [ | |
| 3446 | + "https://www.facebook.com/alimentsduquebec", | |
| 3447 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3448 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3449 | + "https://www.tiktok.com/@alimentsqc" | |
| 3450 | + ], | |
| 3451 | + "discovery_sources": [ | |
| 3452 | + "alimentsduquebec_ent" | |
| 3453 | + ], | |
| 3454 | + "discovery_source_urls": [ | |
| 3455 | + "https://alimentsduquebec.com/entreprises/10530573-canada-inc", | |
| 3456 | + "https://alimentsduquebec.com/entreprises/1143888-canada-inc", | |
| 3457 | + "https://alimentsduquebec.com/entreprises/123-crepes-inc", | |
| 3458 | + "https://alimentsduquebec.com/entreprises/1642-sodas-inc", | |
| 3459 | + "https://alimentsduquebec.com/entreprises/16832750-canada-inc" | |
| 3460 | + ], | |
| 3461 | + "language": null, | |
| 3462 | + "ecommerce": false, | |
| 3463 | + "verification_date": "2026-08-12", | |
| 3464 | + "status": "probable", | |
| 3465 | + "enabled": false | |
| 3466 | + }, | |
| 3467 | + { | |
| 3468 | + "id": "app.flodesk.com", | |
| 3469 | + "name": "Alcyon", | |
| 3470 | + "url": "https://app.flodesk.com", | |
| 3471 | + "platform": "", | |
| 3472 | + "catalog_endpoint": "", | |
| 3473 | + "city": "", | |
| 3474 | + "region": "", | |
| 3475 | + "postal_prefix": null, | |
| 3476 | + "phone": null, | |
| 3477 | + "origin_class": "A", | |
| 3478 | + "origin_confidence": 0.3, | |
| 3479 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3480 | + "categories": [], | |
| 3481 | + "socials": [ | |
| 3482 | + "https://www.instagram.com/ideecadeauquebec/", | |
| 3483 | + "https://www.facebook.com/IdeeCadeauQuebec/", | |
| 3484 | + "https://pinterest.com/IdeeCadeauQC", | |
| 3485 | + "https://twitter.com/IdeeCadeauQC" | |
| 3486 | + ], | |
| 3487 | + "discovery_sources": [ | |
| 3488 | + "ideecadeauquebec" | |
| 3489 | + ], | |
| 3490 | + "discovery_source_urls": [ | |
| 3491 | + "https://www.ideecadeauquebec.com/boutique/alcyonsel/", | |
| 3492 | + "https://www.ideecadeauquebec.com/boutique/allosimonne/", | |
| 3493 | + "https://www.ideecadeauquebec.com/boutique/amareebasse/", | |
| 3494 | + "https://www.ideecadeauquebec.com/boutique/aperoazero/", | |
| 3495 | + "https://www.ideecadeauquebec.com/boutique/aquaovo/" | |
| 3496 | + ], | |
| 3497 | + "language": null, | |
| 3498 | + "ecommerce": false, | |
| 3499 | + "verification_date": "2026-08-12", | |
| 3500 | + "status": "probable", | |
| 3501 | + "enabled": false | |
| 3502 | + }, | |
| 3503 | + { | |
| 3504 | + "id": "app.next.nuorder.com", | |
| 3505 | + "name": "Ganka inc", | |
| 3506 | + "url": "https://app.next.nuorder.com", | |
| 3507 | + "platform": "", | |
| 3508 | + "catalog_endpoint": "", | |
| 3509 | + "city": "", | |
| 3510 | + "region": "Capitale-Nationale", | |
| 3511 | + "postal_prefix": "G2E", | |
| 3512 | + "phone": "514 847-9799", | |
| 3513 | + "origin_class": "A", | |
| 3514 | + "origin_confidence": 0.6, | |
| 3515 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3516 | + "categories": [], | |
| 3517 | + "socials": [ | |
| 3518 | + "https://www.facebook.com/lesproduitsduqc", | |
| 3519 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 3520 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 3521 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 3522 | + ], | |
| 3523 | + "discovery_sources": [ | |
| 3524 | + "lesproduitsduquebec_ent" | |
| 3525 | + ], | |
| 3526 | + "discovery_source_urls": [ | |
| 3527 | + "https://lesproduitsduquebec.com/entreprises-adherentes/ganka-inc" | |
| 3528 | + ], | |
| 3529 | + "language": null, | |
| 3530 | + "ecommerce": false, | |
| 3531 | + "verification_date": "2026-08-12", | |
| 3532 | + "status": "probable", | |
| 3533 | + "enabled": false | |
| 3534 | + }, | |
| 3535 | + { | |
| 3536 | + "id": "app.ueat.io", | |
| 3537 | + "name": "Cabane à sucre le Bistro la Dent Sucrée", | |
| 3538 | + "url": "https://app.ueat.io", | |
| 3539 | + "platform": "", | |
| 3540 | + "catalog_endpoint": "", | |
| 3541 | + "city": "", | |
| 3542 | + "region": "Laurentides", | |
| 3543 | + "postal_prefix": "J7R", | |
| 3544 | + "phone": "450 623-0062", | |
| 3545 | + "origin_class": "A", | |
| 3546 | + "origin_confidence": 0.7, | |
| 3547 | + "origin_evidence": "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3548 | + "categories": [ | |
| 3549 | + "erable" | |
| 3550 | + ], | |
| 3551 | + "socials": [ | |
| 3552 | + "https://www.facebook.com/bistroladentsucree", | |
| 3553 | + "https://www.facebook.com/Producteurs-et-productrices-ac%C3%A9ricoles-du-Qu%C3%A9bec-168610803187571/", | |
| 3554 | + "https://x.com/AcericoleQc", | |
| 3555 | + "https://www.youtube.com/channel/UCq8BHBMeMudLzspOaF9QMOg" | |
| 3556 | + ], | |
| 3557 | + "discovery_sources": [ | |
| 3558 | + "erabledici" | |
| 3559 | + ], | |
| 3560 | + "discovery_source_urls": [ | |
| 3561 | + "https://www.erabledici.ca/fr/erabliere/cabane-a-sucre-le-bistro-la-dent-sucree/" | |
| 3562 | + ], | |
| 3563 | + "language": null, | |
| 3564 | + "ecommerce": false, | |
| 3565 | + "verification_date": "2026-08-12", | |
| 3566 | + "status": "probable", | |
| 3567 | + "enabled": false | |
| 3568 | + }, | |
| 3569 | + { | |
| 3570 | + "id": "appalachesnature.com", | |
| 3571 | + "name": "Appalaches Nature inc.", | |
| 3572 | + "url": "https://appalachesnature.com", | |
| 3573 | + "platform": "wordpress", | |
| 3574 | + "catalog_endpoint": "", | |
| 3575 | + "city": "", | |
| 3576 | + "region": "Chaudière-Appalaches", | |
| 3577 | + "postal_prefix": "G6G", | |
| 3578 | + "phone": "418 423-1143", | |
| 3579 | + "origin_class": "A", | |
| 3580 | + "origin_confidence": 0.95, | |
| 3581 | + "origin_evidence": "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca)", | |
| 3582 | + "categories": [ | |
| 3583 | + "erable" | |
| 3584 | + ], | |
| 3585 | + "socials": [ | |
| 3586 | + "https://www.facebook.com/alimentsduquebec", | |
| 3587 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3588 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3589 | + "https://www.tiktok.com/@alimentsqc" | |
| 3590 | + ], | |
| 3591 | + "discovery_sources": [ | |
| 3592 | + "alimentsduquebec_ent", | |
| 3593 | + "erabledici" | |
| 3594 | + ], | |
| 3595 | + "discovery_source_urls": [ | |
| 3596 | + "https://alimentsduquebec.com/entreprises/appalaches-nature-inc", | |
| 3597 | + "https://www.erabledici.ca/fr/erabliere/appalaches-nature/" | |
| 3598 | + ], | |
| 3599 | + "language": "fr", | |
| 3600 | + "ecommerce": false, | |
| 3601 | + "verification_date": "2026-08-12", | |
| 3602 | + "status": "verified", | |
| 3603 | + "enabled": false | |
| 3604 | + }, | |
| 3605 | + { | |
| 3606 | + "id": "aprtcreations.ca", | |
| 3607 | + "name": "APRT Créations", | |
| 3608 | + "url": "https://www.aprtcreations.ca", | |
| 3609 | + "platform": "shopify", | |
| 3610 | + "catalog_endpoint": "/products.json", | |
| 3611 | + "city": "", | |
| 3612 | + "region": "", | |
| 3613 | + "postal_prefix": null, | |
| 3614 | + "phone": null, | |
| 3615 | + "origin_class": "A", | |
| 3616 | + "origin_confidence": 0.55, | |
| 3617 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 3618 | + "categories": [], | |
| 3619 | + "socials": [ | |
| 3620 | + "facebook.com" | |
| 3621 | + ], | |
| 3622 | + "discovery_sources": [ | |
| 3623 | + "search_sweep_nonfood" | |
| 3624 | + ], | |
| 3625 | + "discovery_source_urls": [ | |
| 3626 | + "vêtements conçus fabriqués au Québec boutique en ligne" | |
| 3627 | + ], | |
| 3628 | + "language": null, | |
| 3629 | + "ecommerce": true, | |
| 3630 | + "verification_date": "2026-08-12", | |
| 3631 | + "status": "probable", | |
| 3632 | + "enabled": true | |
| 3633 | + }, | |
| 3634 | + { | |
| 3635 | + "id": "aquafuchsia.com", | |
| 3636 | + "name": "LES ALIMENTS AQUAFUCHSIA inc.", | |
| 3637 | + "url": "https://www.aquafuchsia.com", | |
| 3638 | + "platform": "wordpress", | |
| 3639 | + "catalog_endpoint": "", | |
| 3640 | + "city": "", | |
| 3641 | + "region": "Laval", | |
| 3642 | + "postal_prefix": "H7C", | |
| 3643 | + "phone": null, | |
| 3644 | + "origin_class": "A", | |
| 3645 | + "origin_confidence": 0.85, | |
| 3646 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 3647 | + "categories": [ | |
| 3648 | + "epicerie" | |
| 3649 | + ], | |
| 3650 | + "socials": [ | |
| 3651 | + "https://www.facebook.com/alimentsduquebec", | |
| 3652 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3653 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3654 | + "https://www.tiktok.com/@alimentsqc" | |
| 3655 | + ], | |
| 3656 | + "discovery_sources": [ | |
| 3657 | + "alimentsduquebec_ent" | |
| 3658 | + ], | |
| 3659 | + "discovery_source_urls": [ | |
| 3660 | + "https://alimentsduquebec.com/entreprises/les-aliments-aquafuchsia-inc" | |
| 3661 | + ], | |
| 3662 | + "language": "fr", | |
| 3663 | + "ecommerce": false, | |
| 3664 | + "verification_date": "2026-08-12", | |
| 3665 | + "status": "verified", | |
| 3666 | + "enabled": false | |
| 3667 | + }, | |
| 3668 | + { | |
| 3669 | + "id": "aquaovo.com", | |
| 3670 | + "name": "Aquaovo", | |
| 3671 | + "url": "https://aquaovo.com", | |
| 3672 | + "platform": "shopify", | |
| 3673 | + "catalog_endpoint": "/products.json", | |
| 3674 | + "city": "", | |
| 3675 | + "region": "", | |
| 3676 | + "postal_prefix": null, | |
| 3677 | + "phone": "(438) 792-4266", | |
| 3678 | + "origin_class": "A", | |
| 3679 | + "origin_confidence": 0.55, | |
| 3680 | + "origin_evidence": "Entreprise québécoise répertoriée par Idée Cadeau Québec", | |
| 3681 | + "categories": [], | |
| 3682 | + "socials": [ | |
| 3683 | + "facebook.com", | |
| 3684 | + "instagram.com" | |
| 3685 | + ], | |
| 3686 | + "discovery_sources": [ | |
| 3687 | + "icq_resolved" | |
| 3688 | + ], | |
| 3689 | + "discovery_source_urls": [ | |
| 3690 | + "https://www.ideecadeauquebec.com/boutique/aquaovo/" | |
| 3691 | + ], | |
| 3692 | + "language": null, | |
| 3693 | + "ecommerce": true, | |
| 3694 | + "verification_date": "2026-08-12", | |
| 3695 | + "status": "probable", | |
| 3696 | + "enabled": true | |
| 3697 | + }, | |
| 3698 | + { | |
| 3699 | + "id": "aquavelo.ca", | |
| 3700 | + "name": "AQUAVÉLO", | |
| 3701 | + "url": "https://aquavelo.ca", | |
| 3702 | + "platform": "wordpress", | |
| 3703 | + "catalog_endpoint": "", | |
| 3704 | + "city": "", | |
| 3705 | + "region": "Chaudière-Appalaches", | |
| 3706 | + "postal_prefix": "G6G", | |
| 3707 | + "phone": "514 847-9799", | |
| 3708 | + "origin_class": "A", | |
| 3709 | + "origin_confidence": 0.9, | |
| 3710 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 3711 | + "categories": [], | |
| 3712 | + "socials": [ | |
| 3713 | + "https://www.facebook.com/lesproduitsduqc", | |
| 3714 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 3715 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 3716 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 3717 | + ], | |
| 3718 | + "discovery_sources": [ | |
| 3719 | + "lesproduitsduquebec_ent" | |
| 3720 | + ], | |
| 3721 | + "discovery_source_urls": [ | |
| 3722 | + "https://lesproduitsduquebec.com/entreprises-adherentes/aquavelo" | |
| 3723 | + ], | |
| 3724 | + "language": "fr", | |
| 3725 | + "ecommerce": false, | |
| 3726 | + "verification_date": "2026-08-12", | |
| 3727 | + "status": "verified", | |
| 3728 | + "enabled": false | |
| 3729 | + }, | |
| 3730 | + { | |
| 3731 | + "id": "aquaventurecharlevoix.ca", | |
| 3732 | + "name": "Aquaventure Charlevoix", | |
| 3733 | + "url": "https://www.aquaventurecharlevoix.ca", | |
| 3734 | + "platform": "wix", | |
| 3735 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 3736 | + "city": "", | |
| 3737 | + "region": "Capitale-Nationale", | |
| 3738 | + "postal_prefix": "G0A", | |
| 3739 | + "phone": "418 617-5904", | |
| 3740 | + "origin_class": "A", | |
| 3741 | + "origin_confidence": 0.7, | |
| 3742 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 3743 | + "categories": [ | |
| 3744 | + "epicerie" | |
| 3745 | + ], | |
| 3746 | + "socials": [ | |
| 3747 | + "https://www.facebook.com/profile.php?id=61590416739590&locale=fr_CA", | |
| 3748 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 3749 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 3750 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 3751 | + ], | |
| 3752 | + "discovery_sources": [ | |
| 3753 | + "charlevoix" | |
| 3754 | + ], | |
| 3755 | + "discovery_source_urls": [ | |
| 3756 | + "https://www.tourisme-charlevoix.com/fr/entreprises/aquaventure-charlevoix" | |
| 3757 | + ], | |
| 3758 | + "language": "fr", | |
| 3759 | + "ecommerce": true, | |
| 3760 | + "verification_date": "2026-08-12", | |
| 3761 | + "status": "verified", | |
| 3762 | + "enabled": true | |
| 3763 | + }, | |
| 3764 | + { | |
| 3765 | + "id": "archambault.ca", | |
| 3766 | + "name": "Cooler Garçon d'horreur - Acheter québécois", | |
| 3767 | + "url": "https://archambault.ca", | |
| 3768 | + "platform": "", | |
| 3769 | + "catalog_endpoint": "", | |
| 3770 | + "city": "", | |
| 3771 | + "region": "", | |
| 3772 | + "postal_prefix": null, | |
| 3773 | + "phone": "(450) 403-0603", | |
| 3774 | + "origin_class": "A", | |
| 3775 | + "origin_confidence": 0.3, | |
| 3776 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 3777 | + "categories": [], | |
| 3778 | + "socials": [ | |
| 3779 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 3780 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 3781 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 3782 | + "https://www.facebook.com/acheterquebecois/" | |
| 3783 | + ], | |
| 3784 | + "discovery_sources": [ | |
| 3785 | + "acheterquebecois" | |
| 3786 | + ], | |
| 3787 | + "discovery_source_urls": [ | |
| 3788 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/livres-auteur-auteure-quebecois/cooler-garcon-dhorreur/" | |
| 3789 | + ], | |
| 3790 | + "language": null, | |
| 3791 | + "ecommerce": true, | |
| 3792 | + "verification_date": "2026-08-12", | |
| 3793 | + "status": "probable", | |
| 3794 | + "enabled": false | |
| 3795 | + }, | |
| 3796 | + { | |
| 3797 | + "id": "argousiere.com", | |
| 3798 | + "name": "Verger l'Argousière", | |
| 3799 | + "url": "https://www.argousiere.com", | |
| 3800 | + "platform": "woocommerce", | |
| 3801 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 3802 | + "city": "", | |
| 3803 | + "region": "Chaudière-Appalaches", | |
| 3804 | + "postal_prefix": "G0M", | |
| 3805 | + "phone": "(418) 215-1570", | |
| 3806 | + "origin_class": "A", | |
| 3807 | + "origin_confidence": 0.8, | |
| 3808 | + "origin_evidence": "Arrêt gourmand certifié (Chaudière-Appalaches)", | |
| 3809 | + "categories": [ | |
| 3810 | + "epicerie" | |
| 3811 | + ], | |
| 3812 | + "socials": [ | |
| 3813 | + "https://www.facebook.com/argousiere", | |
| 3814 | + "https://www.facebook.com/arretsgourmands/", | |
| 3815 | + "https://www.instagram.com/arretsgourmandsca/", | |
| 3816 | + "https://www.youtube.com/@arretsgourmandsca" | |
| 3817 | + ], | |
| 3818 | + "discovery_sources": [ | |
| 3819 | + "arretsgourmands" | |
| 3820 | + ], | |
| 3821 | + "discovery_source_urls": [ | |
| 3822 | + "https://arretsgourmands.ca/arrets-gourmands/verger-l-argousiere" | |
| 3823 | + ], | |
| 3824 | + "language": "en", | |
| 3825 | + "ecommerce": true, | |
| 3826 | + "verification_date": "2026-08-12", | |
| 3827 | + "status": "verified", | |
| 3828 | + "enabled": true | |
| 3829 | + }, | |
| 3830 | + { | |
| 3831 | + "id": "arico.ca", | |
| 3832 | + "name": "ARICO", | |
| 3833 | + "url": "https://arico.ca", | |
| 3834 | + "platform": "shopify", | |
| 3835 | + "catalog_endpoint": "/products.json", | |
| 3836 | + "city": "", | |
| 3837 | + "region": "", | |
| 3838 | + "postal_prefix": "G1N", | |
| 3839 | + "phone": "514 847-9799", | |
| 3840 | + "origin_class": "A", | |
| 3841 | + "origin_confidence": 0.9, | |
| 3842 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 3843 | + "categories": [], | |
| 3844 | + "socials": [ | |
| 3845 | + "https://www.facebook.com/lesproduitsduqc", | |
| 3846 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 3847 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 3848 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 3849 | + ], | |
| 3850 | + "discovery_sources": [ | |
| 3851 | + "icq_resolved", | |
| 3852 | + "lesproduitsduquebec_ent", | |
| 3853 | + "search_sweep_vendors" | |
| 3854 | + ], | |
| 3855 | + "discovery_source_urls": [ | |
| 3856 | + "https://www.ideecadeauquebec.com/boutique/arico/", | |
| 3857 | + "https://lesproduitsduquebec.com/entreprises-adherentes/arico", | |
| 3858 | + "\"Arico.ca\" coussins bean bag Québec" | |
| 3859 | + ], | |
| 3860 | + "language": "en", | |
| 3861 | + "ecommerce": true, | |
| 3862 | + "verification_date": "2026-08-12", | |
| 3863 | + "status": "verified", | |
| 3864 | + "enabled": true | |
| 3865 | + }, | |
| 3866 | + { | |
| 3867 | + "id": "arida.ca", | |
| 3868 | + "name": "Les aliments Arida inc.", | |
| 3869 | + "url": "https://arida.ca", | |
| 3870 | + "platform": "shopify", | |
| 3871 | + "catalog_endpoint": "/products.json", | |
| 3872 | + "city": "", | |
| 3873 | + "region": "Laurentides", | |
| 3874 | + "postal_prefix": "J5N", | |
| 3875 | + "phone": null, | |
| 3876 | + "origin_class": "A", | |
| 3877 | + "origin_confidence": 0.85, | |
| 3878 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 3879 | + "categories": [ | |
| 3880 | + "epicerie" | |
| 3881 | + ], | |
| 3882 | + "socials": [ | |
| 3883 | + "https://www.facebook.com/alimentsduquebec", | |
| 3884 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3885 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3886 | + "https://www.tiktok.com/@alimentsqc" | |
| 3887 | + ], | |
| 3888 | + "discovery_sources": [ | |
| 3889 | + "alimentsduquebec_ent" | |
| 3890 | + ], | |
| 3891 | + "discovery_source_urls": [ | |
| 3892 | + "https://alimentsduquebec.com/entreprises/les-aliments-arida-inc" | |
| 3893 | + ], | |
| 3894 | + "language": "en", | |
| 3895 | + "ecommerce": true, | |
| 3896 | + "verification_date": "2026-08-12", | |
| 3897 | + "status": "verified", | |
| 3898 | + "enabled": true | |
| 3899 | + }, | |
| 3900 | + { | |
| 3901 | + "id": "aristocuisine.com", | |
| 3902 | + "name": "Aristo Sandwichs et Salades", | |
| 3903 | + "url": "https://aristocuisine.com", | |
| 3904 | + "platform": "wordpress", | |
| 3905 | + "catalog_endpoint": "", | |
| 3906 | + "city": "", | |
| 3907 | + "region": "Montréal", | |
| 3908 | + "postal_prefix": "H4N", | |
| 3909 | + "phone": "514-739-7328", | |
| 3910 | + "origin_class": "A", | |
| 3911 | + "origin_confidence": 0.9, | |
| 3912 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 3913 | + "categories": [ | |
| 3914 | + "epicerie" | |
| 3915 | + ], | |
| 3916 | + "socials": [ | |
| 3917 | + "https://www.facebook.com/alimentsduquebec", | |
| 3918 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 3919 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 3920 | + "https://www.tiktok.com/@alimentsqc" | |
| 3921 | + ], | |
| 3922 | + "discovery_sources": [ | |
| 3923 | + "alimentsduquebec_ent" | |
| 3924 | + ], | |
| 3925 | + "discovery_source_urls": [ | |
| 3926 | + "https://alimentsduquebec.com/entreprises/aristo-sandwichs-et-salades" | |
| 3927 | + ], | |
| 3928 | + "language": "fr", | |
| 3929 | + "ecommerce": false, | |
| 3930 | + "verification_date": "2026-08-12", | |
| 3931 | + "status": "verified", | |
| 3932 | + "enabled": false | |
| 3933 | + }, | |
| 3934 | + { | |
| 3935 | + "id": "arquebuserie.ca", | |
| 3936 | + "name": "Arquebuserie Abitibi", | |
| 3937 | + "url": "https://arquebuserie.ca", | |
| 3938 | + "platform": "wordpress", | |
| 3939 | + "catalog_endpoint": "", | |
| 3940 | + "city": "", | |
| 3941 | + "region": "", | |
| 3942 | + "postal_prefix": null, | |
| 3943 | + "phone": null, | |
| 3944 | + "origin_class": "A", | |
| 3945 | + "origin_confidence": 0.85, | |
| 3946 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 3947 | + "categories": [ | |
| 3948 | + "art" | |
| 3949 | + ], | |
| 3950 | + "socials": [ | |
| 3951 | + "facebook.com" | |
| 3952 | + ], | |
| 3953 | + "discovery_sources": [ | |
| 3954 | + "search_sweep_cmaq" | |
| 3955 | + ], | |
| 3956 | + "discovery_source_urls": [ | |
| 3957 | + "Arquebuserie Abitibi Karl Gibbs armurier québec" | |
| 3958 | + ], | |
| 3959 | + "language": "fr", | |
| 3960 | + "ecommerce": false, | |
| 3961 | + "verification_date": "2026-08-12", | |
| 3962 | + "status": "verified", | |
| 3963 | + "enabled": false | |
| 3964 | + }, | |
| 3965 | + { | |
| 3966 | + "id": "arsenaldistillerie.com", | |
| 3967 | + "name": "ÉCONOMUSÉE® de la distillation", | |
| 3968 | + "url": "https://arsenaldistillerie.com", | |
| 3969 | + "platform": "wordpress", | |
| 3970 | + "catalog_endpoint": "", | |
| 3971 | + "city": "", | |
| 3972 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 3973 | + "postal_prefix": "G1N", | |
| 3974 | + "phone": "418 842-9076", | |
| 3975 | + "origin_class": "A", | |
| 3976 | + "origin_confidence": 0.98, | |
| 3977 | + "origin_evidence": "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca) ; le site affiche une mention de fabrication québécoise", | |
| 3978 | + "categories": [ | |
| 3979 | + "erable" | |
| 3980 | + ], | |
| 3981 | + "socials": [ | |
| 3982 | + "https://www.facebook.com/Artisansaloeuvre", | |
| 3983 | + "https://www.instagram.com/artisansaloeuvreqc/", | |
| 3984 | + "https://www.youtube.com/@artisansaloeuvre", | |
| 3985 | + "https://www.linkedin.com/company/societe-du-reseau-economusee/" | |
| 3986 | + ], | |
| 3987 | + "discovery_sources": [ | |
| 3988 | + "artisansaloeuvre", | |
| 3989 | + "distilleriesduquebec", | |
| 3990 | + "erabledici" | |
| 3991 | + ], | |
| 3992 | + "discovery_source_urls": [ | |
| 3993 | + "https://artisansaloeuvre.com/artisans/economusee-de-la-distillation/", | |
| 3994 | + "https://distilleriesduquebec.com/fr/le-repertoire-des-distilleries/distillerie-arsenal", | |
| 3995 | + "https://www.erabledici.ca/fr/erabliere/distillerie-arsenal-co/" | |
| 3996 | + ], | |
| 3997 | + "language": "en", | |
| 3998 | + "ecommerce": false, | |
| 3999 | + "verification_date": "2026-08-12", | |
| 4000 | + "status": "verified", | |
| 4001 | + "enabled": false | |
| 4002 | + }, | |
| 4003 | + { | |
| 4004 | + "id": "polkarsenal.com", | |
| 4005 | + "name": "Boucherie Charcuterie Perron", | |
| 4006 | + "url": "https://polkarsenal.com", | |
| 4007 | + "platform": "", | |
| 4008 | + "catalog_endpoint": "", | |
| 4009 | + "city": "", | |
| 4010 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 4011 | + "postal_prefix": "G8B", | |
| 4012 | + "phone": "418 251-3131", | |
| 4013 | + "origin_class": "A", | |
| 4014 | + "origin_confidence": 0.85, | |
| 4015 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 4016 | + "categories": [ | |
| 4017 | + "epicerie" | |
| 4018 | + ], | |
| 4019 | + "socials": [ | |
| 4020 | + "https://www.facebook.com/zoneboreale", | |
| 4021 | + "https://www.instagram.com/zone_boreale/", | |
| 4022 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 4023 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 4024 | + ], | |
| 4025 | + "discovery_sources": [ | |
| 4026 | + "zoneboreale" | |
| 4027 | + ], | |
| 4028 | + "discovery_source_urls": [ | |
| 4029 | + "https://zoneboreale.com/entreprises/boucherie-charcuterie-perron/", | |
| 4030 | + "https://zoneboreale.com/entreprises/boucherie-daniel-remy/", | |
| 4031 | + "https://zoneboreale.com/entreprises/boucherie-le-bouvillon-jak/", | |
| 4032 | + "https://zoneboreale.com/entreprises/boulangerie-du-royaume/", | |
| 4033 | + "https://zoneboreale.com/entreprises/charcuterie-fortin/" | |
| 4034 | + ], | |
| 4035 | + "language": "fr", | |
| 4036 | + "ecommerce": false, | |
| 4037 | + "verification_date": "2026-08-12", | |
| 4038 | + "status": "verified", | |
| 4039 | + "enabled": false | |
| 4040 | + }, | |
| 4041 | + { | |
| 4042 | + "id": "artbeton.ca", | |
| 4043 | + "name": "Les Enduits Art Béton - CMAQ", | |
| 4044 | + "url": "https://artbeton.ca", | |
| 4045 | + "platform": "woocommerce", | |
| 4046 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4047 | + "city": "", | |
| 4048 | + "region": "Capitale-Nationale", | |
| 4049 | + "postal_prefix": "G1J", | |
| 4050 | + "phone": "514 847-9799", | |
| 4051 | + "origin_class": "A", | |
| 4052 | + "origin_confidence": 0.85, | |
| 4053 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 4054 | + "categories": [], | |
| 4055 | + "socials": [ | |
| 4056 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4057 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4058 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4059 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4060 | + ], | |
| 4061 | + "discovery_sources": [ | |
| 4062 | + "lesproduitsduquebec_ent" | |
| 4063 | + ], | |
| 4064 | + "discovery_source_urls": [ | |
| 4065 | + "https://lesproduitsduquebec.com/entreprises-adherentes/les-enduits-art-beton-cmaq" | |
| 4066 | + ], | |
| 4067 | + "language": "en", | |
| 4068 | + "ecommerce": true, | |
| 4069 | + "verification_date": "2026-08-12", | |
| 4070 | + "status": "verified", | |
| 4071 | + "enabled": true | |
| 4072 | + }, | |
| 4073 | + { | |
| 4074 | + "id": "artcimentdecoration.ca", | |
| 4075 | + "name": "Art Ciment décoration", | |
| 4076 | + "url": "https://artcimentdecoration.ca", | |
| 4077 | + "platform": "", | |
| 4078 | + "catalog_endpoint": "", | |
| 4079 | + "city": "", | |
| 4080 | + "region": "", | |
| 4081 | + "postal_prefix": null, | |
| 4082 | + "phone": "819-692-4265", | |
| 4083 | + "origin_class": "A", | |
| 4084 | + "origin_confidence": 0.85, | |
| 4085 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 4086 | + "categories": [ | |
| 4087 | + "art" | |
| 4088 | + ], | |
| 4089 | + "socials": [ | |
| 4090 | + "facebook.com", | |
| 4091 | + "instagram.com" | |
| 4092 | + ], | |
| 4093 | + "discovery_sources": [ | |
| 4094 | + "search_sweep_cmaq" | |
| 4095 | + ], | |
| 4096 | + "discovery_source_urls": [ | |
| 4097 | + "Art Ciment décoration Julie Bellemare québec" | |
| 4098 | + ], | |
| 4099 | + "language": "fr", | |
| 4100 | + "ecommerce": false, | |
| 4101 | + "verification_date": "2026-08-12", | |
| 4102 | + "status": "verified", | |
| 4103 | + "enabled": false | |
| 4104 | + }, | |
| 4105 | + { | |
| 4106 | + "id": "artgraphique.ca", | |
| 4107 | + "name": "Stickers personnalisés - Acheter québécois", | |
| 4108 | + "url": "https://artgraphique.ca", | |
| 4109 | + "platform": "woocommerce", | |
| 4110 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4111 | + "city": "", | |
| 4112 | + "region": "Montérégie", | |
| 4113 | + "postal_prefix": "J4P", | |
| 4114 | + "phone": "(450) 403-0603", | |
| 4115 | + "origin_class": "A", | |
| 4116 | + "origin_confidence": 0.55, | |
| 4117 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca", | |
| 4118 | + "categories": [], | |
| 4119 | + "socials": [ | |
| 4120 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 4121 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 4122 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 4123 | + "https://www.facebook.com/acheterquebecois/" | |
| 4124 | + ], | |
| 4125 | + "discovery_sources": [ | |
| 4126 | + "acheterquebecois" | |
| 4127 | + ], | |
| 4128 | + "discovery_source_urls": [ | |
| 4129 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/services-aux-entreprises/stickers-personnalises/" | |
| 4130 | + ], | |
| 4131 | + "language": "en", | |
| 4132 | + "ecommerce": true, | |
| 4133 | + "verification_date": "2026-08-12", | |
| 4134 | + "status": "probable", | |
| 4135 | + "enabled": true | |
| 4136 | + }, | |
| 4137 | + { | |
| 4138 | + "id": "arthicetnunc.com", | |
| 4139 | + "name": "Art Hic Et Nunc", | |
| 4140 | + "url": "https://arthicetnunc.com", | |
| 4141 | + "platform": "shopify", | |
| 4142 | + "catalog_endpoint": "/products.json", | |
| 4143 | + "city": "", | |
| 4144 | + "region": "", | |
| 4145 | + "postal_prefix": null, | |
| 4146 | + "phone": null, | |
| 4147 | + "origin_class": "A", | |
| 4148 | + "origin_confidence": 0.55, | |
| 4149 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 4150 | + "categories": [], | |
| 4151 | + "socials": [], | |
| 4152 | + "discovery_sources": [ | |
| 4153 | + "search_sweep_nonfood" | |
| 4154 | + ], | |
| 4155 | + "discovery_source_urls": [ | |
| 4156 | + "poterie céramique Estrie boutique en ligne artisan" | |
| 4157 | + ], | |
| 4158 | + "language": null, | |
| 4159 | + "ecommerce": true, | |
| 4160 | + "verification_date": "2026-08-12", | |
| 4161 | + "status": "probable", | |
| 4162 | + "enabled": true | |
| 4163 | + }, | |
| 4164 | + { | |
| 4165 | + "id": "arthriplus.com", | |
| 4166 | + "name": "Arthri-Plus INC.", | |
| 4167 | + "url": "https://arthriplus.com", | |
| 4168 | + "platform": "woocommerce", | |
| 4169 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4170 | + "city": "", | |
| 4171 | + "region": "Laurentides", | |
| 4172 | + "postal_prefix": "J4B", | |
| 4173 | + "phone": "514 847-9799", | |
| 4174 | + "origin_class": "A", | |
| 4175 | + "origin_confidence": 0.9, | |
| 4176 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 4177 | + "categories": [], | |
| 4178 | + "socials": [ | |
| 4179 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4180 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4181 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4182 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4183 | + ], | |
| 4184 | + "discovery_sources": [ | |
| 4185 | + "lesproduitsduquebec_ent" | |
| 4186 | + ], | |
| 4187 | + "discovery_source_urls": [ | |
| 4188 | + "https://lesproduitsduquebec.com/entreprises-adherentes/arthri-plus-inc" | |
| 4189 | + ], | |
| 4190 | + "language": "fr", | |
| 4191 | + "ecommerce": true, | |
| 4192 | + "verification_date": "2026-08-12", | |
| 4193 | + "status": "verified", | |
| 4194 | + "enabled": true | |
| 4195 | + }, | |
| 4196 | + { | |
| 4197 | + "id": "artinunat.com", | |
| 4198 | + "name": "Arts et Trésors Inouïs", | |
| 4199 | + "url": "https://artinunat.com", | |
| 4200 | + "platform": "", | |
| 4201 | + "catalog_endpoint": "", | |
| 4202 | + "city": "", | |
| 4203 | + "region": "Nord-du-Québec", | |
| 4204 | + "postal_prefix": null, | |
| 4205 | + "phone": "8196386969", | |
| 4206 | + "origin_class": "A", | |
| 4207 | + "origin_confidence": 0.5, | |
| 4208 | + "origin_evidence": "Trouvé par balayage ciblé des régions sous-couvertes (Nord-du-Québec/Abitibi/BSL/Côte-Nord)", | |
| 4209 | + "categories": [], | |
| 4210 | + "socials": [], | |
| 4211 | + "discovery_sources": [ | |
| 4212 | + "search_sweep_regions" | |
| 4213 | + ], | |
| 4214 | + "discovery_source_urls": [ | |
| 4215 | + "Radisson Baie-James artisanat inuit cri boutique souvenirs vente en ligne" | |
| 4216 | + ], | |
| 4217 | + "language": "fr", | |
| 4218 | + "ecommerce": true, | |
| 4219 | + "verification_date": "2026-08-12", | |
| 4220 | + "status": "probable", | |
| 4221 | + "enabled": false | |
| 4222 | + }, | |
| 4223 | + { | |
| 4224 | + "id": "artisacreations.ca", | |
| 4225 | + "name": "ARTISA CRÉATIONS - CMAQ", | |
| 4226 | + "url": "https://artisacreations.ca", | |
| 4227 | + "platform": "shopify", | |
| 4228 | + "catalog_endpoint": "/products.json", | |
| 4229 | + "city": "", | |
| 4230 | + "region": "Montréal", | |
| 4231 | + "postal_prefix": "H2T", | |
| 4232 | + "phone": "514 847-9799", | |
| 4233 | + "origin_class": "A", | |
| 4234 | + "origin_confidence": 0.6, | |
| 4235 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4236 | + "categories": [], | |
| 4237 | + "socials": [ | |
| 4238 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4239 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4240 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4241 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4242 | + ], | |
| 4243 | + "discovery_sources": [ | |
| 4244 | + "lesproduitsduquebec_ent" | |
| 4245 | + ], | |
| 4246 | + "discovery_source_urls": [ | |
| 4247 | + "https://lesproduitsduquebec.com/entreprises-adherentes/artisa-creations-cmaq" | |
| 4248 | + ], | |
| 4249 | + "language": "en", | |
| 4250 | + "ecommerce": true, | |
| 4251 | + "verification_date": "2026-08-12", | |
| 4252 | + "status": "probable", | |
| 4253 | + "enabled": true | |
| 4254 | + }, | |
| 4255 | + { | |
| 4256 | + "id": "artisandulac.ca", | |
| 4257 | + "name": "L'Artisan du Lac", | |
| 4258 | + "url": "https://artisandulac.ca", | |
| 4259 | + "platform": "woocommerce", | |
| 4260 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4261 | + "city": "", | |
| 4262 | + "region": "Abitibi-Témiscamingue", | |
| 4263 | + "postal_prefix": null, | |
| 4264 | + "phone": "450-753-7767", | |
| 4265 | + "origin_class": "A", | |
| 4266 | + "origin_confidence": 0.55, | |
| 4267 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 4268 | + "categories": [], | |
| 4269 | + "socials": [ | |
| 4270 | + "facebook.com", | |
| 4271 | + "instagram.com" | |
| 4272 | + ], | |
| 4273 | + "discovery_sources": [ | |
| 4274 | + "search_sweep_nonfood" | |
| 4275 | + ], | |
| 4276 | + "discovery_source_urls": [ | |
| 4277 | + "artisan bois meubles Abitibi-Témiscamingue vente en ligne" | |
| 4278 | + ], | |
| 4279 | + "language": "en", | |
| 4280 | + "ecommerce": true, | |
| 4281 | + "verification_date": "2026-08-12", | |
| 4282 | + "status": "probable", | |
| 4283 | + "enabled": true | |
| 4284 | + }, | |
| 4285 | + { | |
| 4286 | + "id": "artisansduvitrail.com", | |
| 4287 | + "name": "Artisans du Vitrail (Canadian Stained Glass Warehouse)", | |
| 4288 | + "url": "https://artisansduvitrail.com", | |
| 4289 | + "platform": "woocommerce", | |
| 4290 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4291 | + "city": "", | |
| 4292 | + "region": "", | |
| 4293 | + "postal_prefix": "G1L", | |
| 4294 | + "phone": "4186480969", | |
| 4295 | + "origin_class": "A", | |
| 4296 | + "origin_confidence": 0.5, | |
| 4297 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 4298 | + "categories": [ | |
| 4299 | + "industriel" | |
| 4300 | + ], | |
| 4301 | + "socials": [ | |
| 4302 | + "facebook.com" | |
| 4303 | + ], | |
| 4304 | + "discovery_sources": [ | |
| 4305 | + "search_sweep_manuf" | |
| 4306 | + ], | |
| 4307 | + "discovery_source_urls": [ | |
| 4308 | + "vitrail québécois atelier boutique en ligne" | |
| 4309 | + ], | |
| 4310 | + "language": "en", | |
| 4311 | + "ecommerce": true, | |
| 4312 | + "verification_date": "2026-08-12", | |
| 4313 | + "status": "probable", | |
| 4314 | + "enabled": true | |
| 4315 | + }, | |
| 4316 | + { | |
| 4317 | + "id": "artpublic.ca", | |
| 4318 | + "name": "Infravert (Louis Dumontier)", | |
| 4319 | + "url": "https://artpublic.ca", | |
| 4320 | + "platform": "woocommerce", | |
| 4321 | + "catalog_endpoint": "", | |
| 4322 | + "city": "", | |
| 4323 | + "region": "", | |
| 4324 | + "postal_prefix": null, | |
| 4325 | + "phone": null, | |
| 4326 | + "origin_class": "A", | |
| 4327 | + "origin_confidence": 0.6, | |
| 4328 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4329 | + "categories": [ | |
| 4330 | + "art" | |
| 4331 | + ], | |
| 4332 | + "socials": [], | |
| 4333 | + "discovery_sources": [ | |
| 4334 | + "search_sweep_cmaq" | |
| 4335 | + ], | |
| 4336 | + "discovery_source_urls": [ | |
| 4337 | + "Infravert Louis Dumontier métallier d'art québec", | |
| 4338 | + "Infravert Ariel Dumontier métallier d'art québec" | |
| 4339 | + ], | |
| 4340 | + "language": "fr", | |
| 4341 | + "ecommerce": false, | |
| 4342 | + "verification_date": "2026-08-12", | |
| 4343 | + "status": "probable", | |
| 4344 | + "enabled": false | |
| 4345 | + }, | |
| 4346 | + { | |
| 4347 | + "id": "artsvitrail.com", | |
| 4348 | + "name": "Jocelyne Grondin Arts & Vitrail", | |
| 4349 | + "url": "https://www.artsvitrail.com", | |
| 4350 | + "platform": "wix", | |
| 4351 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 4352 | + "city": "", | |
| 4353 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 4354 | + "postal_prefix": "G0W", | |
| 4355 | + "phone": "418-748-5319", | |
| 4356 | + "origin_class": "A", | |
| 4357 | + "origin_confidence": 0.5, | |
| 4358 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 4359 | + "categories": [ | |
| 4360 | + "industriel" | |
| 4361 | + ], | |
| 4362 | + "socials": [], | |
| 4363 | + "discovery_sources": [ | |
| 4364 | + "search_sweep_manuf" | |
| 4365 | + ], | |
| 4366 | + "discovery_source_urls": [ | |
| 4367 | + "vitrail québécois atelier boutique en ligne" | |
| 4368 | + ], | |
| 4369 | + "language": null, | |
| 4370 | + "ecommerce": true, | |
| 4371 | + "verification_date": "2026-08-12", | |
| 4372 | + "status": "probable", | |
| 4373 | + "enabled": true | |
| 4374 | + }, | |
| 4375 | + { | |
| 4376 | + "id": "arvidaroasting.com", | |
| 4377 | + "name": "Arvida Roasting Co.", | |
| 4378 | + "url": "https://www.arvidaroasting.com", | |
| 4379 | + "platform": "shopify", | |
| 4380 | + "catalog_endpoint": "/products.json", | |
| 4381 | + "city": "", | |
| 4382 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 4383 | + "postal_prefix": "G7X", | |
| 4384 | + "phone": "581 447-6250", | |
| 4385 | + "origin_class": "A", | |
| 4386 | + "origin_confidence": 0.85, | |
| 4387 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 4388 | + "categories": [ | |
| 4389 | + "epicerie" | |
| 4390 | + ], | |
| 4391 | + "socials": [ | |
| 4392 | + "https://www.facebook.com/zoneboreale", | |
| 4393 | + "https://www.instagram.com/zone_boreale/", | |
| 4394 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 4395 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 4396 | + ], | |
| 4397 | + "discovery_sources": [ | |
| 4398 | + "search_sweep_food", | |
| 4399 | + "zoneboreale" | |
| 4400 | + ], | |
| 4401 | + "discovery_source_urls": [ | |
| 4402 | + "torréfacteur café artisanal boutique en ligne Saguenay Lac-Saint-Jean", | |
| 4403 | + "https://zoneboreale.com/entreprises/arvida-roasting-co/" | |
| 4404 | + ], | |
| 4405 | + "language": null, | |
| 4406 | + "ecommerce": true, | |
| 4407 | + "verification_date": "2026-08-12", | |
| 4408 | + "status": "verified", | |
| 4409 | + "enabled": true | |
| 4410 | + }, | |
| 4411 | + { | |
| 4412 | + "id": "asdusmokedmeat.com", | |
| 4413 | + "name": "As du smoked meat", | |
| 4414 | + "url": "https://www.asdusmokedmeat.com", | |
| 4415 | + "platform": "wix", | |
| 4416 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 4417 | + "city": "", | |
| 4418 | + "region": "Lanaudière", | |
| 4419 | + "postal_prefix": "J5W", | |
| 4420 | + "phone": null, | |
| 4421 | + "origin_class": "A", | |
| 4422 | + "origin_confidence": 0.85, | |
| 4423 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 4424 | + "categories": [ | |
| 4425 | + "epicerie" | |
| 4426 | + ], | |
| 4427 | + "socials": [ | |
| 4428 | + "https://www.facebook.com/alimentsduquebec", | |
| 4429 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 4430 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 4431 | + "https://www.tiktok.com/@alimentsqc" | |
| 4432 | + ], | |
| 4433 | + "discovery_sources": [ | |
| 4434 | + "alimentsduquebec_ent" | |
| 4435 | + ], | |
| 4436 | + "discovery_source_urls": [ | |
| 4437 | + "https://alimentsduquebec.com/entreprises/as-du-smoked-meat" | |
| 4438 | + ], | |
| 4439 | + "language": null, | |
| 4440 | + "ecommerce": true, | |
| 4441 | + "verification_date": "2026-08-12", | |
| 4442 | + "status": "verified", | |
| 4443 | + "enabled": true | |
| 4444 | + }, | |
| 4445 | + { | |
| 4446 | + "id": "aspergesprimera.com", | |
| 4447 | + "name": "Productions Cousins Fruitiers Inc.", | |
| 4448 | + "url": "https://www.aspergesprimera.com", | |
| 4449 | + "platform": "woocommerce", | |
| 4450 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4451 | + "city": "", | |
| 4452 | + "region": "Lanaudière", | |
| 4453 | + "postal_prefix": "J0K", | |
| 4454 | + "phone": "450-803-6261", | |
| 4455 | + "origin_class": "A", | |
| 4456 | + "origin_confidence": 0.85, | |
| 4457 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 4458 | + "categories": [ | |
| 4459 | + "epicerie" | |
| 4460 | + ], | |
| 4461 | + "socials": [ | |
| 4462 | + "https://www.facebook.com/alimentsduquebec", | |
| 4463 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 4464 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 4465 | + "https://www.tiktok.com/@alimentsqc" | |
| 4466 | + ], | |
| 4467 | + "discovery_sources": [ | |
| 4468 | + "alimentsduquebec_ent" | |
| 4469 | + ], | |
| 4470 | + "discovery_source_urls": [ | |
| 4471 | + "https://alimentsduquebec.com/entreprises/productions-cousins-fruitiers-inc" | |
| 4472 | + ], | |
| 4473 | + "language": "en", | |
| 4474 | + "ecommerce": true, | |
| 4475 | + "verification_date": "2026-08-12", | |
| 4476 | + "status": "verified", | |
| 4477 | + "enabled": true | |
| 4478 | + }, | |
| 4479 | + { | |
| 4480 | + "id": "association-pieddesmonts.com", | |
| 4481 | + "name": "Association Plein Air Secteur Pied des Monts", | |
| 4482 | + "url": "https://www.association-pieddesmonts.com", | |
| 4483 | + "platform": "", | |
| 4484 | + "catalog_endpoint": "", | |
| 4485 | + "city": "", | |
| 4486 | + "region": "Capitale-Nationale", | |
| 4487 | + "postal_prefix": null, | |
| 4488 | + "phone": null, | |
| 4489 | + "origin_class": "A", | |
| 4490 | + "origin_confidence": 0.45, | |
| 4491 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4492 | + "categories": [ | |
| 4493 | + "epicerie" | |
| 4494 | + ], | |
| 4495 | + "socials": [ | |
| 4496 | + "https://www.facebook.com/share/1NZquaHshA/", | |
| 4497 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 4498 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 4499 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 4500 | + ], | |
| 4501 | + "discovery_sources": [ | |
| 4502 | + "charlevoix" | |
| 4503 | + ], | |
| 4504 | + "discovery_source_urls": [ | |
| 4505 | + "https://www.tourisme-charlevoix.com/fr/entreprises/association-plein-air-secteur-pied-des-monts" | |
| 4506 | + ], | |
| 4507 | + "language": "fr", | |
| 4508 | + "ecommerce": true, | |
| 4509 | + "verification_date": "2026-08-12", | |
| 4510 | + "status": "probable", | |
| 4511 | + "enabled": false | |
| 4512 | + }, | |
| 4513 | + { | |
| 4514 | + "id": "astroblemecharlevoix.org", | |
| 4515 | + "name": "Observatoire de l'Astroblème", | |
| 4516 | + "url": "https://astroblemecharlevoix.org", | |
| 4517 | + "platform": "wordpress", | |
| 4518 | + "catalog_endpoint": "", | |
| 4519 | + "city": "", | |
| 4520 | + "region": "Capitale-Nationale", | |
| 4521 | + "postal_prefix": "G5A", | |
| 4522 | + "phone": "418-665-7645", | |
| 4523 | + "origin_class": "A", | |
| 4524 | + "origin_confidence": 0.7, | |
| 4525 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 4526 | + "categories": [ | |
| 4527 | + "epicerie" | |
| 4528 | + ], | |
| 4529 | + "socials": [ | |
| 4530 | + "https://www.youtube.com/watch?v=9ZVvPW1yBPo", | |
| 4531 | + "https://www.facebook.com/observatoirescharlevoix/", | |
| 4532 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 4533 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 4534 | + ], | |
| 4535 | + "discovery_sources": [ | |
| 4536 | + "charlevoix" | |
| 4537 | + ], | |
| 4538 | + "discovery_source_urls": [ | |
| 4539 | + "https://www.tourisme-charlevoix.com/fr/entreprises/observatoire-de-l-astrobleme" | |
| 4540 | + ], | |
| 4541 | + "language": "fr", | |
| 4542 | + "ecommerce": false, | |
| 4543 | + "verification_date": "2026-08-12", | |
| 4544 | + "status": "verified", | |
| 4545 | + "enabled": false | |
| 4546 | + }, | |
| 4547 | + { | |
| 4548 | + "id": "asymmetricbydesign.com", | |
| 4549 | + "name": "Asymmetric by Design", | |
| 4550 | + "url": "https://asymmetricbydesign.com", | |
| 4551 | + "platform": "woocommerce", | |
| 4552 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4553 | + "city": "", | |
| 4554 | + "region": "Montréal", | |
| 4555 | + "postal_prefix": "H4A", | |
| 4556 | + "phone": "514 847-9799", | |
| 4557 | + "origin_class": "A", | |
| 4558 | + "origin_confidence": 0.6, | |
| 4559 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4560 | + "categories": [], | |
| 4561 | + "socials": [ | |
| 4562 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4563 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4564 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4565 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4566 | + ], | |
| 4567 | + "discovery_sources": [ | |
| 4568 | + "lesproduitsduquebec_ent" | |
| 4569 | + ], | |
| 4570 | + "discovery_source_urls": [ | |
| 4571 | + "https://lesproduitsduquebec.com/entreprises-adherentes/asymmetric-by-design" | |
| 4572 | + ], | |
| 4573 | + "language": "en", | |
| 4574 | + "ecommerce": true, | |
| 4575 | + "verification_date": "2026-08-12", | |
| 4576 | + "status": "probable", | |
| 4577 | + "enabled": true | |
| 4578 | + }, | |
| 4579 | + { | |
| 4580 | + "id": "atelier-b.ca", | |
| 4581 | + "name": "Atelier b - CMAQ", | |
| 4582 | + "url": "https://atelier-b.ca", | |
| 4583 | + "platform": "shopify", | |
| 4584 | + "catalog_endpoint": "/products.json", | |
| 4585 | + "city": "", | |
| 4586 | + "region": "Montréal", | |
| 4587 | + "postal_prefix": "H2T", | |
| 4588 | + "phone": "514 847-9799", | |
| 4589 | + "origin_class": "A", | |
| 4590 | + "origin_confidence": 0.6, | |
| 4591 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4592 | + "categories": [ | |
| 4593 | + "art" | |
| 4594 | + ], | |
| 4595 | + "socials": [ | |
| 4596 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4597 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4598 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4599 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4600 | + ], | |
| 4601 | + "discovery_sources": [ | |
| 4602 | + "lesproduitsduquebec_ent", | |
| 4603 | + "search_sweep_cmaq", | |
| 4604 | + "search_sweep_urban" | |
| 4605 | + ], | |
| 4606 | + "discovery_source_urls": [ | |
| 4607 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-b-cmaq", | |
| 4608 | + "atelier b Anne-Marie Laflamme Catherine Métivier couturier québec", | |
| 4609 | + "marque montréalaise vêtements boutique en ligne fabriqué à Montréal" | |
| 4610 | + ], | |
| 4611 | + "language": "en", | |
| 4612 | + "ecommerce": true, | |
| 4613 | + "verification_date": "2026-08-12", | |
| 4614 | + "status": "probable", | |
| 4615 | + "enabled": true | |
| 4616 | + }, | |
| 4617 | + { | |
| 4618 | + "id": "atelier-du-patrimoine.ca", | |
| 4619 | + "name": "ExpertiseAMG inc. - Restauration du Patrimoine", | |
| 4620 | + "url": "https://www.atelier-du-patrimoine.ca", | |
| 4621 | + "platform": "", | |
| 4622 | + "catalog_endpoint": "", | |
| 4623 | + "city": "", | |
| 4624 | + "region": "", | |
| 4625 | + "postal_prefix": null, | |
| 4626 | + "phone": "8199999928", | |
| 4627 | + "origin_class": "A", | |
| 4628 | + "origin_confidence": 0.85, | |
| 4629 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 4630 | + "categories": [ | |
| 4631 | + "art" | |
| 4632 | + ], | |
| 4633 | + "socials": [], | |
| 4634 | + "discovery_sources": [ | |
| 4635 | + "search_sweep_cmaq" | |
| 4636 | + ], | |
| 4637 | + "discovery_source_urls": [ | |
| 4638 | + "ExpertiseAMG Ann Marlène Gagnon doreur peintre décorateur québec" | |
| 4639 | + ], | |
| 4640 | + "language": "fr", | |
| 4641 | + "ecommerce": false, | |
| 4642 | + "verification_date": "2026-08-12", | |
| 4643 | + "status": "verified", | |
| 4644 | + "enabled": false | |
| 4645 | + }, | |
| 4646 | + { | |
| 4647 | + "id": "atelier-letabli.ca", | |
| 4648 | + "name": "Atelier l'Établi", | |
| 4649 | + "url": "https://atelier-letabli.ca", | |
| 4650 | + "platform": "wordpress", | |
| 4651 | + "catalog_endpoint": "", | |
| 4652 | + "city": "", | |
| 4653 | + "region": "", | |
| 4654 | + "postal_prefix": "H2G", | |
| 4655 | + "phone": null, | |
| 4656 | + "origin_class": "A", | |
| 4657 | + "origin_confidence": 0.85, | |
| 4658 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 4659 | + "categories": [ | |
| 4660 | + "art" | |
| 4661 | + ], | |
| 4662 | + "socials": [ | |
| 4663 | + "facebook.com" | |
| 4664 | + ], | |
| 4665 | + "discovery_sources": [ | |
| 4666 | + "search_sweep_cmaq" | |
| 4667 | + ], | |
| 4668 | + "discovery_source_urls": [ | |
| 4669 | + "Atelier l'Établi Jean-François Lachance menuisier d'art québec" | |
| 4670 | + ], | |
| 4671 | + "language": "fr", | |
| 4672 | + "ecommerce": false, | |
| 4673 | + "verification_date": "2026-08-12", | |
| 4674 | + "status": "verified", | |
| 4675 | + "enabled": false | |
| 4676 | + }, | |
| 4677 | + { | |
| 4678 | + "id": "atelier140.ca", | |
| 4679 | + "name": "Atelier 140 Ébénisterie", | |
| 4680 | + "url": "https://atelier140.ca", | |
| 4681 | + "platform": "wordpress", | |
| 4682 | + "catalog_endpoint": "", | |
| 4683 | + "city": "", | |
| 4684 | + "region": "Centre-du-Québec", | |
| 4685 | + "postal_prefix": "G6T", | |
| 4686 | + "phone": "819 552-2011", | |
| 4687 | + "origin_class": "A", | |
| 4688 | + "origin_confidence": 0.55, | |
| 4689 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 4690 | + "categories": [], | |
| 4691 | + "socials": [ | |
| 4692 | + "facebook.com" | |
| 4693 | + ], | |
| 4694 | + "discovery_sources": [ | |
| 4695 | + "search_sweep_nonfood" | |
| 4696 | + ], | |
| 4697 | + "discovery_source_urls": [ | |
| 4698 | + "ébénisterie meubles québécois boutique en ligne" | |
| 4699 | + ], | |
| 4700 | + "language": "fr", | |
| 4701 | + "ecommerce": false, | |
| 4702 | + "verification_date": "2026-08-12", | |
| 4703 | + "status": "probable", | |
| 4704 | + "enabled": false | |
| 4705 | + }, | |
| 4706 | + { | |
| 4707 | + "id": "atelier1ermai.com", | |
| 4708 | + "name": "1ER MAI - CMAQ", | |
| 4709 | + "url": "https://www.atelier1ermai.com", | |
| 4710 | + "platform": "wix", | |
| 4711 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 4712 | + "city": "", | |
| 4713 | + "region": "Montréal", | |
| 4714 | + "postal_prefix": "H2T", | |
| 4715 | + "phone": "514 847-9799", | |
| 4716 | + "origin_class": "A", | |
| 4717 | + "origin_confidence": 0.85, | |
| 4718 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 4719 | + "categories": [], | |
| 4720 | + "socials": [ | |
| 4721 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4722 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4723 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4724 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4725 | + ], | |
| 4726 | + "discovery_sources": [ | |
| 4727 | + "lesproduitsduquebec_ent" | |
| 4728 | + ], | |
| 4729 | + "discovery_source_urls": [ | |
| 4730 | + "https://lesproduitsduquebec.com/entreprises-adherentes/1er-mai-cmaq" | |
| 4731 | + ], | |
| 4732 | + "language": null, | |
| 4733 | + "ecommerce": true, | |
| 4734 | + "verification_date": "2026-08-12", | |
| 4735 | + "status": "verified", | |
| 4736 | + "enabled": true | |
| 4737 | + }, | |
| 4738 | + { | |
| 4739 | + "id": "atelierbalsam.com", | |
| 4740 | + "name": "Atelier Balsam", | |
| 4741 | + "url": "https://atelierbalsam.com", | |
| 4742 | + "platform": "shopify", | |
| 4743 | + "catalog_endpoint": "/products.json", | |
| 4744 | + "city": "", | |
| 4745 | + "region": "Outaouais", | |
| 4746 | + "postal_prefix": "J8X", | |
| 4747 | + "phone": "819-777-7133", | |
| 4748 | + "origin_class": "A", | |
| 4749 | + "origin_confidence": 0.5, | |
| 4750 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC)", | |
| 4751 | + "categories": [], | |
| 4752 | + "socials": [ | |
| 4753 | + "facebook.com", | |
| 4754 | + "instagram.com" | |
| 4755 | + ], | |
| 4756 | + "discovery_sources": [ | |
| 4757 | + "search_sweep_nonfood" | |
| 4758 | + ], | |
| 4759 | + "discovery_source_urls": [ | |
| 4760 | + "chandelles bougies artisanales Bas-Saint-Laurent boutique en ligne" | |
| 4761 | + ], | |
| 4762 | + "language": null, | |
| 4763 | + "ecommerce": true, | |
| 4764 | + "verification_date": "2026-08-12", | |
| 4765 | + "status": "probable", | |
| 4766 | + "enabled": true | |
| 4767 | + }, | |
| 4768 | + { | |
| 4769 | + "id": "atelierboisart.com", | |
| 4770 | + "name": "Atelier Bois'Art Ébénisterie", | |
| 4771 | + "url": "https://atelierboisart.com", | |
| 4772 | + "platform": "shopify", | |
| 4773 | + "catalog_endpoint": "/products.json", | |
| 4774 | + "city": "", | |
| 4775 | + "region": "", | |
| 4776 | + "postal_prefix": null, | |
| 4777 | + "phone": "418-297-7437", | |
| 4778 | + "origin_class": "A", | |
| 4779 | + "origin_confidence": 0.55, | |
| 4780 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 4781 | + "categories": [], | |
| 4782 | + "socials": [ | |
| 4783 | + "facebook.com" | |
| 4784 | + ], | |
| 4785 | + "discovery_sources": [ | |
| 4786 | + "search_sweep_nonfood" | |
| 4787 | + ], | |
| 4788 | + "discovery_source_urls": [ | |
| 4789 | + "ébénisterie meubles québécois boutique en ligne" | |
| 4790 | + ], | |
| 4791 | + "language": null, | |
| 4792 | + "ecommerce": true, | |
| 4793 | + "verification_date": "2026-08-12", | |
| 4794 | + "status": "probable", | |
| 4795 | + "enabled": true | |
| 4796 | + }, | |
| 4797 | + { | |
| 4798 | + "id": "atelierboisetcie.ca", | |
| 4799 | + "name": "Atelier Bois & Cie", | |
| 4800 | + "url": "https://atelierboisetcie.ca", | |
| 4801 | + "platform": "woocommerce", | |
| 4802 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 4803 | + "city": "", | |
| 4804 | + "region": "Centre-du-Québec", | |
| 4805 | + "postal_prefix": "J1Z", | |
| 4806 | + "phone": "514 847-9799", | |
| 4807 | + "origin_class": "A", | |
| 4808 | + "origin_confidence": 0.9, | |
| 4809 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 4810 | + "categories": [], | |
| 4811 | + "socials": [ | |
| 4812 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4813 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4814 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4815 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4816 | + ], | |
| 4817 | + "discovery_sources": [ | |
| 4818 | + "lesproduitsduquebec_ent" | |
| 4819 | + ], | |
| 4820 | + "discovery_source_urls": [ | |
| 4821 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-bois-cie" | |
| 4822 | + ], | |
| 4823 | + "language": "fr", | |
| 4824 | + "ecommerce": true, | |
| 4825 | + "verification_date": "2026-08-12", | |
| 4826 | + "status": "verified", | |
| 4827 | + "enabled": true | |
| 4828 | + }, | |
| 4829 | + { | |
| 4830 | + "id": "ateliercanopee.ca", | |
| 4831 | + "name": "Atelier Canopée - CMAQ", | |
| 4832 | + "url": "https://ateliercanopee.ca", | |
| 4833 | + "platform": "wordpress", | |
| 4834 | + "catalog_endpoint": "", | |
| 4835 | + "city": "", | |
| 4836 | + "region": "", | |
| 4837 | + "postal_prefix": "G5M", | |
| 4838 | + "phone": "514 847-9799", | |
| 4839 | + "origin_class": "A", | |
| 4840 | + "origin_confidence": 0.6, | |
| 4841 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4842 | + "categories": [], | |
| 4843 | + "socials": [ | |
| 4844 | + "https://www.facebook.com/lesproduitsduqc", | |
| 4845 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 4846 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 4847 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 4848 | + ], | |
| 4849 | + "discovery_sources": [ | |
| 4850 | + "lesproduitsduquebec_ent" | |
| 4851 | + ], | |
| 4852 | + "discovery_source_urls": [ | |
| 4853 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-canopee-cmaq" | |
| 4854 | + ], | |
| 4855 | + "language": "fr", | |
| 4856 | + "ecommerce": false, | |
| 4857 | + "verification_date": "2026-08-12", | |
| 4858 | + "status": "probable", | |
| 4859 | + "enabled": false | |
| 4860 | + }, | |
| 4861 | + { | |
| 4862 | + "id": "ateliercharlotte.com", | |
| 4863 | + "name": "Charlotte! Atelier/Boutique et sa Petite Place d'art", | |
| 4864 | + "url": "https://www.ateliercharlotte.com", | |
| 4865 | + "platform": "wix", | |
| 4866 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 4867 | + "city": "", | |
| 4868 | + "region": "Capitale-Nationale", | |
| 4869 | + "postal_prefix": "G0A", | |
| 4870 | + "phone": "418 438-2321", | |
| 4871 | + "origin_class": "A", | |
| 4872 | + "origin_confidence": 0.7, | |
| 4873 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 4874 | + "categories": [ | |
| 4875 | + "epicerie" | |
| 4876 | + ], | |
| 4877 | + "socials": [ | |
| 4878 | + "https://www.facebook.com/Charlotte-Atelier-Boutique-119599078065517/?ref=hl", | |
| 4879 | + "https://www.instagram.com/atelier_charlotte/", | |
| 4880 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 4881 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 4882 | + ], | |
| 4883 | + "discovery_sources": [ | |
| 4884 | + "charlevoix" | |
| 4885 | + ], | |
| 4886 | + "discovery_source_urls": [ | |
| 4887 | + "https://www.tourisme-charlevoix.com/fr/entreprises/charlotte-atelier-boutique-et-sa-petite-place-dart" | |
| 4888 | + ], | |
| 4889 | + "language": "fr", | |
| 4890 | + "ecommerce": true, | |
| 4891 | + "verification_date": "2026-08-12", | |
| 4892 | + "status": "verified", | |
| 4893 | + "enabled": true | |
| 4894 | + }, | |
| 4895 | + { | |
| 4896 | + "id": "ateliercub.com", | |
| 4897 | + "name": "Atelier C.U.B. (Richard Pontais)", | |
| 4898 | + "url": "https://www.ateliercub.com", | |
| 4899 | + "platform": "wordpress", | |
| 4900 | + "catalog_endpoint": "", | |
| 4901 | + "city": "", | |
| 4902 | + "region": "", | |
| 4903 | + "postal_prefix": null, | |
| 4904 | + "phone": null, | |
| 4905 | + "origin_class": "A", | |
| 4906 | + "origin_confidence": 0.6, | |
| 4907 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4908 | + "categories": [ | |
| 4909 | + "art" | |
| 4910 | + ], | |
| 4911 | + "socials": [ | |
| 4912 | + "facebook.com", | |
| 4913 | + "instagram.com" | |
| 4914 | + ], | |
| 4915 | + "discovery_sources": [ | |
| 4916 | + "search_sweep_cmaq" | |
| 4917 | + ], | |
| 4918 | + "discovery_source_urls": [ | |
| 4919 | + "Atelier C.U.B Richard Pontais ébéniste québec", | |
| 4920 | + "Atelier C.U.B Sophie Gailliot gainier québec" | |
| 4921 | + ], | |
| 4922 | + "language": "fr", | |
| 4923 | + "ecommerce": false, | |
| 4924 | + "verification_date": "2026-08-12", | |
| 4925 | + "status": "probable", | |
| 4926 | + "enabled": false | |
| 4927 | + }, | |
| 4928 | + { | |
| 4929 | + "id": "atelierdalfred.com", | |
| 4930 | + "name": "Atelier d'Alfred", | |
| 4931 | + "url": "https://www.atelierdalfred.com", | |
| 4932 | + "platform": "squarespace", | |
| 4933 | + "catalog_endpoint": "", | |
| 4934 | + "city": "", | |
| 4935 | + "region": "Montréal", | |
| 4936 | + "postal_prefix": "H2X", | |
| 4937 | + "phone": "438-797-2472", | |
| 4938 | + "origin_class": "A", | |
| 4939 | + "origin_confidence": 0.5, | |
| 4940 | + "origin_evidence": "Fabricant/artisan urbain trouvé par balayage dense (Montréal/Québec/Laval)", | |
| 4941 | + "categories": [], | |
| 4942 | + "socials": [ | |
| 4943 | + "facebook.com", | |
| 4944 | + "instagram.com" | |
| 4945 | + ], | |
| 4946 | + "discovery_sources": [ | |
| 4947 | + "search_sweep_urban" | |
| 4948 | + ], | |
| 4949 | + "discovery_source_urls": [ | |
| 4950 | + "produits barbier fabriqués Montréal boutique en ligne barbe" | |
| 4951 | + ], | |
| 4952 | + "language": "fr", | |
| 4953 | + "ecommerce": true, | |
| 4954 | + "verification_date": "2026-08-12", | |
| 4955 | + "status": "probable", | |
| 4956 | + "enabled": false | |
| 4957 | + }, | |
| 4958 | + { | |
| 4959 | + "id": "merdeka62tas.com", | |
| 4960 | + "name": "Emballages à base de cire d'abeille : Kit de trois - Acheter québécois", | |
| 4961 | + "url": "https://merdeka62tas.com", | |
| 4962 | + "platform": "", | |
| 4963 | + "catalog_endpoint": "", | |
| 4964 | + "city": "", | |
| 4965 | + "region": "", | |
| 4966 | + "postal_prefix": null, | |
| 4967 | + "phone": "(450) 403-0603", | |
| 4968 | + "origin_class": "A", | |
| 4969 | + "origin_confidence": 0.3, | |
| 4970 | + "origin_evidence": "Répertorié comme produit québécois sur acheterquebecois.ca ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 4971 | + "categories": [], | |
| 4972 | + "socials": [ | |
| 4973 | + "https://www.pinterest.ca/acheterquebecois/", | |
| 4974 | + "https://www.youtube.com/channel/UCQb7eCmrM_8Lpw8tZ5ZHRSA", | |
| 4975 | + "https://www.linkedin.com/company/acheterquebecois/", | |
| 4976 | + "https://www.facebook.com/acheterquebecois/" | |
| 4977 | + ], | |
| 4978 | + "discovery_sources": [ | |
| 4979 | + "acheterquebecois" | |
| 4980 | + ], | |
| 4981 | + "discovery_source_urls": [ | |
| 4982 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/accessoires-de-cuisine/emballages-a-base-de-cire-dabeille-kit-de-trois/", | |
| 4983 | + "https://acheterquebecois.ca/produits-quebecois-a-vendre/accessoires-de-cuisine/emballages-alimentaires-a-base-de-cire-dabeille/" | |
| 4984 | + ], | |
| 4985 | + "language": "en", | |
| 4986 | + "ecommerce": false, | |
| 4987 | + "verification_date": "2026-08-12", | |
| 4988 | + "status": "probable", | |
| 4989 | + "enabled": false | |
| 4990 | + }, | |
| 4991 | + { | |
| 4992 | + "id": "atelierdelouis.ca", | |
| 4993 | + "name": "Atelier de Louis", | |
| 4994 | + "url": "https://www.atelierdelouis.ca", | |
| 4995 | + "platform": "shopify", | |
| 4996 | + "catalog_endpoint": "/products.json", | |
| 4997 | + "city": "", | |
| 4998 | + "region": "Laurentides", | |
| 4999 | + "postal_prefix": "J5K", | |
| 5000 | + "phone": "514 847-9799", | |
| 5001 | + "origin_class": "A", | |
| 5002 | + "origin_confidence": 0.9, | |
| 5003 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 5004 | + "categories": [], | |
| 5005 | + "socials": [ | |
| 5006 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5007 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5008 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5009 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5010 | + ], | |
| 5011 | + "discovery_sources": [ | |
| 5012 | + "lesproduitsduquebec_ent" | |
| 5013 | + ], | |
| 5014 | + "discovery_source_urls": [ | |
| 5015 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-de-louis" | |
| 5016 | + ], | |
| 5017 | + "language": "en", | |
| 5018 | + "ecommerce": true, | |
| 5019 | + "verification_date": "2026-08-12", | |
| 5020 | + "status": "verified", | |
| 5021 | + "enabled": true | |
| 5022 | + }, | |
| 5023 | + { | |
| 5024 | + "id": "atelierdubijou.ca", | |
| 5025 | + "name": "Atelier du Bijou", | |
| 5026 | + "url": "https://www.atelierdubijou.ca", | |
| 5027 | + "platform": "woocommerce", | |
| 5028 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 5029 | + "city": "", | |
| 5030 | + "region": "Estrie", | |
| 5031 | + "postal_prefix": "G6B", | |
| 5032 | + "phone": "514 847-9799", | |
| 5033 | + "origin_class": "A", | |
| 5034 | + "origin_confidence": 0.9, | |
| 5035 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise", | |
| 5036 | + "categories": [], | |
| 5037 | + "socials": [ | |
| 5038 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5039 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5040 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5041 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5042 | + ], | |
| 5043 | + "discovery_sources": [ | |
| 5044 | + "lesproduitsduquebec_ent" | |
| 5045 | + ], | |
| 5046 | + "discovery_source_urls": [ | |
| 5047 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-du-bijou" | |
| 5048 | + ], | |
| 5049 | + "language": "en", | |
| 5050 | + "ecommerce": true, | |
| 5051 | + "verification_date": "2026-08-12", | |
| 5052 | + "status": "verified", | |
| 5053 | + "enabled": true | |
| 5054 | + }, | |
| 5055 | + { | |
| 5056 | + "id": "atelierdumoucheur.com", | |
| 5057 | + "name": "Atelier du Moucheur", | |
| 5058 | + "url": "https://www.atelierdumoucheur.com", | |
| 5059 | + "platform": "", | |
| 5060 | + "catalog_endpoint": "", | |
| 5061 | + "city": "", | |
| 5062 | + "region": "Bas-Saint-Laurent", | |
| 5063 | + "postal_prefix": "G5L", | |
| 5064 | + "phone": "4187226993", | |
| 5065 | + "origin_class": "A", | |
| 5066 | + "origin_confidence": 0.5, | |
| 5067 | + "origin_evidence": "Manufacturier québécois trouvé par balayage manufacturier ciblé", | |
| 5068 | + "categories": [ | |
| 5069 | + "industriel" | |
| 5070 | + ], | |
| 5071 | + "socials": [ | |
| 5072 | + "facebook.com" | |
| 5073 | + ], | |
| 5074 | + "discovery_sources": [ | |
| 5075 | + "search_sweep_manuf" | |
| 5076 | + ], | |
| 5077 | + "discovery_source_urls": [ | |
| 5078 | + "fabricant québécois mouches pêche articles de pêche boutique en ligne" | |
| 5079 | + ], | |
| 5080 | + "language": "en", | |
| 5081 | + "ecommerce": true, | |
| 5082 | + "verification_date": "2026-08-12", | |
| 5083 | + "status": "probable", | |
| 5084 | + "enabled": false | |
| 5085 | + }, | |
| 5086 | + { | |
| 5087 | + "id": "atelierdunordet.ca", | |
| 5088 | + "name": "Atelier du Nordet - CMAQ", | |
| 5089 | + "url": "https://www.atelierdunordet.ca", | |
| 5090 | + "platform": "squarespace", | |
| 5091 | + "catalog_endpoint": "", | |
| 5092 | + "city": "", | |
| 5093 | + "region": "Chaudière-Appalaches", | |
| 5094 | + "postal_prefix": "G0S", | |
| 5095 | + "phone": "514 847-9799", | |
| 5096 | + "origin_class": "A", | |
| 5097 | + "origin_confidence": 0.85, | |
| 5098 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 5099 | + "categories": [], | |
| 5100 | + "socials": [ | |
| 5101 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5102 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5103 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5104 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5105 | + ], | |
| 5106 | + "discovery_sources": [ | |
| 5107 | + "lesproduitsduquebec_ent" | |
| 5108 | + ], | |
| 5109 | + "discovery_source_urls": [ | |
| 5110 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-du-nordet-cmaq" | |
| 5111 | + ], | |
| 5112 | + "language": "fr", | |
| 5113 | + "ecommerce": true, | |
| 5114 | + "verification_date": "2026-08-12", | |
| 5115 | + "status": "verified", | |
| 5116 | + "enabled": false | |
| 5117 | + }, | |
| 5118 | + { | |
| 5119 | + "id": "atelieremige.com", | |
| 5120 | + "name": "Atelier Émigé", | |
| 5121 | + "url": "https://atelieremige.com", | |
| 5122 | + "platform": "shopify", | |
| 5123 | + "catalog_endpoint": "/products.json", | |
| 5124 | + "city": "", | |
| 5125 | + "region": "", | |
| 5126 | + "postal_prefix": null, | |
| 5127 | + "phone": null, | |
| 5128 | + "origin_class": "A", | |
| 5129 | + "origin_confidence": 0.6, | |
| 5130 | + "origin_evidence": "Bijouterie/joaillerie québécoise trouvée par balayage dédié bijoux ; le site affiche une mention de fabrication québécoise", | |
| 5131 | + "categories": [ | |
| 5132 | + "bijoux" | |
| 5133 | + ], | |
| 5134 | + "socials": [], | |
| 5135 | + "discovery_sources": [ | |
| 5136 | + "search_sweep_bijoux" | |
| 5137 | + ], | |
| 5138 | + "discovery_source_urls": [ | |
| 5139 | + "alliances joncs de mariage créateur québécois boutique en ligne" | |
| 5140 | + ], | |
| 5141 | + "language": "en", | |
| 5142 | + "ecommerce": true, | |
| 5143 | + "verification_date": "2026-08-12", | |
| 5144 | + "status": "verified", | |
| 5145 | + "enabled": true | |
| 5146 | + }, | |
| 5147 | + { | |
| 5148 | + "id": "atelierformecreation.com", | |
| 5149 | + "name": "For Mé Création - CMAQ", | |
| 5150 | + "url": "https://www.atelierformecreation.com", | |
| 5151 | + "platform": "wix", | |
| 5152 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 5153 | + "city": "", | |
| 5154 | + "region": "Laurentides", | |
| 5155 | + "postal_prefix": "J8H", | |
| 5156 | + "phone": "514 847-9799", | |
| 5157 | + "origin_class": "A", | |
| 5158 | + "origin_confidence": 0.85, | |
| 5159 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 5160 | + "categories": [ | |
| 5161 | + "art" | |
| 5162 | + ], | |
| 5163 | + "socials": [ | |
| 5164 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5165 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5166 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5167 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5168 | + ], | |
| 5169 | + "discovery_sources": [ | |
| 5170 | + "lesproduitsduquebec_ent", | |
| 5171 | + "search_sweep_cmaq" | |
| 5172 | + ], | |
| 5173 | + "discovery_source_urls": [ | |
| 5174 | + "https://lesproduitsduquebec.com/entreprises-adherentes/for-me-creation-cmaq", | |
| 5175 | + "For Mé Création Mélanie Forest céramiste québec" | |
| 5176 | + ], | |
| 5177 | + "language": "fr", | |
| 5178 | + "ecommerce": true, | |
| 5179 | + "verification_date": "2026-08-12", | |
| 5180 | + "status": "verified", | |
| 5181 | + "enabled": true | |
| 5182 | + }, | |
| 5183 | + { | |
| 5184 | + "id": "ateliergaston.ca", | |
| 5185 | + "name": "Atelier Gaston - CMAQ", | |
| 5186 | + "url": "https://ateliergaston.ca", | |
| 5187 | + "platform": "", | |
| 5188 | + "catalog_endpoint": "", | |
| 5189 | + "city": "", | |
| 5190 | + "region": "Montréal", | |
| 5191 | + "postal_prefix": "H1Y", | |
| 5192 | + "phone": "514 847-9799", | |
| 5193 | + "origin_class": "A", | |
| 5194 | + "origin_confidence": 0.85, | |
| 5195 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 5196 | + "categories": [], | |
| 5197 | + "socials": [ | |
| 5198 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5199 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5200 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5201 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5202 | + ], | |
| 5203 | + "discovery_sources": [ | |
| 5204 | + "lesproduitsduquebec_ent" | |
| 5205 | + ], | |
| 5206 | + "discovery_source_urls": [ | |
| 5207 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-gaston-cmaq" | |
| 5208 | + ], | |
| 5209 | + "language": "fr", | |
| 5210 | + "ecommerce": false, | |
| 5211 | + "verification_date": "2026-08-12", | |
| 5212 | + "status": "verified", | |
| 5213 | + "enabled": false | |
| 5214 | + }, | |
| 5215 | + { | |
| 5216 | + "id": "atelierhotelmotel.com", | |
| 5217 | + "name": "Atelier HOTELMOTEL", | |
| 5218 | + "url": "https://www.atelierhotelmotel.com", | |
| 5219 | + "platform": "squarespace", | |
| 5220 | + "catalog_endpoint": "", | |
| 5221 | + "city": "", | |
| 5222 | + "region": "", | |
| 5223 | + "postal_prefix": "H2N", | |
| 5224 | + "phone": "5146807488", | |
| 5225 | + "origin_class": "A", | |
| 5226 | + "origin_confidence": 0.85, | |
| 5227 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 5228 | + "categories": [ | |
| 5229 | + "art" | |
| 5230 | + ], | |
| 5231 | + "socials": [ | |
| 5232 | + "facebook.com", | |
| 5233 | + "instagram.com" | |
| 5234 | + ], | |
| 5235 | + "discovery_sources": [ | |
| 5236 | + "search_sweep_cmaq" | |
| 5237 | + ], | |
| 5238 | + "discovery_source_urls": [ | |
| 5239 | + "Atelier HOTELMOTEL Corinne Bourget maroquinier québec" | |
| 5240 | + ], | |
| 5241 | + "language": "en", | |
| 5242 | + "ecommerce": true, | |
| 5243 | + "verification_date": "2026-08-12", | |
| 5244 | + "status": "verified", | |
| 5245 | + "enabled": false | |
| 5246 | + }, | |
| 5247 | + { | |
| 5248 | + "id": "atelierlaf.com", | |
| 5249 | + "name": "Atelier L.A.F.", | |
| 5250 | + "url": "https://www.atelierlaf.com", | |
| 5251 | + "platform": "shopify", | |
| 5252 | + "catalog_endpoint": "/products.json", | |
| 5253 | + "city": "", | |
| 5254 | + "region": "", | |
| 5255 | + "postal_prefix": null, | |
| 5256 | + "phone": null, | |
| 5257 | + "origin_class": "A", | |
| 5258 | + "origin_confidence": 0.6, | |
| 5259 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 5260 | + "categories": [ | |
| 5261 | + "art" | |
| 5262 | + ], | |
| 5263 | + "socials": [ | |
| 5264 | + "facebook.com", | |
| 5265 | + "instagram.com" | |
| 5266 | + ], | |
| 5267 | + "discovery_sources": [ | |
| 5268 | + "search_sweep_cmaq" | |
| 5269 | + ], | |
| 5270 | + "discovery_source_urls": [ | |
| 5271 | + "Atelier L.A.F Andrew Floyd joaillier québec" | |
| 5272 | + ], | |
| 5273 | + "language": "en", | |
| 5274 | + "ecommerce": true, | |
| 5275 | + "verification_date": "2026-08-12", | |
| 5276 | + "status": "probable", | |
| 5277 | + "enabled": true | |
| 5278 | + }, | |
| 5279 | + { | |
| 5280 | + "id": "atelierluclaramee.com", | |
| 5281 | + "name": "Atelier Luc Laramée - CMAQ", | |
| 5282 | + "url": "https://www.atelierluclaramee.com", | |
| 5283 | + "platform": "wix", | |
| 5284 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 5285 | + "city": "", | |
| 5286 | + "region": "Mauricie", | |
| 5287 | + "postal_prefix": "G0X", | |
| 5288 | + "phone": "514 847-9799", | |
| 5289 | + "origin_class": "A", | |
| 5290 | + "origin_confidence": 0.85, | |
| 5291 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 5292 | + "categories": [], | |
| 5293 | + "socials": [ | |
| 5294 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5295 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5296 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5297 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5298 | + ], | |
| 5299 | + "discovery_sources": [ | |
| 5300 | + "lesproduitsduquebec_ent" | |
| 5301 | + ], | |
| 5302 | + "discovery_source_urls": [ | |
| 5303 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-luc-laramee-cmaq" | |
| 5304 | + ], | |
| 5305 | + "language": "fr", | |
| 5306 | + "ecommerce": true, | |
| 5307 | + "verification_date": "2026-08-12", | |
| 5308 | + "status": "verified", | |
| 5309 | + "enabled": true | |
| 5310 | + }, | |
| 5311 | + { | |
| 5312 | + "id": "ateliermajuli.com", | |
| 5313 | + "name": "Atelier Majuli", | |
| 5314 | + "url": "https://ateliermajuli.com", | |
| 5315 | + "platform": "shopify", | |
| 5316 | + "catalog_endpoint": "/products.json", | |
| 5317 | + "city": "", | |
| 5318 | + "region": "", | |
| 5319 | + "postal_prefix": null, | |
| 5320 | + "phone": null, | |
| 5321 | + "origin_class": "A", | |
| 5322 | + "origin_confidence": 0.45, | |
| 5323 | + "origin_evidence": "Marque québécoise vendue par Signé Local ou Boutique Métiers d'art — site officiel résolu ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 5324 | + "categories": [], | |
| 5325 | + "socials": [ | |
| 5326 | + "facebook.com", | |
| 5327 | + "instagram.com" | |
| 5328 | + ], | |
| 5329 | + "discovery_sources": [ | |
| 5330 | + "search_sweep_vendors" | |
| 5331 | + ], | |
| 5332 | + "discovery_source_urls": [ | |
| 5333 | + "\"Atelier Majuli\" bague argent sterling Québec boutique" | |
| 5334 | + ], | |
| 5335 | + "language": "en", | |
| 5336 | + "ecommerce": true, | |
| 5337 | + "verification_date": "2026-08-12", | |
| 5338 | + "status": "probable", | |
| 5339 | + "enabled": true | |
| 5340 | + }, | |
| 5341 | + { | |
| 5342 | + "id": "mielsdanicet.com", | |
| 5343 | + "name": "Mélia", | |
| 5344 | + "url": "https://mielsdanicet.com", | |
| 5345 | + "platform": "woocommerce", | |
| 5346 | + "catalog_endpoint": "/wp-json/wc/store/v1/products", | |
| 5347 | + "city": "", | |
| 5348 | + "region": "Laurentides", | |
| 5349 | + "postal_prefix": "J0W", | |
| 5350 | + "phone": "819-587-4825", | |
| 5351 | + "origin_class": "A", | |
| 5352 | + "origin_confidence": 0.75, | |
| 5353 | + "origin_evidence": "Marque québécoise vendue par Signé Local ou Boutique Métiers d'art — site officiel résolu ; le site affiche une mention de fabrication québécoise", | |
| 5354 | + "categories": [], | |
| 5355 | + "socials": [], | |
| 5356 | + "discovery_sources": [ | |
| 5357 | + "search_sweep_vendors", | |
| 5358 | + "search_sweep_food" | |
| 5359 | + ], | |
| 5360 | + "discovery_source_urls": [ | |
| 5361 | + "\"Mélia\" vaporisateur moustique Québec boutique" | |
| 5362 | + ], | |
| 5363 | + "language": "fr", | |
| 5364 | + "ecommerce": true, | |
| 5365 | + "verification_date": "2026-08-12", | |
| 5366 | + "status": "verified", | |
| 5367 | + "enabled": true | |
| 5368 | + }, | |
| 5369 | + { | |
| 5370 | + "id": "ateliermoniqueratelle.com", | |
| 5371 | + "name": "Atelier Monique Ratelle - CMAQ", | |
| 5372 | + "url": "https://www.ateliermoniqueratelle.com", | |
| 5373 | + "platform": "wix", | |
| 5374 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 5375 | + "city": "", | |
| 5376 | + "region": "Lanaudière", | |
| 5377 | + "postal_prefix": "J0K", | |
| 5378 | + "phone": "514 847-9799", | |
| 5379 | + "origin_class": "A", | |
| 5380 | + "origin_confidence": 0.85, | |
| 5381 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 5382 | + "categories": [], | |
| 5383 | + "socials": [ | |
| 5384 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5385 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5386 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5387 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5388 | + ], | |
| 5389 | + "discovery_sources": [ | |
| 5390 | + "lesproduitsduquebec_ent" | |
| 5391 | + ], | |
| 5392 | + "discovery_source_urls": [ | |
| 5393 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-monique-ratelle-cmaq" | |
| 5394 | + ], | |
| 5395 | + "language": "en", | |
| 5396 | + "ecommerce": true, | |
| 5397 | + "verification_date": "2026-08-12", | |
| 5398 | + "status": "verified", | |
| 5399 | + "enabled": true | |
| 5400 | + }, | |
| 5401 | + { | |
| 5402 | + "id": "atelieroboismassif.com", | |
| 5403 | + "name": "L'Atelier O'Bois Massif", | |
| 5404 | + "url": "https://atelieroboismassif.com", | |
| 5405 | + "platform": "generic", | |
| 5406 | + "catalog_endpoint": "__generic__", | |
| 5407 | + "city": "", | |
| 5408 | + "region": "Lanaudière", | |
| 5409 | + "postal_prefix": "J0K", | |
| 5410 | + "phone": "418-369-4040", | |
| 5411 | + "origin_class": "A", | |
| 5412 | + "origin_confidence": 0.55, | |
| 5413 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 5414 | + "categories": [], | |
| 5415 | + "socials": [], | |
| 5416 | + "discovery_sources": [ | |
| 5417 | + "search_sweep_nonfood" | |
| 5418 | + ], | |
| 5419 | + "discovery_source_urls": [ | |
| 5420 | + "meubles bois massif fabricant Laurentides boutique en ligne" | |
| 5421 | + ], | |
| 5422 | + "language": "fr", | |
| 5423 | + "ecommerce": true, | |
| 5424 | + "verification_date": "2026-08-12", | |
| 5425 | + "status": "probable", | |
| 5426 | + "enabled": true | |
| 5427 | + }, | |
| 5428 | + { | |
| 5429 | + "id": "atelierora.ca", | |
| 5430 | + "name": "Atelier Ora", | |
| 5431 | + "url": "https://www.atelierora.ca", | |
| 5432 | + "platform": "shopify", | |
| 5433 | + "catalog_endpoint": "/products.json", | |
| 5434 | + "city": "", | |
| 5435 | + "region": "", | |
| 5436 | + "postal_prefix": "H2T", | |
| 5437 | + "phone": "514 847-9799", | |
| 5438 | + "origin_class": "A", | |
| 5439 | + "origin_confidence": 0.85, | |
| 5440 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", | |
| 5441 | + "categories": [], | |
| 5442 | + "socials": [ | |
| 5443 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5444 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5445 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5446 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5447 | + ], | |
| 5448 | + "discovery_sources": [ | |
| 5449 | + "lesproduitsduquebec_ent" | |
| 5450 | + ], | |
| 5451 | + "discovery_source_urls": [ | |
| 5452 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-ora" | |
| 5453 | + ], | |
| 5454 | + "language": "en", | |
| 5455 | + "ecommerce": true, | |
| 5456 | + "verification_date": "2026-08-12", | |
| 5457 | + "status": "verified", | |
| 5458 | + "enabled": true | |
| 5459 | + }, | |
| 5460 | + { | |
| 5461 | + "id": "atelierpare.com", | |
| 5462 | + "name": "ÉCONOMUSÉE® de la sculpture sur bois", | |
| 5463 | + "url": "https://atelierpare.com", | |
| 5464 | + "platform": "", | |
| 5465 | + "catalog_endpoint": "", | |
| 5466 | + "city": "", | |
| 5467 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 5468 | + "postal_prefix": "G0A", | |
| 5469 | + "phone": "418 827 3992", | |
| 5470 | + "origin_class": "A", | |
| 5471 | + "origin_confidence": 0.9, | |
| 5472 | + "origin_evidence": "Artisan certifié du réseau Économusée / Artisans à l'œuvre (production artisanale sur place)", | |
| 5473 | + "categories": [ | |
| 5474 | + "art" | |
| 5475 | + ], | |
| 5476 | + "socials": [ | |
| 5477 | + "https://www.facebook.com/Artisansaloeuvre", | |
| 5478 | + "https://www.instagram.com/artisansaloeuvreqc/", | |
| 5479 | + "https://www.youtube.com/@artisansaloeuvre", | |
| 5480 | + "https://www.linkedin.com/company/societe-du-reseau-economusee/" | |
| 5481 | + ], | |
| 5482 | + "discovery_sources": [ | |
| 5483 | + "artisansaloeuvre" | |
| 5484 | + ], | |
| 5485 | + "discovery_source_urls": [ | |
| 5486 | + "https://artisansaloeuvre.com/artisans/economusee-de-la-sculpture-sur-bois-atelier-pare/" | |
| 5487 | + ], | |
| 5488 | + "language": "fr", | |
| 5489 | + "ecommerce": false, | |
| 5490 | + "verification_date": "2026-08-12", | |
| 5491 | + "status": "verified", | |
| 5492 | + "enabled": false | |
| 5493 | + }, | |
| 5494 | + { | |
| 5495 | + "id": "ateliersaintcerf.com", | |
| 5496 | + "name": "Atelier Saint-Cerf", | |
| 5497 | + "url": "https://www.ateliersaintcerf.com", | |
| 5498 | + "platform": "wix", | |
| 5499 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 5500 | + "city": "", | |
| 5501 | + "region": "Estrie", | |
| 5502 | + "postal_prefix": "G6B", | |
| 5503 | + "phone": null, | |
| 5504 | + "origin_class": "A", | |
| 5505 | + "origin_confidence": 0.85, | |
| 5506 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 5507 | + "categories": [ | |
| 5508 | + "art" | |
| 5509 | + ], | |
| 5510 | + "socials": [ | |
| 5511 | + "instagram.com", | |
| 5512 | + "facebook.com" | |
| 5513 | + ], | |
| 5514 | + "discovery_sources": [ | |
| 5515 | + "search_sweep_cmaq", | |
| 5516 | + "search_sweep_manuf" | |
| 5517 | + ], | |
| 5518 | + "discovery_source_urls": [ | |
| 5519 | + "Alain Laferriere Atelier Saint-Cerf sculpteur québec", | |
| 5520 | + "fabricant québécois jouets en bois boutique en ligne" | |
| 5521 | + ], | |
| 5522 | + "language": "en", | |
| 5523 | + "ecommerce": true, | |
| 5524 | + "verification_date": "2026-08-12", | |
| 5525 | + "status": "verified", | |
| 5526 | + "enabled": true | |
| 5527 | + }, | |
| 5528 | + { | |
| 5529 | + "id": "ateliersderegis.com", | |
| 5530 | + "name": "Ateliers de Régis", | |
| 5531 | + "url": "https://ateliersderegis.com", | |
| 5532 | + "platform": "", | |
| 5533 | + "catalog_endpoint": "", | |
| 5534 | + "city": "", | |
| 5535 | + "region": "", | |
| 5536 | + "postal_prefix": null, | |
| 5537 | + "phone": null, | |
| 5538 | + "origin_class": "A", | |
| 5539 | + "origin_confidence": 0.6, | |
| 5540 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 5541 | + "categories": [ | |
| 5542 | + "art" | |
| 5543 | + ], | |
| 5544 | + "socials": [], | |
| 5545 | + "discovery_sources": [ | |
| 5546 | + "search_sweep_cmaq" | |
| 5547 | + ], | |
| 5548 | + "discovery_source_urls": [ | |
| 5549 | + "Ateliers de Régis Régis Eroyan tailleur de pierre québec" | |
| 5550 | + ], | |
| 5551 | + "language": null, | |
| 5552 | + "ecommerce": false, | |
| 5553 | + "verification_date": "2026-08-12", | |
| 5554 | + "status": "probable", | |
| 5555 | + "enabled": false | |
| 5556 | + }, | |
| 5557 | + { | |
| 5558 | + "id": "ateliertrilobe.com", | |
| 5559 | + "name": "Atelier Trilobe", | |
| 5560 | + "url": "https://www.ateliertrilobe.com", | |
| 5561 | + "platform": "wix", | |
| 5562 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 5563 | + "city": "", | |
| 5564 | + "region": "Capitale-Nationale", | |
| 5565 | + "postal_prefix": "G0A", | |
| 5566 | + "phone": "418-609-2617", | |
| 5567 | + "origin_class": "A", | |
| 5568 | + "origin_confidence": 0.85, | |
| 5569 | + "origin_evidence": "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", | |
| 5570 | + "categories": [ | |
| 5571 | + "art" | |
| 5572 | + ], | |
| 5573 | + "socials": [ | |
| 5574 | + "facebook.com" | |
| 5575 | + ], | |
| 5576 | + "discovery_sources": [ | |
| 5577 | + "search_sweep_cmaq" | |
| 5578 | + ], | |
| 5579 | + "discovery_source_urls": [ | |
| 5580 | + "Atelier Trilobe Adrien Bobin tailleur de pierre québec" | |
| 5581 | + ], | |
| 5582 | + "language": "fr", | |
| 5583 | + "ecommerce": true, | |
| 5584 | + "verification_date": "2026-08-12", | |
| 5585 | + "status": "verified", | |
| 5586 | + "enabled": true | |
| 5587 | + }, | |
| 5588 | + { | |
| 5589 | + "id": "ateliervcuir.com", | |
| 5590 | + "name": "Atelier V Cuir", | |
| 5591 | + "url": "https://www.ateliervcuir.com", | |
| 5592 | + "platform": "squarespace", | |
| 5593 | + "catalog_endpoint": "__generic__", | |
| 5594 | + "city": "", | |
| 5595 | + "region": "Bas-Saint-Laurent", | |
| 5596 | + "postal_prefix": "G0L", | |
| 5597 | + "phone": null, | |
| 5598 | + "origin_class": "A", | |
| 5599 | + "origin_confidence": 0.55, | |
| 5600 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC) ; le site affiche une mention de fabrication québécoise", | |
| 5601 | + "categories": [], | |
| 5602 | + "socials": [ | |
| 5603 | + "facebook.com", | |
| 5604 | + "instagram.com" | |
| 5605 | + ], | |
| 5606 | + "discovery_sources": [ | |
| 5607 | + "search_sweep_nonfood" | |
| 5608 | + ], | |
| 5609 | + "discovery_source_urls": [ | |
| 5610 | + "cuir artisanal Québec boutique en ligne sacs" | |
| 5611 | + ], | |
| 5612 | + "language": "en", | |
| 5613 | + "ecommerce": true, | |
| 5614 | + "verification_date": "2026-08-12", | |
| 5615 | + "status": "probable", | |
| 5616 | + "enabled": true | |
| 5617 | + }, | |
| 5618 | + { | |
| 5619 | + "id": "atelierverred.com", | |
| 5620 | + "name": "Atelier Verred - CMAQ", | |
| 5621 | + "url": "https://atelierverred.com", | |
| 5622 | + "platform": "", | |
| 5623 | + "catalog_endpoint": "", | |
| 5624 | + "city": "", | |
| 5625 | + "region": "Montréal", | |
| 5626 | + "postal_prefix": "H2T", | |
| 5627 | + "phone": "514 847-9799", | |
| 5628 | + "origin_class": "A", | |
| 5629 | + "origin_confidence": 0.65, | |
| 5630 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; le site affiche une mention de fabrication québécoise ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 5631 | + "categories": [], | |
| 5632 | + "socials": [ | |
| 5633 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5634 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5635 | + "https://www.youtube.com/@lesproduitsduquebec", | |
| 5636 | + "https://www.instagram.com/lesproduitsduqc/" | |
| 5637 | + ], | |
| 5638 | + "discovery_sources": [ | |
| 5639 | + "lesproduitsduquebec_ent" | |
| 5640 | + ], | |
| 5641 | + "discovery_source_urls": [ | |
| 5642 | + "https://lesproduitsduquebec.com/entreprises-adherentes/atelier-verred-cmaq" | |
| 5643 | + ], | |
| 5644 | + "language": "fr", | |
| 5645 | + "ecommerce": true, | |
| 5646 | + "verification_date": "2026-08-12", | |
| 5647 | + "status": "probable", | |
| 5648 | + "enabled": false | |
| 5649 | + }, | |
| 5650 | + { | |
| 5651 | + "id": "atestrie.com", | |
| 5652 | + "name": "Créateurs", | |
| 5653 | + "url": "https://www.atestrie.com", | |
| 5654 | + "platform": "wix", | |
| 5655 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 5656 | + "city": "", | |
| 5657 | + "region": "Estrie", | |
| 5658 | + "postal_prefix": "J1H", | |
| 5659 | + "phone": "(819) 562-4413", | |
| 5660 | + "origin_class": "A", | |
| 5661 | + "origin_confidence": 0.85, | |
| 5662 | + "origin_evidence": "Membre Créateurs de saveurs Cantons-de-l'Est (CIBLE Estrie)", | |
| 5663 | + "categories": [ | |
| 5664 | + "epicerie" | |
| 5665 | + ], | |
| 5666 | + "socials": [ | |
| 5667 | + "https://www.facebook.com/createursdesaveurscde/", | |
| 5668 | + "https://www.instagram.com/createursdesaveurscde/", | |
| 5669 | + "https://www.tiktok.com/@createursdesaveurs", | |
| 5670 | + "https://www.facebook.com/marchesolidariteestrie" | |
| 5671 | + ], | |
| 5672 | + "discovery_sources": [ | |
| 5673 | + "createursdesaveurs" | |
| 5674 | + ], | |
| 5675 | + "discovery_source_urls": [ | |
| 5676 | + "https://createursdesaveurs.com/createurs/marche-de-solidarite-des-amies-de-la-terre-de-lestrie/" | |
| 5677 | + ], | |
| 5678 | + "language": null, | |
| 5679 | + "ecommerce": true, | |
| 5680 | + "verification_date": "2026-08-12", | |
| 5681 | + "status": "verified", | |
| 5682 | + "enabled": true | |
| 5683 | + }, | |
| 5684 | + { | |
| 5685 | + "id": "atikuss.com", | |
| 5686 | + "name": "ÉCONOMUSÉE® du Maskisin", | |
| 5687 | + "url": "https://atikuss.com", | |
| 5688 | + "platform": "shopify", | |
| 5689 | + "catalog_endpoint": "/products.json", | |
| 5690 | + "city": "", | |
| 5691 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 5692 | + "postal_prefix": "G4R", | |
| 5693 | + "phone": "418 962 6377", | |
| 5694 | + "origin_class": "A", | |
| 5695 | + "origin_confidence": 0.9, | |
| 5696 | + "origin_evidence": "Artisan certifié du réseau Économusée / Artisans à l'œuvre (production artisanale sur place)", | |
| 5697 | + "categories": [ | |
| 5698 | + "art" | |
| 5699 | + ], | |
| 5700 | + "socials": [ | |
| 5701 | + "https://www.facebook.com/Artisansaloeuvre", | |
| 5702 | + "https://www.instagram.com/artisansaloeuvreqc/", | |
| 5703 | + "https://www.youtube.com/@artisansaloeuvre", | |
| 5704 | + "https://www.linkedin.com/company/societe-du-reseau-economusee/" | |
| 5705 | + ], | |
| 5706 | + "discovery_sources": [ | |
| 5707 | + "artisansaloeuvre", | |
| 5708 | + "search_sweep_nonfood", | |
| 5709 | + "search_sweep_regions" | |
| 5710 | + ], | |
| 5711 | + "discovery_source_urls": [ | |
| 5712 | + "https://artisansaloeuvre.com/artisans/economusee-de-maskisin/", | |
| 5713 | + "artisanat autochtone premières nations Québec boutique en ligne mocassins perles", | |
| 5714 | + "Atikuss mocassins mukluks boutique en ligne Innu" | |
| 5715 | + ], | |
| 5716 | + "language": null, | |
| 5717 | + "ecommerce": true, | |
| 5718 | + "verification_date": "2026-08-12", | |
| 5719 | + "status": "verified", | |
| 5720 | + "enabled": true | |
| 5721 | + }, | |
| 5722 | + { | |
| 5723 | + "id": "atkinsetfreres.com", | |
| 5724 | + "name": "ÉCONOMUSÉE® du charcutier de la mer", | |
| 5725 | + "url": "https://www.atkinsetfreres.com", | |
| 5726 | + "platform": "squarespace", | |
| 5727 | + "catalog_endpoint": "", | |
| 5728 | + "city": "", | |
| 5729 | + "region": "Gaspésie–Îles-de-la-Madeleine", | |
| 5730 | + "postal_prefix": "G0E", | |
| 5731 | + "phone": "418-797-5059", | |
| 5732 | + "origin_class": "A", | |
| 5733 | + "origin_confidence": 0.95, | |
| 5734 | + "origin_evidence": "Artisan certifié du réseau Économusée / Artisans à l'œuvre (production artisanale sur place) ; le site affiche une mention de fabrication québécoise", | |
| 5735 | + "categories": [ | |
| 5736 | + "art" | |
| 5737 | + ], | |
| 5738 | + "socials": [ | |
| 5739 | + "https://www.facebook.com/Artisansaloeuvre", | |
| 5740 | + "https://www.instagram.com/artisansaloeuvreqc/", | |
| 5741 | + "https://www.youtube.com/@artisansaloeuvre", | |
| 5742 | + "https://www.linkedin.com/company/societe-du-reseau-economusee/" | |
| 5743 | + ], | |
| 5744 | + "discovery_sources": [ | |
| 5745 | + "artisansaloeuvre", | |
| 5746 | + "gaspesiegourmande", | |
| 5747 | + "search_sweep_food" | |
| 5748 | + ], | |
| 5749 | + "discovery_source_urls": [ | |
| 5750 | + "https://artisansaloeuvre.com/artisans/economusee-charcutier-mer-qc/", | |
| 5751 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=408&FiltreSec=2", | |
| 5752 | + "https://gaspesiegourmande.com/producteurs-transformateurs?id=408&Filtre=9&FiltreSec=2", | |
| 5753 | + "poissonnerie fumoir Gaspésie boutique en ligne" | |
| 5754 | + ], | |
| 5755 | + "language": "fr", | |
| 5756 | + "ecommerce": true, | |
| 5757 | + "verification_date": "2026-08-12", | |
| 5758 | + "status": "verified", | |
| 5759 | + "enabled": false | |
| 5760 | + }, | |
| 5761 | + { | |
| 5762 | + "id": "attitudeanimale.com", | |
| 5763 | + "name": "Attitude Animale", | |
| 5764 | + "url": "https://www.attitudeanimale.com", | |
| 5765 | + "platform": "shopify", | |
| 5766 | + "catalog_endpoint": "/products.json", | |
| 5767 | + "city": "", | |
| 5768 | + "region": "", | |
| 5769 | + "postal_prefix": null, | |
| 5770 | + "phone": null, | |
| 5771 | + "origin_class": "A", | |
| 5772 | + "origin_confidence": 0.5, | |
| 5773 | + "origin_evidence": "Trouvé par balayage de recherche ciblé (fabricant/artisan QC)", | |
| 5774 | + "categories": [], | |
| 5775 | + "socials": [ | |
| 5776 | + "facebook.com", | |
| 5777 | + "instagram.com" | |
| 5778 | + ], | |
| 5779 | + "discovery_sources": [ | |
| 5780 | + "search_sweep_nonfood" | |
| 5781 | + ], | |
| 5782 | + "discovery_source_urls": [ | |
| 5783 | + "accessoires chien chat fabriqués main Québec harnais laisse boutique" | |
| 5784 | + ], | |
| 5785 | + "language": null, | |
| 5786 | + "ecommerce": true, | |
| 5787 | + "verification_date": "2026-08-12", | |
| 5788 | + "status": "probable", | |
| 5789 | + "enabled": true | |
| 5790 | + }, | |
| 5791 | + { | |
| 5792 | + "id": "attitudenutrition.ca", | |
| 5793 | + "name": "Attitude Nutrition", | |
| 5794 | + "url": "https://attitudenutrition.ca", | |
| 5795 | + "platform": "shopify", | |
| 5796 | + "catalog_endpoint": "/products.json", | |
| 5797 | + "city": "", | |
| 5798 | + "region": "Capitale-Nationale", | |
| 5799 | + "postal_prefix": "G5A", | |
| 5800 | + "phone": null, | |
| 5801 | + "origin_class": "A", | |
| 5802 | + "origin_confidence": 0.85, | |
| 5803 | + "origin_evidence": "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", | |
| 5804 | + "categories": [ | |
| 5805 | + "epicerie" | |
| 5806 | + ], | |
| 5807 | + "socials": [ | |
| 5808 | + "https://www.facebook.com/alimentsduquebec", | |
| 5809 | + "https://www.instagram.com/alimentsqc/?hl=fr", | |
| 5810 | + "https://www.youtube.com/@alimentsduquebec918", | |
| 5811 | + "https://www.tiktok.com/@alimentsqc" | |
| 5812 | + ], | |
| 5813 | + "discovery_sources": [ | |
| 5814 | + "alimentsduquebec_ent" | |
| 5815 | + ], | |
| 5816 | + "discovery_source_urls": [ | |
| 5817 | + "https://alimentsduquebec.com/entreprises/attitude-nutrition" | |
| 5818 | + ], | |
| 5819 | + "language": "en", | |
| 5820 | + "ecommerce": true, | |
| 5821 | + "verification_date": "2026-08-12", | |
| 5822 | + "status": "verified", | |
| 5823 | + "enabled": true | |
| 5824 | + }, | |
| 5825 | + { | |
| 5826 | + "id": "aubainerie.com", | |
| 5827 | + "name": "Aubainerie", | |
| 5828 | + "url": "https://aubainerie.com", | |
| 5829 | + "platform": "shopify", | |
| 5830 | + "catalog_endpoint": "/products.json", | |
| 5831 | + "city": "", | |
| 5832 | + "region": "Montréal", | |
| 5833 | + "postal_prefix": "H1Z", | |
| 5834 | + "phone": "514 847-9799", | |
| 5835 | + "origin_class": "A", | |
| 5836 | + "origin_confidence": 0.6, | |
| 5837 | + "origin_evidence": "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 5838 | + "categories": [], | |
| 5839 | + "socials": [ | |
| 5840 | + "https://www.facebook.com/Aubainerie/?locale=fr_CA", | |
| 5841 | + "https://www.facebook.com/lesproduitsduqc", | |
| 5842 | + "https://www.linkedin.com/company/les-produits-du-qu%C3%A9bec/", | |
| 5843 | + "https://www.youtube.com/@lesproduitsduquebec" | |
| 5844 | + ], | |
| 5845 | + "discovery_sources": [ | |
| 5846 | + "lesproduitsduquebec_det", | |
| 5847 | + "lesproduitsduquebec_ent" | |
| 5848 | + ], | |
| 5849 | + "discovery_source_urls": [ | |
| 5850 | + "https://lesproduitsduquebec.com/detaillants/aubainerie", | |
| 5851 | + "https://lesproduitsduquebec.com/entreprises-adherentes/aubainerie-mode" | |
| 5852 | + ], | |
| 5853 | + "language": "en", | |
| 5854 | + "ecommerce": true, | |
| 5855 | + "verification_date": "2026-08-12", | |
| 5856 | + "status": "probable", | |
| 5857 | + "enabled": true | |
| 5858 | + }, | |
| 5859 | + { | |
| 5860 | + "id": "domainelacbrouillard.com", | |
| 5861 | + "name": "Auberge Boréale de Charlevoix", | |
| 5862 | + "url": "https://www.domainelacbrouillard.com", | |
| 5863 | + "platform": "", | |
| 5864 | + "catalog_endpoint": "", | |
| 5865 | + "city": "", | |
| 5866 | + "region": "Capitale-Nationale", | |
| 5867 | + "postal_prefix": "G0V", | |
| 5868 | + "phone": "418 478-9958", | |
| 5869 | + "origin_class": "A", | |
| 5870 | + "origin_confidence": 0.7, | |
| 5871 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 5872 | + "categories": [ | |
| 5873 | + "epicerie" | |
| 5874 | + ], | |
| 5875 | + "socials": [ | |
| 5876 | + "https://www.instagram.com/domaine.lac.brouillard/", | |
| 5877 | + "http://www.facebook.com/domaine.lac.brouillard", | |
| 5878 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 5879 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 5880 | + ], | |
| 5881 | + "discovery_sources": [ | |
| 5882 | + "charlevoix" | |
| 5883 | + ], | |
| 5884 | + "discovery_source_urls": [ | |
| 5885 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-boreale-de-charlevoix" | |
| 5886 | + ], | |
| 5887 | + "language": "fr", | |
| 5888 | + "ecommerce": false, | |
| 5889 | + "verification_date": "2026-08-12", | |
| 5890 | + "status": "verified", | |
| 5891 | + "enabled": false | |
| 5892 | + }, | |
| 5893 | + { | |
| 5894 | + "id": "auberge3canards.com", | |
| 5895 | + "name": "Auberge Des 3 Canards", | |
| 5896 | + "url": "https://www.auberge3canards.com", | |
| 5897 | + "platform": "", | |
| 5898 | + "catalog_endpoint": "", | |
| 5899 | + "city": "", | |
| 5900 | + "region": "Capitale-Nationale", | |
| 5901 | + "postal_prefix": "G5A", | |
| 5902 | + "phone": "418 665-3761", | |
| 5903 | + "origin_class": "A", | |
| 5904 | + "origin_confidence": 0.7, | |
| 5905 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 5906 | + "categories": [ | |
| 5907 | + "epicerie" | |
| 5908 | + ], | |
| 5909 | + "socials": [ | |
| 5910 | + "https://www.instagram.com/auberge3canards/", | |
| 5911 | + "https://www.facebook.com/auberge3canards/", | |
| 5912 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 5913 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 5914 | + ], | |
| 5915 | + "discovery_sources": [ | |
| 5916 | + "charlevoix" | |
| 5917 | + ], | |
| 5918 | + "discovery_source_urls": [ | |
| 5919 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-des-3-canards", | |
| 5920 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-des-3-canards-restaurant" | |
| 5921 | + ], | |
| 5922 | + "language": "en", | |
| 5923 | + "ecommerce": true, | |
| 5924 | + "verification_date": "2026-08-12", | |
| 5925 | + "status": "verified", | |
| 5926 | + "enabled": false | |
| 5927 | + }, | |
| 5928 | + { | |
| 5929 | + "id": "aubergealachouenne.com", | |
| 5930 | + "name": "Auberge à La Chouenne", | |
| 5931 | + "url": "https://www.aubergealachouenne.com", | |
| 5932 | + "platform": "", | |
| 5933 | + "catalog_endpoint": "", | |
| 5934 | + "city": "", | |
| 5935 | + "region": "Capitale-Nationale", | |
| 5936 | + "postal_prefix": "G5A", | |
| 5937 | + "phone": "418 665-1166", | |
| 5938 | + "origin_class": "A", | |
| 5939 | + "origin_confidence": 0.7, | |
| 5940 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 5941 | + "categories": [ | |
| 5942 | + "epicerie" | |
| 5943 | + ], | |
| 5944 | + "socials": [ | |
| 5945 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 5946 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 5947 | + "https://www.instagram.com/tourismecharlevoix/", | |
| 5948 | + "https://www.youtube.com/user/TourismeCharlevoix" | |
| 5949 | + ], | |
| 5950 | + "discovery_sources": [ | |
| 5951 | + "charlevoix" | |
| 5952 | + ], | |
| 5953 | + "discovery_source_urls": [ | |
| 5954 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-a-la-chouenne" | |
| 5955 | + ], | |
| 5956 | + "language": null, | |
| 5957 | + "ecommerce": true, | |
| 5958 | + "verification_date": "2026-08-12", | |
| 5959 | + "status": "verified", | |
| 5960 | + "enabled": false | |
| 5961 | + }, | |
| 5962 | + { | |
| 5963 | + "id": "aubergeancrage.com", | |
| 5964 | + "name": "Auberge Ancrage", | |
| 5965 | + "url": "https://aubergeancrage.com", | |
| 5966 | + "platform": "woocommerce", | |
| 5967 | + "catalog_endpoint": "", | |
| 5968 | + "city": "", | |
| 5969 | + "region": "Capitale-Nationale", | |
| 5970 | + "postal_prefix": "G3Z", | |
| 5971 | + "phone": "418 240-3264", | |
| 5972 | + "origin_class": "A", | |
| 5973 | + "origin_confidence": 0.7, | |
| 5974 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 5975 | + "categories": [ | |
| 5976 | + "epicerie" | |
| 5977 | + ], | |
| 5978 | + "socials": [ | |
| 5979 | + "https://www.facebook.com/Auberge-Ancrage-206529602713026/?fref=ts", | |
| 5980 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 5981 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 5982 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 5983 | + ], | |
| 5984 | + "discovery_sources": [ | |
| 5985 | + "charlevoix" | |
| 5986 | + ], | |
| 5987 | + "discovery_source_urls": [ | |
| 5988 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-ancrage" | |
| 5989 | + ], | |
| 5990 | + "language": "fr", | |
| 5991 | + "ecommerce": false, | |
| 5992 | + "verification_date": "2026-08-12", | |
| 5993 | + "status": "verified", | |
| 5994 | + "enabled": false | |
| 5995 | + }, | |
| 5996 | + { | |
| 5997 | + "id": "aubergebeausejour.com", | |
| 5998 | + "name": "Auberge Beauséjour", | |
| 5999 | + "url": "https://www.aubergebeausejour.com", | |
| 6000 | + "platform": "wix", | |
| 6001 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 6002 | + "city": "", | |
| 6003 | + "region": "Capitale-Nationale", | |
| 6004 | + "postal_prefix": "G0A", | |
| 6005 | + "phone": "418-635-2895", | |
| 6006 | + "origin_class": "A", | |
| 6007 | + "origin_confidence": 0.7, | |
| 6008 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 6009 | + "categories": [ | |
| 6010 | + "epicerie" | |
| 6011 | + ], | |
| 6012 | + "socials": [ | |
| 6013 | + "https://www.facebook.com/Auberge-Beausejour-53579914567/?fref=ts", | |
| 6014 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 6015 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 6016 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 6017 | + ], | |
| 6018 | + "discovery_sources": [ | |
| 6019 | + "charlevoix" | |
| 6020 | + ], | |
| 6021 | + "discovery_source_urls": [ | |
| 6022 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-beausejour", | |
| 6023 | + "https://www.tourisme-charlevoix.com/fr/entreprises/le-bistro-du-quai" | |
| 6024 | + ], | |
| 6025 | + "language": "fr", | |
| 6026 | + "ecommerce": true, | |
| 6027 | + "verification_date": "2026-08-12", | |
| 6028 | + "status": "verified", | |
| 6029 | + "enabled": true | |
| 6030 | + }, | |
| 6031 | + { | |
| 6032 | + "id": "aubergecampdebase.com", | |
| 6033 | + "name": "Auberge Le Camp de Base", | |
| 6034 | + "url": "https://www.aubergecampdebase.com", | |
| 6035 | + "platform": "wordpress", | |
| 6036 | + "catalog_endpoint": "", | |
| 6037 | + "city": "", | |
| 6038 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 6039 | + "postal_prefix": "G0V", | |
| 6040 | + "phone": "418 272-3359", | |
| 6041 | + "origin_class": "A", | |
| 6042 | + "origin_confidence": 0.85, | |
| 6043 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 6044 | + "categories": [ | |
| 6045 | + "epicerie" | |
| 6046 | + ], | |
| 6047 | + "socials": [ | |
| 6048 | + "https://www.facebook.com/zoneboreale", | |
| 6049 | + "https://www.instagram.com/zone_boreale/", | |
| 6050 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
| 6051 | + "https://www.linkedin.com/company/table-agroalimentaire-du-saguenay-lac-saint-jean/" | |
| 6052 | + ], | |
| 6053 | + "discovery_sources": [ | |
| 6054 | + "zoneboreale" | |
| 6055 | + ], | |
| 6056 | + "discovery_source_urls": [ | |
| 6057 | + "https://zoneboreale.com/entreprises/auberge-le-camp-de-base/", | |
| 6058 | + "https://zoneboreale.com/entreprises/fum-fum/" | |
| 6059 | + ], | |
| 6060 | + "language": "fr", | |
| 6061 | + "ecommerce": false, | |
| 6062 | + "verification_date": "2026-08-12", | |
| 6063 | + "status": "verified", | |
| 6064 | + "enabled": false | |
| 6065 | + }, | |
| 6066 | + { | |
| 6067 | + "id": "aubergecarpediem.ca", | |
| 6068 | + "name": "Auberge Carpe Diem", | |
| 6069 | + "url": "https://aubergecarpediem.ca", | |
| 6070 | + "platform": "wordpress", | |
| 6071 | + "catalog_endpoint": "", | |
| 6072 | + "city": "", | |
| 6073 | + "region": "Capitale-Nationale", | |
| 6074 | + "postal_prefix": "G3Z", | |
| 6075 | + "phone": "418 435-6983", | |
| 6076 | + "origin_class": "A", | |
| 6077 | + "origin_confidence": 0.45, | |
| 6078 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix) ; aucun signal de localisation québécoise détecté sur le site (à vérifier)", | |
| 6079 | + "categories": [ | |
| 6080 | + "epicerie" | |
| 6081 | + ], | |
| 6082 | + "socials": [ | |
| 6083 | + "https://www.facebook.com/aubergecarpediem/", | |
| 6084 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 6085 | + "https://www.linkedin.com/company/tourisme-charlevoix/", | |
| 6086 | + "https://www.instagram.com/tourismecharlevoix/" | |
| 6087 | + ], | |
| 6088 | + "discovery_sources": [ | |
| 6089 | + "charlevoix" | |
| 6090 | + ], | |
| 6091 | + "discovery_source_urls": [ | |
| 6092 | + "https://www.tourisme-charlevoix.com/fr/entreprises/auberge-carpe-diem" | |
| 6093 | + ], | |
| 6094 | + "language": "fr", | |
| 6095 | + "ecommerce": false, | |
| 6096 | + "verification_date": "2026-08-12", | |
| 6097 | + "status": "probable", | |
| 6098 | + "enabled": false | |
| 6099 | + }, | |
| 6100 | + { | |
| 6101 | + "id": "aubergecheztruchon.com", | |
| 6102 | + "name": "Bistro-Auberge Chez Truchon", | |
| 6103 | + "url": "https://www.aubergecheztruchon.com", | |
| 6104 | + "platform": "wix", | |
| 6105 | + "catalog_endpoint": "/_api/wix-ecommerce-storefront-web/api", | |
| 6106 | + "city": "", | |
| 6107 | + "region": "Capitale-Nationale", | |
| 6108 | + "postal_prefix": "G5A", | |
| 6109 | + "phone": "418 665-4622", | |
| 6110 | + "origin_class": "A", | |
| 6111 | + "origin_confidence": 0.7, | |
| 6112 | + "origin_evidence": "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", | |
| 6113 | + "categories": [ | |
| 6114 | + "epicerie" | |
| 6115 | + ], | |
| 6116 | + "socials": [ | |
| 6117 | + "https://www.instagram.com/cheztruchon/", | |
| 6118 | + "http://www.facebook.com/pages/Chez-Truchon/215638428463776", | |
| 6119 | + "https://www.facebook.com/tourismecharlevoix/?locale=fr_CA", | |
| 6120 | + "https://www.linkedin.com/company/tourisme-charlevoix/" | |
| 6121 | + ], | |
| 6122 | + "discovery_sources": [ | |
| 6123 | + "charlevoix" | |
| 6124 | + ], | |
| 6125 | + "discovery_source_urls": [ | |
| 6126 | + "https://www.tourisme-charlevoix.com/fr/entreprises/bistro-auberge-chez-truchon", | |
| 6127 | + "https://www.tourisme-charlevoix.com/fr/entreprises/chez-truchon" | |
| 6128 | + ], | |
| 6129 | + "language": "en", | |
| 6130 | + "ecommerce": true, | |
| 6131 | + "verification_date": "2026-08-12", | |
| 6132 | + "status": "verified", | |
| 6133 | + "enabled": true | |
| 6134 | + }, | |
| 6135 | + { | |
| 6136 | + "id": "aubergedes21.ca", | |
| 6137 | + "name": "Horizon – restaurant & bar à vin", | |
| 6138 | + "url": "https://aubergedes21.ca", | |
| 6139 | + "platform": "woocommerce", | |
| 6140 | + "catalog_endpoint": "", | |
| 6141 | + "city": "", | |
| 6142 | + "region": "Saguenay–Lac-Saint-Jean", | |
| 6143 | + "postal_prefix": "G7B", | |
| 6144 | + "phone": "418 697-2121", | |
| 6145 | + "origin_class": "A", | |
| 6146 | + "origin_confidence": 0.85, | |
| 6147 | + "origin_evidence": "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", | |
| 6148 | + "categories": [ | |
| 6149 | + "epicerie" | |
| 6150 | + ], | |
| 6151 | + "socials": [ | |
| 6152 | + "https://www.facebook.com/zoneboreale", | |
| 6153 | + "https://www.instagram.com/zone_boreale/", | |
| 6154 | + "https://www.youtube.com/channel/UCcc5klSXSkqU3lkiE5nizeA", | |
Diff truncated — file too large.