SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%

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>
Simon-Pierre Boucher committed 2 h ago (Aug 13, 2026)

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 +[![Live](https://img.shields.io/badge/live-www.ora--ka.com-d9f26b?style=flat-square&labelColor=141814)](https://www.ora-ka.com)
6 +[![Python](https://img.shields.io/badge/python-3.14-3776ab?style=flat-square&labelColor=141814&logo=python&logoColor=white)](requirements.txt)
7 +[![FastAPI](https://img.shields.io/badge/fastapi-%E2%89%A50.110-009688?style=flat-square&labelColor=141814)](oraka/web.py)
8 +[![React](https://img.shields.io/badge/react-18-61dafb?style=flat-square&labelColor=141814&logo=react&logoColor=61dafb)](apps)
9 +[![SQLite](https://img.shields.io/badge/sqlite-%E2%89%881.4_Go-044a64?style=flat-square&labelColor=141814&logo=sqlite&logoColor=white)](apps)
10 +[![Embeddings](https://img.shields.io/badge/openai-text--embedding--3--small_256d-6e56cf?style=flat-square&labelColor=141814)](oraka/semantic.py)
11 +[![Inscriptions](https://img.shields.io/badge/inscriptions-400_498-d9f26b?style=flat-square&labelColor=141814)](https://www.ora-ka.com/api/overview)
12 +[![Node](https://img.shields.io/badge/prod-M4M64a%3A8200_·_pm2_·_ngrok-ff5a2a?style=flat-square&labelColor=141814)](#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 +![Landing Ora-Ka](docs/screenshots/landing.png)
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 +| ![Recherche condo](docs/screenshots/recherche-condo.png) | ![Recherche fromage](docs/screenshots/recherche-fromage.png) |
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·Ka](docs/screenshots/immo.png) `/immo/` | ![Lou·Ka](docs/screenshots/lou.png) `/lou/` |
69 +| ![Fabri·Ka](docs/screenshots/fabri.png) `/fabri/` | ![Auto·Ka](docs/screenshots/auto.png) `/auto/` |
70 +| ![Food·Ka](docs/screenshots/food.png) `/food/` | ![Splash Groupe KA](docs/screenshots/splash.png) 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(?:é|&eacute;)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 (&eacute;…) -> texte brut compact."""
47 + if not text:
48 + return ""
49 + out = htmllib.unescape(str(text))
50 + out = re.sub(r"<[^>]+>", " ", out)
51 + return re.sub(r"\s{2,}", " ", out).strip()
52 +
53 +
54 +class MegaCentreDeLiquidation(BaseConnector):
55 + """Méga Centre de liquidation — sitemap + page-data Gatsby (usedCar)."""
56 +
57 + source_id = "megacentredeliquidation"
58 + base_url = "https://www.megacentredeliquidation.com"
59 + dealer_name = "Méga Centre de liquidation"
60 + request_delay = 1.0
61 + max_details = 600 # plafond de vraies requêtes par sync
62 +
63 + # -- sitemap -------------------------------------------------------------
64 + def _vehicle_slugs(self) -> list[str]:
65 + index = self.get(f"{self.base_url}/sitemap-index.xml").text
66 + maps = re.findall(r"<loc>([^<]+)</loc>", index) \
67 + or [f"{self.base_url}/sitemap-0.xml"]
68 + slugs: list[str] = []
69 + for sitemap_url in maps:
70 + xml = self.get(sitemap_url.strip()).text
71 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
72 + m = _VEH_URL_RE.match(loc.strip())
73 + if m:
74 + slugs.append(m.group(1))
75 + return sorted(set(slugs))
76 +
77 + # -- page-data Gatsby -> payload usedCar ----------------------------------
78 + def _fetch_detail(self, slug: str) -> dict:
79 + url = f"{self.base_url}/page-data/auto-usage/{slug}/page-data.json"
80 + try:
81 + data = self.get(url).json()
82 + except ValueError:
83 + return {}
84 + result = data.get("result") or {}
85 + used_car = (result.get("data") or {}).get("usedCar")
86 + if not isinstance(used_car, dict):
87 + return {}
88 + used_car.pop("coordinates", None) # gros bloc inutile
89 + used_car.pop("loanPaiement", None)
90 + return {"usedCar": used_car,
91 + "latestModification":
92 + (result.get("pageContext") or {}).get("latestModification")}
93 +
94 + # -- contrat ---------------------------------------------------------------
95 + def fetch(self) -> list[Vehicle]:
96 + slugs = self._vehicle_slugs()
97 + week = datetime.date.today().isocalendar()
98 + cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
99 + cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
100 +
101 + vehicles: list[Vehicle] = []
102 + real_fetches = 0
103 + for slug in slugs:
104 + def _fetch(s=slug):
105 + return self._fetch_detail(s)
106 +
107 + # plafond atteint : ne servir que le cache (fetch réel interdit)
108 + if real_fetches >= cap:
109 + from .. import db
110 + if self._detail_con is None:
111 + self._detail_con = db.connect()
112 + data = db.get_cached_detail(self._detail_con, self.source_id,
113 + slug, cache_key)
114 + if data is None:
115 + continue
116 + else:
117 + before = self._last_request
118 + try:
119 + data = self.detail(slug, cache_key, _fetch)
120 + except Exception:
121 + # page disparue (vendu) ou erreur ponctuelle : ne jamais
122 + # bloquer la source entière
123 + if self._last_request != before:
124 + real_fetches += 1
125 + continue
126 + if self._last_request != before: # une vraie requête a eu lieu
127 + real_fetches += 1
128 + if not data or not data.get("usedCar"):
129 + continue
130 + veh = self._to_vehicle(slug, data["usedCar"])
131 + if veh is not None:
132 + vehicles.append(veh)
133 + return vehicles
134 +
135 + # -- usedCar -> Vehicle ------------------------------------------------------
136 + def _to_vehicle(self, slug: str, d: dict) -> Vehicle | None:
137 + make = str(d.get("brand") or "").strip()
138 + model = str(d.get("labelModel_fr") or d.get("model") or "").strip()
139 + body = str(d.get("labelBodytype_fr") or d.get("bodytype") or "").strip()
140 + if _EXCLUDE_RE.search(f"{make} {model} {body}"): # non-automobile
141 + return None
142 +
143 + year = d.get("year")
144 + trim = str(d.get("trim") or "").strip()
145 + # certains labels de modèle incluent déjà la version (X1 XDRIVE28I)
146 + title_trim = "" if trim.lower() in model.lower() else trim
147 + title = " ".join(str(x)
148 + for x in (make, model, title_trim, year or "") if x)
149 + if not title:
150 + return None
151 +
152 + price = d.get("price")
153 + try:
154 + price = float(price) if price else None
155 + except (TypeError, ValueError):
156 + price = None
157 +
158 + km = d.get("odometer")
159 + try:
160 + km = float(km) if km not in (None, "") else None
161 + except (TypeError, ValueError):
162 + km = None
163 + unit = str(d.get("odometer_Type") or "km").lower()
164 + if km is not None and unit.startswith("mi"):
165 + km = round(km * 1.609344)
166 +
167 + images = d.get("images") or []
168 + if isinstance(images, str):
169 + images = [images]
170 + images = [u for u in images if isinstance(u, str)]
171 + if not images and d.get("image"):
172 + images = [str(d["image"])]
173 +
174 + features = [f.strip() for f in str(d.get("options") or "").split("|")
175 + if f.strip()]
176 +
177 + doors = d.get("doors")
178 + seats = d.get("passengers")
179 +
180 + details: dict = {"branch": str(d.get("nameDealer") or "")}
181 + for src, dst in (("accident", "accidented"),
182 + ("certified", "certified"),
183 + ("uniqueOwner", "unique_owner")):
184 + if d.get(src) is not None:
185 + details[dst] = bool(d[src])
186 + if d.get("cityConsumption"):
187 + details["consumption_city_l_100km"] = d["cityConsumption"]
188 + if d.get("hightwayConsumption"):
189 + details["consumption_hwy_l_100km"] = d["hightwayConsumption"]
190 + if d.get("inventoryID"):
191 + details["inventory_id"] = str(d["inventoryID"])
192 +
193 + return Vehicle(
194 + source=self.source_id,
195 + external_id=slug,
196 + url=f"{self.base_url}/auto-usage/{slug}/",
197 + title=title,
198 + make=make,
199 + model=model,
200 + trim=trim,
201 + year=int(year) if year else None,
202 + price=price,
203 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
204 + mileage_km=km,
205 + mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",
206 + transmission=str(d.get("transmission") or ""),
207 + fuel=str(d.get("labelEngine_fr") or d.get("engine") or ""),
208 + drivetrain=str(d.get("drivetrain") or ""),
209 + body_type=body,
210 + exterior_color=str(d.get("labelColorExt_fr") or ""),
211 + interior_color=str(d.get("labelColorInt_fr") or ""),
212 + engine=str(d.get("motor") or ""),
213 + doors=int(doors) if doors else None,
214 + seats=int(seats) if seats else None,
215 + vin=str(d.get("niv") or ""),
216 + stock_number=str(d.get("stockNO") or ""),
217 + dealer_name=str(d.get("nameDealer") or "").strip()
218 + or self.dealer_name,
219 + city=str(d.get("cityDealer") or "").strip(),
220 + description=_clean_html(d.get("description"))[:4000],
221 + features=features[:60],
222 + details=details,
223 + images=images[:20],
224 + carfax_url=str(d.get("carproofURL") or ""),
225 + )
added 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&amp;FiltreSec=2",
332 + "https://gaspesiegourmande.com/producteurs-transformateurs?id=553&amp;Filtre=6&amp;FiltreSec=2",
333 + "https://gaspesiegourmande.com/producteurs-transformateurs?id=553&amp;Filtre=7&amp;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&amp;FiltreSec=4",
1109 + "https://gaspesiegourmande.com/producteurs-transformateurs?id=611&amp;Filtre=10&amp;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&amp;FiltreSec=4",
2929 + "https://gaspesiegourmande.com/producteurs-transformateurs?id=552&amp;Filtre=7&amp;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&amp;FiltreSec=2",
5752 + "https://gaspesiegourmande.com/producteurs-transformateurs?id=408&amp;Filtre=9&amp;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.