SPB Git

spb/food-ka Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

Python 57.7% TypeScript 24.9% CSS 16.7% HTML 0.6%

Food-Ka — agrégateur de produits d'épicerie du Québec

24 connecteurs actifs (Metro, Super C, IGA/Voilà, Maxi, Provigo, Walmart,
Costco, Adonis, Club Entrepôt, T&T, Akhavan, LOCO, Mayrand, Avril, Tau,
PA, Aubut, Maturin, Giant Tiger, Epipresto, Nuvo, Boîte à Grains,
BocoBoco, Aliments Merci), ~25 500 produits, panier comparatif,
rapport PDF, frontend React « marché frais », déployé sur M4M64b
(www.food-ka.com).

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

Showing 88 changed files with +9,725 and −0

added .gitignore +11 −0
@@ -0,0 +1,11 @@
1 +__pycache__/
2 +*.pyc
3 +.venv/
4 +.env
5 +data/*.db
6 +data/*.db-journal
7 +frontend/node_modules/
8 +frontend/dist/
9 +frontend/tsconfig.tsbuildinfo
10 +.DS_Store
11 +.pytest_cache/
added README.md +186 −0
@@ -0,0 +1,186 @@
1 +<div align="center">
2 +
3 +# Food·Ka
4 +
5 +### Tous les prix d'épicerie du Québec. Un seul endroit.
6 +
7 +**[www.food-ka.com](https://www.food-ka.com)**
8 +
9 +![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=d9f26b)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=d9f26b)
11 +![React](https://img.shields.io/badge/React_18-Vite_+_TS-141814?style=for-the-badge&logo=react&logoColor=d9f26b)
12 +![SQLite](https://img.shields.io/badge/SQLite-storage-141814?style=for-the-badge&logo=sqlite&logoColor=d9f26b)
13 +![PWA](https://img.shields.io/badge/PWA-mobile_ready-141814?style=for-the-badge&logoColor=d9f26b)
14 +
15 +![Bannières](https://img.shields.io/badge/banni%C3%A8res_recens%C3%A9es-30-1c5c41?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-24-1c5c41?style=flat-square)
17 +![Produits](https://img.shields.io/badge/produits_agr%C3%A9g%C3%A9s-22%20000%2B-1c5c41?style=flat-square)
18 +![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-1c5c41?style=flat-square)
19 +
20 +*Agrégateur indépendant de produits d'épicerie — chaque produit avec son prix courant,
21 +son prix régulier, son prix unitaire comparable ($/100 g) et un lien direct vers la
22 +fiche originale de la bannière. Toujours à jour, automatiquement.*
23 +
24 +</div>
25 +
26 +---
27 +
28 +## Pourquoi Food-Ka ?
29 +
30 +Comparer les prix d'épicerie au Québec, c'est ouvrir Metro, IGA, Maxi, Super C,
31 +Provigo, Walmart… chacun avec sa propre navigation, son propre panier, son propre
32 +format. **Food-Ka retourne le problème** : un connecteur dédié par bannière visite
33 +chaque site, normalise chaque produit vers un schéma unique, et détecte les
34 +changements de prix en continu.
35 +
36 +> Les épiceries n'offrent pas de webhooks. Food-Ka reproduit l'équivalent :
37 +> **synchronisation périodique + hash de contenu** → nouveaux produits, changements
38 +> de prix et retraits détectés automatiquement. Chaque variation de prix est
39 +> historisée (`price_log`) — les soldes deviennent traçables.
40 +
41 +## L'architecture en 30 secondes
42 +
43 +```mermaid
44 +flowchart LR
45 + subgraph Sources["Bannières d'épicerie"]
46 + S1["Metro · Super C · IGA/Voilà<br/>Maxi · Provigo · Walmart<br/>Mayrand · Avril · PA · Tau<br/>… 24 connecteurs actifs"]
47 + end
48 + subgraph FoodKa["Food-Ka"]
49 + C["Connecteurs<br/><i>1 adaptateur / bannière</i>"] --> N["Normalisation<br/><i>schéma Product unique</i>"]
50 + N --> D[("SQLite<br/>hash + diff + prix")]
51 + D --> A["API FastAPI<br/>/api/products · /api/facets"]
52 + A --> F["React 18 + Vite<br/>PWA mobile"]
53 + end
54 + W["⏱ Watcher (PM2)"] -.-> C
55 + S1 --> C
56 + F --> U["🛒 Consommateur"]
57 +```
58 +
59 +| Couche | Rôle | Fichiers |
60 +|---|---|---|
61 +| **Connecteurs** | 1 module Python par bannière : HTML rendu serveur, API JSON ouvertes (Shopify `products.json`, WooCommerce Store API), `__NEXT_DATA__` (Loblaw), ou **Scrapfly** (anti-bot ASP + rendu JS) / **Firecrawl** pour les sites protégés | `foodka/connectors/*.py` |
62 +| **Schéma** | `Product` standardisé : nom, marque, format, prix, prix régulier, **prix unitaire $/100 g**, catégorie canonique, images | `foodka/schema.py` |
63 +| **Diff engine** | Upsert par hash de contenu — nouveau / modifié / disparu (désactivé), historique de prix | `foodka/db.py` |
64 +| **API** | Filtres catégorie / bannière / marque / prix / soldes / recherche, tris (prix, prix unitaire, rabais), facettes, stats | `foodka/web.py` |
65 +| **Frontend** | Design « éditorial sharp » : Space Grotesk, ombres décalées, accent lime, ticker temps réel, fiches produit avec comparaison inter-bannières, PWA installable | `frontend/` |
66 +
67 +## Démarrage rapide
68 +
69 +```bash
70 +git clone https://github.com/spboucher-ai/food-ka.git && cd food-ka
71 +
72 +# Backend
73 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
74 +
75 +# Frontend
76 +cd frontend && npm install && npm run build && cd ..
77 +
78 +# Sites protégés (anti-bot) et rendu JavaScript
79 +cat > .env <<'ENV'
80 +SCRAPFLY_API_KEY=scp-live-votre-cle
81 +FIRECRAWL_API_KEY=fc-votre-cle
82 +ENV
83 +
84 +# Ingestion puis service
85 +.venv/bin/python run.py sync # toutes les sources (ou: run.py sync metro iga)
86 +.venv/bin/python run.py serve 8080 # → http://localhost:8080
87 +.venv/bin/python run.py watch 360 # resynchronisation en boucle (minutes)
88 +```
89 +
90 +## Ajouter une bannière (≈ 30 lignes)
91 +
92 +L'enregistrement est **auto-découvrant** : déposez un module dans `foodka/connectors/`,
93 +c'est tout — aucun fichier partagé à modifier.
94 +
95 +```python
96 +# foodka/connectors/mon_epicerie.py
97 +from ..schema import Product, normalize_category, parse_price
98 +from .base import BaseConnector
99 +
100 +class MonEpicerieConnector(BaseConnector):
101 + source_id = "mon_epicerie"
102 +
103 + def fetch(self) -> list[Product]:
104 + html = self.get("https://mon-epicerie.ca/produits").text # throttlé, poli
105 + # ... ou self.get_scrapfly(url) si le site est protégé ...
106 + return [Product(
107 + source=self.source_id, external_id="123",
108 + url="https://mon-epicerie.ca/produit/123",
109 + name="Beurre d'arachide croquant", brand="Kraft",
110 + category=normalize_category("Garde-manger"),
111 + size_label="500 g", price=parse_price("4,99 $"),
112 + regular_price=6.49, images=[...],
113 + )]
114 +```
115 +
116 +Puis : `.venv/bin/python run.py sync mon_epicerie` — et les produits apparaissent sur
117 +le site, avec prix unitaire calculé et comparaison inter-bannières. Ajoutez l'entrée
118 +correspondante dans `data/sources.json` pour la page **Sources**.
119 +
120 +## API
121 +
122 +| Endpoint | Description |
123 +|---|---|
124 +| `GET /api/products?category=&source=&brand=&price_min=&price_max=&on_sale=1&q=&sort=` | Recherche filtrée (tris : prix, prix unitaire, rabais, nom, récents) |
125 +| `GET /api/products/{uid}` | Fiche complète + historique de prix + **comparaison inter-bannières** |
126 +| `GET /api/facets` | Valeurs distinctes pour construire les filtres |
127 +| `GET /api/sources` | Registre des bannières + compteurs + dernière sync |
128 +| `GET /api/stats` | Totaux, soldes actifs, meilleures aubaines, journal de synchronisation |
129 +| `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
130 +
131 +## Couverture
132 +
133 +**Grandes bannières** — Metro, Super C, IGA (Voilà), Maxi, Provigo, Walmart Canada,
134 +Costco Canada (épicerie livrée), Marché Adonis.
135 +
136 +**Spécialisées & indépendantes** — Mayrand (grossiste), Avril Supermarché Santé,
137 +Marché Tau, PA Supermarché, Aubut, Maturin (producteurs québécois), Giant Tiger,
138 +Epipresto, Marché Nuvo, La Boîte à Grains, BocoBoco, Aliments Merci, Club Entrepôt,
139 +Épiceries LOCO, Akhavan, T&T Supermarket.
140 +
141 +Chaque bannière non-connectable est **documentée avec sa raison** dans
142 +`data/sources.json` (ex. : prix liés à une session Instacart/DoorDash, catalogue
143 +sans prix, anti-bot strict).
144 +
145 +## Production
146 +
147 +Déployé sous **PM2** (3 processus) derrière **ngrok** :
148 +
149 +```
150 +food-ka-web .venv/bin/python run.py serve 8096 # API + frontend
151 +food-ka-sync .venv/bin/python run.py watch 360 # resync aux 6 h
152 +food-ka-ngrok ngrok http --url=www.food-ka.com 8096 # tunnel
153 +```
154 +
155 +Philosophie d'exploitation : **on ne pousse que le code — le serveur maintient ses
156 +données lui-même.**
157 +
158 +## Principes
159 +
160 +1. **Politesse** — délai ≥ 0,5 s entre requêtes, périmètre de crawl borné,
161 + User-Agent identifié.
162 +2. **Fidélité** — aucun prix inventé : si la source n'affiche pas de prix,
163 + `price = null` ; un « prix régulier » incohérent est rejeté.
164 +3. **Traçabilité** — chaque fiche renvoie vers le produit original de la bannière,
165 + et chaque changement de prix est historisé.
166 +4. **Robustesse** — un connecteur qui casse n'affecte jamais les autres
167 + (auto-découverte tolérante, try/except par produit, détection de dérive,
168 + journal `sync_log`).
169 +
170 +---
171 +
172 +<div align="center">
173 +
174 +## Auteur
175 +
176 +**Simon-Pierre Boucher**
177 +
178 +[![Email](https://img.shields.io/badge/contact@spboucher.ai-141814?style=for-the-badge&logo=minutemailer&logoColor=d9f26b)](mailto:contact@spboucher.ai)
179 +[![GitHub](https://img.shields.io/badge/spboucher--ai-141814?style=for-the-badge&logo=github&logoColor=d9f26b)](https://github.com/spboucher-ai)
180 +
181 +*Conçu, construit et déployé en une journée — de la recherche de marché
182 +(30 bannières recensées et vérifiées) au produit en production.*
183 +
184 +© 2026 Simon-Pierre Boucher — tous droits réservés.
185 +
186 +</div>
added data/sources.json +313 −0
@@ -0,0 +1,313 @@
1 +{
2 + "_comment": "Food-Ka — Registre des sources (épiceries en ligne, province de Québec). Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 + "sources": [
4 + {
5 + "id": "metro",
6 + "name": "Metro",
7 + "url": "https://www.metro.ca",
8 + "catalog_url": "https://www.metro.ca/epicerie-en-ligne",
9 + "tech": "HTML rendu serveur derrière Cloudflare — Scrapfly (ASP)",
10 + "connector": "metro",
11 + "status": "actif",
12 + "region": "Tout le Québec"
13 + },
14 + {
15 + "id": "superc",
16 + "name": "Super C",
17 + "url": "https://www.superc.ca",
18 + "catalog_url": "https://www.superc.ca/allees/fruits-et-legumes",
19 + "tech": "Même plateforme que Metro — Scrapfly (ASP)",
20 + "connector": "superc",
21 + "status": "actif",
22 + "region": "Tout le Québec"
23 + },
24 + {
25 + "id": "iga",
26 + "name": "IGA (Voilà)",
27 + "url": "https://www.iga.net",
28 + "catalog_url": "https://voila.ca",
29 + "tech": "SPA React (plateforme Voilà/Sobeys) — Scrapfly avec rendu JavaScript",
30 + "connector": "iga",
31 + "status": "actif",
32 + "region": "Tout le Québec"
33 + },
34 + {
35 + "id": "maxi",
36 + "name": "Maxi",
37 + "url": "https://www.maxi.ca",
38 + "catalog_url": "https://www.maxi.ca/fr/alimentation/c/27985",
39 + "tech": "Next.js (__NEXT_DATA__) derrière Akamai — Scrapfly (ASP)",
40 + "connector": "maxi",
41 + "status": "actif",
42 + "region": "Tout le Québec"
43 + },
44 + {
45 + "id": "provigo",
46 + "name": "Provigo",
47 + "url": "https://www.provigo.ca",
48 + "catalog_url": "https://www.provigo.ca/fr/alimentation/c/27985",
49 + "tech": "Next.js (__NEXT_DATA__) derrière Akamai — Scrapfly (ASP)",
50 + "connector": "provigo",
51 + "status": "actif",
52 + "region": "Tout le Québec"
53 + },
54 + {
55 + "id": "club_entrepot",
56 + "name": "Club Entrepôt",
57 + "url": "https://www.wholesaleclub.ca",
58 + "catalog_url": "https://www.wholesaleclub.ca/fr/alimentation/c/27985",
59 + "tech": "Next.js (__NEXT_DATA__) derrière Akamai — Scrapfly (ASP)",
60 + "connector": "club_entrepot",
61 + "status": "actif",
62 + "region": "Québec (grossiste Loblaw)"
63 + },
64 + {
65 + "id": "pa",
66 + "name": "PA Supermarché",
67 + "url": "https://www.supermarchepa.com",
68 + "catalog_url": "https://www.supermarchepa.com/collections",
69 + "tech": "Shopify — /products.json ouvert",
70 + "connector": "pa",
71 + "status": "actif",
72 + "region": "Montréal"
73 + },
74 + {
75 + "id": "giant_tiger",
76 + "name": "Giant Tiger",
77 + "url": "https://www.gianttiger.com",
78 + "catalog_url": "https://www.gianttiger.com/collections",
79 + "tech": "Shopify — /products.json ouvert",
80 + "connector": "giant_tiger",
81 + "status": "actif",
82 + "region": "Québec et Canada"
83 + },
84 + {
85 + "id": "epipresto",
86 + "name": "Epipresto",
87 + "url": "https://epipresto.ca",
88 + "catalog_url": "https://epipresto.ca/collections",
89 + "tech": "Shopify — /products.json ouvert",
90 + "connector": "epipresto",
91 + "status": "actif",
92 + "region": "Grand Montréal"
93 + },
94 + {
95 + "id": "nuvo",
96 + "name": "Marché Nuvo",
97 + "url": "https://marchenuvo.ca",
98 + "catalog_url": "https://marchenuvo.ca/collections",
99 + "tech": "Shopify — /products.json ouvert",
100 + "connector": "nuvo",
101 + "status": "actif",
102 + "region": "Grand Montréal"
103 + },
104 + {
105 + "id": "boite_a_grains",
106 + "name": "La Boîte à Grains",
107 + "url": "https://laboiteagrains.com",
108 + "catalog_url": "https://laboiteagrains.com/collections",
109 + "tech": "Shopify — /products.json ouvert",
110 + "connector": "boite_a_grains",
111 + "status": "actif",
112 + "region": "Outaouais"
113 + },
114 + {
115 + "id": "bocoboco",
116 + "name": "BocoBoco",
117 + "url": "https://bocoboco.ca",
118 + "catalog_url": "https://bocoboco.ca/marche/",
119 + "tech": "WooCommerce — Store API ouverte",
120 + "connector": "bocoboco",
121 + "status": "actif",
122 + "region": "Grand Montréal"
123 + },
124 + {
125 + "id": "aliments_merci",
126 + "name": "Aliments Merci",
127 + "url": "https://alimentsmerci.com",
128 + "catalog_url": "https://alimentsmerci.com/epicerie-en-ligne/",
129 + "tech": "WooCommerce — Store API ouverte",
130 + "connector": "aliments_merci",
131 + "status": "actif",
132 + "region": "Grand Montréal"
133 + },
134 + {
135 + "id": "mayrand",
136 + "name": "Mayrand",
137 + "url": "https://mayrand.ca",
138 + "catalog_url": "https://mayrand.ca/fr/nos-produits/",
139 + "tech": "HubSpot CMS — HTML statique avec prix",
140 + "connector": "mayrand",
141 + "status": "actif",
142 + "region": "Grand Montréal"
143 + },
144 + {
145 + "id": "aubut",
146 + "name": "Aubut",
147 + "url": "https://www.aubut.ca",
148 + "catalog_url": "https://www.aubut.ca/produits",
149 + "tech": "Site sur mesure — HTML avec prix sans connexion",
150 + "connector": "aubut",
151 + "status": "actif",
152 + "region": "Montréal et Laval"
153 + },
154 + {
155 + "id": "maturin",
156 + "name": "Maturin",
157 + "url": "https://maturin.ca",
158 + "catalog_url": "https://maturin.ca",
159 + "tech": "SSR sur mesure — HTML avec prix",
160 + "connector": "maturin",
161 + "status": "actif",
162 + "region": "Tout le Québec (producteurs d'ici)"
163 + },
164 + {
165 + "id": "avril",
166 + "name": "Avril Supermarché Santé",
167 + "url": "https://avril.ca",
168 + "catalog_url": "https://avril.ca/fr/epicerie.html",
169 + "tech": "Magento 2 — prix rendus serveur",
170 + "connector": "avril",
171 + "status": "actif",
172 + "region": "Québec, Montréal, Estrie…"
173 + },
174 + {
175 + "id": "tau",
176 + "name": "Marché Tau",
177 + "url": "https://marchestau.com",
178 + "catalog_url": "https://marchestau.com/fruits-legumes-bio",
179 + "tech": "k-eCommerce — fiches produit rendues serveur (sitemap)",
180 + "connector": "tau",
181 + "status": "actif",
182 + "region": "Grand Montréal"
183 + },
184 + {
185 + "id": "walmart",
186 + "name": "Walmart Canada",
187 + "url": "https://www.walmart.ca",
188 + "catalog_url": "https://www.walmart.ca/fr/browse/epicerie/10019",
189 + "tech": "Next.js derrière Akamai + PerimeterX — Scrapfly (ASP)",
190 + "connector": "walmart",
191 + "status": "actif",
192 + "region": "Tout le Québec"
193 + },
194 + {
195 + "id": "costco",
196 + "name": "Costco Canada",
197 + "url": "https://www.costco.ca",
198 + "catalog_url": "https://www.costco.ca/grocery-household.html",
199 + "tech": "Next.js derrière Akamai — Scrapfly avec rendu JavaScript",
200 + "connector": "costco",
201 + "status": "actif",
202 + "region": "Tout le Québec (livraison)",
203 + "notes": "Épicerie non périssable livrée seulement — le frais est « prix en entrepôt » sans prix en ligne."
204 + },
205 + {
206 + "id": "adonis",
207 + "name": "Marché Adonis",
208 + "url": "https://www.groupeadonis.ca",
209 + "catalog_url": "https://livraison.groupeadonis.ca/store/adonis/storefront",
210 + "tech": "Marque blanche Instacart — Scrapfly avec rendu JavaScript",
211 + "connector": "adonis",
212 + "status": "actif",
213 + "region": "Grand Montréal et Québec",
214 + "notes": "Prix du magasin par défaut Instacart (livraison Montréal)."
215 + },
216 + {
217 + "id": "rachelle_bery",
218 + "name": "Rachelle-Béry",
219 + "url": "https://www.rachellebery.ca",
220 + "catalog_url": null,
221 + "tech": "Site vitrine sans prix (bannière Sobeys)",
222 + "connector": null,
223 + "status": "non connectable",
224 + "region": "Montréal et environs",
225 + "notes": "Aucun catalogue en ligne avec prix : l'épicerie en ligne passe par Voilà (couverte via IGA) et Instacart."
226 + },
227 + {
228 + "id": "kim_phat",
229 + "name": "Kim Phat",
230 + "url": "https://www.kimphat.com",
231 + "catalog_url": null,
232 + "tech": "Site vitrine",
233 + "connector": null,
234 + "status": "non connectable",
235 + "region": "Grand Montréal",
236 + "notes": "Commande en ligne déléguée à Voilà — aucun catalogue propre avec prix."
237 + },
238 + {
239 + "id": "bulk_barn",
240 + "name": "Bulk Barn",
241 + "url": "https://www.bulkbarn.ca",
242 + "catalog_url": null,
243 + "tech": "Catalogue sans prix",
244 + "connector": null,
245 + "status": "non connectable",
246 + "region": "Tout le Québec",
247 + "notes": "Le catalogue public n'affiche aucun prix ; commande seulement via Instacart."
248 + },
249 + {
250 + "id": "dollarama",
251 + "name": "Dollarama",
252 + "url": "https://shop.dollarama.com",
253 + "catalog_url": null,
254 + "tech": "Marque blanche DoorDash — prix par magasin après session",
255 + "connector": null,
256 + "status": "non connectable",
257 + "region": "Tout le Québec",
258 + "notes": "Le rendu public affiche des prix à 0,00 $ (placeholders DoorDash) — aucun prix réel sans session."
259 + },
260 + {
261 + "id": "lufa",
262 + "name": "Fermes Lufa",
263 + "url": "https://montreal.lufa.com",
264 + "catalog_url": "https://montreal.lufa.com/fr/marche",
265 + "tech": "Catalogue lié à une session client",
266 + "connector": null,
267 + "status": "non connectable",
268 + "region": "Grand Montréal",
269 + "notes": "Le marché complet exige un compte (endpoints /superMarket/* de session) — à réévaluer."
270 + },
271 + {
272 + "id": "loco",
273 + "name": "Épiceries LOCO",
274 + "url": "https://epicerieloco.ca",
275 + "catalog_url": "https://epicerie-loco-en-ligne.square.site",
276 + "tech": "Square Online (SPA) — Scrapfly avec rendu JavaScript",
277 + "connector": "loco",
278 + "status": "actif",
279 + "region": "Montréal (zéro déchet)"
280 + },
281 + {
282 + "id": "akhavan",
283 + "name": "Akhavan",
284 + "url": "https://akhavanfood.com",
285 + "catalog_url": "https://akhavanfood.com/shop/",
286 + "tech": "WooCommerce — Store API ouverte",
287 + "connector": "akhavan",
288 + "status": "actif",
289 + "region": "Montréal (épicerie moyen-orientale)"
290 + },
291 + {
292 + "id": "tt",
293 + "name": "T&T Supermarket",
294 + "url": "https://www.tntsupermarket.com",
295 + "catalog_url": "https://www.tntsupermarket.com/eng/",
296 + "tech": "Magento GraphQL derrière Akamai — Scrapfly (ASP)",
297 + "connector": "tt",
298 + "status": "actif",
299 + "region": "Montréal (Saint-Laurent)"
300 + },
301 + {
302 + "id": "frenco",
303 + "name": "Frenco",
304 + "url": "https://frenco.ca",
305 + "catalog_url": null,
306 + "tech": "Vitrine GoDaddy — API boutique en 403",
307 + "connector": null,
308 + "status": "non connectable",
309 + "region": "Montréal (vrac)",
310 + "notes": "L'API GoDaddy de la boutique refuse toute requête publique (403) et la page rendue n'affiche aucun prix."
311 + }
312 + ]
313 +}
\ No newline at end of file
added foodka/__init__.py +7 −0
@@ -0,0 +1,7 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# -----------------------------------------------------------------------------
5 +"""Food-Ka : tous les produits d'épicerie du Québec, un seul endroit."""
6 +
7 +__version__ = "1.0.0"
added foodka/connectors/__init__.py +30 −0
@@ -0,0 +1,30 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (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__"):
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"[food-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 foodka/connectors/_loblaw.py +134 −0
@@ -0,0 +1,134 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_loblaw.py : socle commun des bannières Loblaw (Maxi, Provigo)
5 +# Les sites Loblaw (Next.js) embarquent la grille produits complète dans
6 +# <script id="__NEXT_DATA__"> ; on la lit derrière l'anti-bot via Scrapfly.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import json
11 +import re
12 +
13 +from ..schema import Product, parse_price
14 +from .base import BaseConnector
15 +
16 +_NEXT_RE = re.compile(
17 + r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)
18 +
19 +# sous-catégories standard du réseau Loblaw : (slug, code, catégorie canonique)
20 +CATEGORIES = [
21 + ("fruits-et-legumes", 28000, "Fruits et légumes"),
22 + ("viandes", 27998, "Viandes et volailles"),
23 + ("poissons-et-fruits-de-mer", 28004, "Poissons et fruits de mer"),
24 + ("charcuteries-et-fromages", 28131, "Charcuteries et fromages"),
25 + ("produits-laitiers-et-oeufs", 28008, "Produits laitiers et œufs"),
26 + ("boulangerie", 28020, "Boulangerie"),
27 + ("surgeles", 28195, "Surgelés"),
28 + ("garde-manger", 28016, "Garde-manger"),
29 + ("boissons", 28051, "Boissons"),
30 +]
31 +
32 +
33 +class LoblawConnector(BaseConnector):
34 + """Base des bannières Loblaw — sous-classes : définir source_id et domain."""
35 +
36 + domain: str = "" # ex. "www.maxi.ca"
37 + pages_per_category: int = 2 # 48 produits/page ; ~860 produits/sync
38 +
39 + def _next_data(self, url: str) -> dict | None:
40 + html = self.get_scrapfly(url)
41 + m = _NEXT_RE.search(html)
42 + return json.loads(m.group(1)) if m else None
43 +
44 + def _tiles(self, data: dict) -> tuple[list[dict], bool]:
45 + """(tuiles produit, hasMore) — collecte récursive : selon la page
46 + (racine ou sous-catégorie), Loblaw embarque une ou plusieurs grilles
47 + `productTiles`, avec ou sans bloc `pagination`."""
48 + tiles: list[dict] = []
49 + has_more = False
50 +
51 + def walk(o):
52 + nonlocal has_more
53 + if isinstance(o, dict):
54 + if "productTiles" in o:
55 + tiles.extend(o.get("productTiles") or [])
56 + if (o.get("pagination") or {}).get("hasMore"):
57 + has_more = True
58 + return
59 + for v in o.values():
60 + walk(v)
61 + elif isinstance(o, list):
62 + for v in o:
63 + walk(v)
64 +
65 + try:
66 + walk(data["props"]["pageProps"]["initialData"]["layout"]["sections"])
67 + except (KeyError, TypeError):
68 + pass
69 + return tiles, has_more
70 +
71 + def _tile_to_product(self, tile: dict, category: str,
72 + category_raw: str) -> Product | None:
73 + pid = tile.get("productId")
74 + if not pid:
75 + return None
76 + pricing = tile.get("pricing") or {}
77 + price = parse_price(pricing.get("price"))
78 + was = parse_price(pricing.get("wasPrice"))
79 + images = [img.get("largeUrl") or img.get("imageUrl")
80 + for img in tile.get("productImage") or [] if isinstance(img, dict)]
81 + link = tile.get("link") or ""
82 + badges = []
83 + deal = tile.get("deal") or {}
84 + if isinstance(deal, dict) and deal.get("name"):
85 + badges.append(str(deal["name"]))
86 + return Product(
87 + source=self.source_id,
88 + external_id=str(pid),
89 + url=f"https://{self.domain}{link}" if link.startswith("/") else link,
90 + name=tile.get("title") or "",
91 + brand=tile.get("brand") or "",
92 + category=category,
93 + category_raw=category_raw,
94 + size_label=tile.get("packageSizing") or "",
95 + price=price,
96 + regular_price=was,
97 + price_label=pricing.get("displayPrice") or "",
98 + on_sale=bool(was and price and was > price),
99 + in_stock=None,
100 + keywords=badges,
101 + details={"article": tile.get("articleNumber"),
102 + "uom": tile.get("uom")},
103 + images=[i for i in images if i],
104 + )
105 +
106 + def fetch(self) -> list[Product]:
107 + products: list[Product] = []
108 + seen: set[str] = set()
109 + for slug, code, category in CATEGORIES:
110 + for page in range(1, self.pages_per_category + 1):
111 + url = f"https://{self.domain}/fr/{slug}/c/{code}?page={page}"
112 + try:
113 + data = self._next_data(url)
114 + except Exception: # une catégorie qui casse ne bloque pas le reste
115 + break
116 + if not data:
117 + break
118 + tiles, has_more = self._tiles(data)
119 + new = 0
120 + for tile in tiles:
121 + try:
122 + prod = self._tile_to_product(tile, category,
123 + slug.replace("-", " "))
124 + except Exception:
125 + continue
126 + if prod and prod.uid not in seen:
127 + seen.add(prod.uid)
128 + products.append(prod)
129 + new += 1
130 + # sans bloc pagination : on s'arrête dès qu'une page ne
131 + # rapporte plus de nouveaux produits
132 + if not has_more and new == 0:
133 + break
134 + return products
added foodka/connectors/_metro.py +113 −0
@@ -0,0 +1,113 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_metro.py : socle commun Metro / Super C (même plateforme)
5 +# Tuiles produit rendues côté serveur (data-product-code) mais site derrière
6 +# un anti-bot agressif -> Scrapfly (ASP). Pagination : « <allée>-page-N ».
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import re
11 +
12 +from bs4 import BeautifulSoup
13 +
14 +from ..schema import Product, parse_price
15 +from .base import BaseConnector
16 +
17 +_PER_KG_RE = re.compile(r"(\d+(?:,\d+)?)\s*\$\s*/\s*kg")
18 +
19 +# (slug d'allée, catégorie canonique)
20 +AISLES = [
21 + ("fruits-et-legumes", "Fruits et légumes"),
22 + ("viandes-et-volailles", "Viandes et volailles"),
23 + ("poissons-et-fruits-de-mer", "Poissons et fruits de mer"),
24 + ("produits-laitiers-et-oeufs", "Produits laitiers et œufs"),
25 + ("pains-et-patisseries", "Boulangerie"),
26 + ("produits-surgeles", "Surgelés"),
27 + ("garde-manger", "Garde-manger"),
28 + ("boissons", "Boissons"),
29 + ("collations", "Collations et confiseries"),
30 +]
31 +
32 +
33 +class MetroPlatformConnector(BaseConnector):
34 + """Base Metro/Super C — sous-classes : définir source_id et domain."""
35 +
36 + domain: str = "" # ex. "www.metro.ca"
37 + aisle_prefix: str = "/epicerie-en-ligne/allees" # superc : "/allees"
38 + pages_per_aisle: int = 2 # ~45 produits/page
39 +
40 + def _parse_tiles(self, html: str, category: str) -> list[Product]:
41 + soup = BeautifulSoup(html, "html.parser")
42 + products: list[Product] = []
43 + for tile in soup.select("[data-product-code]"):
44 + code = tile.get("data-product-code")
45 + name = tile.get("data-product-name") or ""
46 + if not code or not name:
47 + continue
48 + link_el = tile.select_one("a.product-details-link[href]")
49 + link = link_el["href"] if link_el else ""
50 + img_el = tile.select_one(".pt__visual img[src]")
51 + image = (img_el["src"] if img_el else "") or ""
52 + brand_el = tile.select_one(".head__brand")
53 + size_el = tile.select_one(".head__unit-details")
54 + price_el = tile.select_one(".price-update")
55 + before_el = tile.select_one(".pricing__before-price")
56 + unit_el = tile.select_one(".pricing__secondary-price")
57 + price_label = price_el.get_text(" ", strip=True) if price_el else ""
58 + unit_label = unit_el.get_text(" ", strip=True) if unit_el else ""
59 + before_txt = before_el.get_text(" ", strip=True) if before_el else ""
60 + price = None
61 + regular = parse_price(before_txt) if before_txt else None
62 + # Produit au poids : le prix affiché est une estimation à l'unité
63 + # mais le « prix régulier » est au kg — on compare tout au kg.
64 + if "kg" in before_txt:
65 + m_cur = _PER_KG_RE.search(unit_label)
66 + m_reg = _PER_KG_RE.search(before_txt)
67 + if m_cur:
68 + price = float(m_cur.group(1).replace(",", "."))
69 + price_label = f"{m_cur.group(1)} $ / kg"
70 + regular = (float(m_reg.group(1).replace(",", "."))
71 + if m_reg else None)
72 + else: # pas de prix au kg comparable : ne rien inventer
73 + regular = None
74 + on_sale = bool(tile.select_one(".pi-price-promo, .promo-price")) or regular is not None
75 + products.append(Product(
76 + source=self.source_id,
77 + external_id=str(code),
78 + url=(f"https://{self.domain}{link}" if link.startswith("/") else link),
79 + name=name,
80 + brand=brand_el.get_text(strip=True) if brand_el else "",
81 + category=category,
82 + category_raw=tile.get("data-product-category") or "",
83 + size_label=size_el.get_text(" ", strip=True) if size_el else "",
84 + price=price,
85 + price_label=price_label,
86 + regular_price=regular,
87 + on_sale=on_sale,
88 + unit_price_label=unit_label,
89 + images=[image] if image.startswith("http") else [],
90 + ))
91 + return products
92 +
93 + def fetch(self) -> list[Product]:
94 + products: list[Product] = []
95 + seen: set[str] = set()
96 + for slug, category in AISLES:
97 + for page in range(1, self.pages_per_aisle + 1):
98 + suffix = slug if page == 1 else f"{slug}-page-{page}"
99 + url = f"https://{self.domain}{self.aisle_prefix}/{suffix}"
100 + try:
101 + html = self.get_scrapfly(url)
102 + except Exception: # une allée qui casse ne bloque pas le reste
103 + break
104 + batch = self._parse_tiles(html, category)
105 + new = 0
106 + for prod in batch:
107 + if prod.uid not in seen:
108 + seen.add(prod.uid)
109 + products.append(prod)
110 + new += 1
111 + if new == 0: # page vide ou répétée : allée épuisée
112 + break
113 + return products
added foodka/connectors/_shopify.py +138 −0
@@ -0,0 +1,138 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_shopify.py : socle commun des épiceries sur Shopify
5 +# Toute boutique Shopify expose son catalogue public en JSON :
6 +# /products.json?limit=250&page=N (ou /collections/{handle}/products.json
7 +# pour cibler des rayons précis). Aucun anti-bot, requests direct suffit.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import html
12 +import re
13 +
14 +from ..schema import Product, normalize_category
15 +from .base import BaseConnector
16 +
17 +_TAG_RE = re.compile(r"<[^>]+>")
18 +
19 +
20 +def _strip_html(text: str | None, max_len: int = 500) -> str:
21 + """Retire les balises HTML d'un body_html Shopify et tronque (~500 car.)."""
22 + if not text:
23 + return ""
24 + clean = html.unescape(_TAG_RE.sub(" ", text))
25 + clean = re.sub(r"\s+", " ", clean).strip()
26 + if len(clean) > max_len:
27 + clean = clean[:max_len].rsplit(" ", 1)[0] + "…"
28 + return clean
29 +
30 +
31 +class ShopifyConnector(BaseConnector):
32 + """Base des épiceries Shopify — sous-classes : définir source_id et domain.
33 +
34 + Par défaut on pagine /products.json (catalogue complet). Si le champ
35 + `product_type` de la boutique est vide ou inutilisable, une sous-classe
36 + peut définir `collections` : liste de (handle, catégorie canonique) —
37 + on pagine alors /collections/{handle}/products.json à la place.
38 + Une catégorie canonique vide ("") signifie : déduire du product_type.
39 + """
40 +
41 + domain: str = "" # ex. "www.supermarchepa.com"
42 + max_pages: int = 8 # 250 produits/page
43 + collections: list[tuple[str, str]] = [] # [(handle, catégorie canonique)]
44 +
45 + # -- récupération -----------------------------------------------------------
46 + def _page(self, url: str) -> list[dict]:
47 + """Une page de produits JSON ; liste vide si la page n'existe pas."""
48 + resp = self.get(url, headers={"Accept": "application/json"})
49 + data = resp.json()
50 + return data.get("products") or []
51 +
52 + # -- mapping Shopify -> Product ----------------------------------------------
53 + def _pick_variant(self, product: dict) -> dict:
54 + """Première variante disponible, sinon la première tout court."""
55 + variants = product.get("variants") or []
56 + for v in variants:
57 + if v.get("available"):
58 + return v
59 + return variants[0] if variants else {}
60 +
61 + def _to_product(self, product: dict, category: str = "") -> Product | None:
62 + pid = product.get("id")
63 + handle = product.get("handle") or ""
64 + if not pid or not handle:
65 + return None
66 + variant = self._pick_variant(product)
67 +
68 + # prix : la variante porte le prix courant ; compare_at_price est le
69 + # prix régulier quand le produit est en solde.
70 + try:
71 + price = float(variant.get("price")) if variant.get("price") else None
72 + except (TypeError, ValueError):
73 + price = None
74 + regular = None
75 + try:
76 + cap = variant.get("compare_at_price")
77 + if cap and price is not None and float(cap) > price:
78 + regular = float(cap)
79 + except (TypeError, ValueError):
80 + regular = None
81 +
82 + # format : titre de variante s'il est réel, sinon le poids en grammes
83 + size_label = ""
84 + vtitle = variant.get("title") or ""
85 + if vtitle and vtitle != "Default Title":
86 + size_label = vtitle
87 + elif variant.get("grams"):
88 + size_label = f"{variant['grams']} g"
89 +
90 + tags = [str(t) for t in product.get("tags") or [] if t]
91 + category_raw = product.get("product_type") or " ".join(tags)
92 +
93 + return Product(
94 + source=self.source_id,
95 + external_id=str(pid),
96 + url=f"https://{self.domain}/products/{handle}",
97 + name=html.unescape(product.get("title") or ""),
98 + brand=product.get("vendor") or "",
99 + category=category or normalize_category(category_raw),
100 + category_raw=category_raw,
101 + size_label=size_label,
102 + price=price,
103 + regular_price=regular,
104 + on_sale=bool(regular is not None),
105 + in_stock=(bool(variant["available"])
106 + if variant.get("available") is not None else None),
107 + description=_strip_html(product.get("body_html")),
108 + keywords=tags,
109 + images=[img["src"] for img in product.get("images") or []
110 + if isinstance(img, dict) and img.get("src")],
111 + )
112 +
113 + # -- contrat ------------------------------------------------------------------
114 + def fetch(self) -> list[Product]:
115 + products: list[Product] = []
116 + seen: set[str] = set()
117 + # sources à paginer : catalogue complet, ou collections ciblées
118 + sources: list[tuple[str, str]] = (
119 + [(f"https://{self.domain}/collections/{handle}/products.json", cat)
120 + for handle, cat in self.collections]
121 + or [(f"https://{self.domain}/products.json", "")])
122 + for base_url, category in sources:
123 + for page in range(1, self.max_pages + 1):
124 + try:
125 + batch = self._page(f"{base_url}?limit=250&page={page}")
126 + except Exception: # une collection qui casse ne bloque pas le reste
127 + break
128 + if not batch:
129 + break
130 + for raw in batch:
131 + try:
132 + prod = self._to_product(raw, category)
133 + except Exception:
134 + continue
135 + if prod and prod.name and prod.uid not in seen:
136 + seen.add(prod.uid)
137 + products.append(prod)
138 + return products
added foodka/connectors/_woocommerce.py +125 −0
@@ -0,0 +1,125 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_woocommerce.py : socle commun des épiceries sur WooCommerce
5 +# L'API publique "Store API" expose le catalogue en JSON sans clé :
6 +# /wp-json/wc/store/v1/products?per_page=100&page=N. Les prix y sont en
7 +# unités mineures (cents) — "1299" + currency_minor_unit=2 -> 12,99 $.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import html
12 +import re
13 +
14 +from ..schema import Product, normalize_category
15 +from .base import BaseConnector
16 +
17 +_TAG_RE = re.compile(r"<[^>]+>")
18 +
19 +# format dans le nom du produit : "Yogourt nature (650 g)", "Huile 2 x 750 ml"
20 +_SIZE_IN_NAME_RE = re.compile(
21 + r"(\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|mg|lb|lbs|oz|ml|cl|l|un|unités?)\b",
22 + re.IGNORECASE)
23 +
24 +
25 +def _strip_html(text: str | None, max_len: int = 500) -> str:
26 + """Retire balises et entités HTML d'un champ WooCommerce, tronque (~500)."""
27 + if not text:
28 + return ""
29 + clean = html.unescape(_TAG_RE.sub(" ", text))
30 + clean = re.sub(r"\s+", " ", clean).strip()
31 + if len(clean) > max_len:
32 + clean = clean[:max_len].rsplit(" ", 1)[0] + "…"
33 + return clean
34 +
35 +
36 +def _minor(value: str | int | None, minor_unit: int) -> float | None:
37 + """Convertit un prix Store API (unités mineures) en dollars : "1299" -> 12.99."""
38 + if value in (None, ""):
39 + return None
40 + try:
41 + return round(int(value) / (10 ** minor_unit), 2)
42 + except (TypeError, ValueError):
43 + return None
44 +
45 +
46 +class WooStoreConnector(BaseConnector):
47 + """Base des épiceries WooCommerce — sous-classes : source_id et domain."""
48 +
49 + domain: str = "" # ex. "bocoboco.ca"
50 + max_pages: int = 8 # 100 produits/page
51 + per_page: int = 100
52 +
53 + def _page(self, page: int) -> list[dict]:
54 + url = (f"https://{self.domain}/wp-json/wc/store/v1/products"
55 + f"?per_page={self.per_page}&page={page}")
56 + resp = self.get(url, headers={"Accept": "application/json"})
57 + data = resp.json()
58 + return data if isinstance(data, list) else []
59 +
60 + def _to_product(self, raw: dict) -> Product | None:
61 + pid = raw.get("id")
62 + name = html.unescape(_TAG_RE.sub("", raw.get("name") or "")).strip()
63 + if not pid or not name:
64 + return None
65 +
66 + prices = raw.get("prices") or {}
67 + minor_unit = int(prices.get("currency_minor_unit") or 2)
68 + price = _minor(prices.get("price"), minor_unit)
69 + on_sale = bool(raw.get("on_sale"))
70 + regular = _minor(prices.get("regular_price"), minor_unit) if on_sale else None
71 + if regular is not None and price is not None and regular <= price:
72 + regular = None # solde annoncé mais prix identiques : pas un rabais
73 +
74 + # taxonomie : première catégorie = catégorie brute, les autres en mots-clés
75 + cats = [c.get("name") or "" for c in raw.get("categories") or []
76 + if isinstance(c, dict)]
77 + cats = [html.unescape(c).strip() for c in cats if c]
78 + category_raw = cats[0] if cats else ""
79 + keywords = cats[1:]
80 +
81 + # format : extrait du nom s'il y figure (ex. "Tofu ferme 454 g")
82 + m = _SIZE_IN_NAME_RE.search(name)
83 + size_label = m.group(0).strip() if m else ""
84 +
85 + return Product(
86 + source=self.source_id,
87 + external_id=str(pid),
88 + url=raw.get("permalink") or f"https://{self.domain}/?p={pid}",
89 + name=name,
90 + brand="",
91 + category=normalize_category(category_raw),
92 + category_raw=category_raw,
93 + size_label=size_label,
94 + price=price,
95 + regular_price=regular,
96 + on_sale=on_sale and regular is not None,
97 + in_stock=(bool(raw["is_in_stock"])
98 + if raw.get("is_in_stock") is not None else None),
99 + description=_strip_html(raw.get("short_description")
100 + or raw.get("description")),
101 + keywords=keywords,
102 + images=[img["src"] for img in raw.get("images") or []
103 + if isinstance(img, dict) and img.get("src")],
104 + )
105 +
106 + # -- contrat ------------------------------------------------------------------
107 + def fetch(self) -> list[Product]:
108 + products: list[Product] = []
109 + seen: set[str] = set()
110 + for page in range(1, self.max_pages + 1):
111 + try:
112 + batch = self._page(page)
113 + except Exception: # fin de pagination ou site en dérangement
114 + break
115 + if not batch:
116 + break
117 + for raw in batch:
118 + try:
119 + prod = self._to_product(raw)
120 + except Exception:
121 + continue
122 + if prod and prod.uid not in seen:
123 + seen.add(prod.uid)
124 + products.append(prod)
125 + return products
added foodka/connectors/adonis.py +118 −0
@@ -0,0 +1,118 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Adonis (livraison.groupeadonis.ca)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique Instacart en marque blanche (SPA React) : rendu JavaScript via
5 +# Scrapfly + attente des cartes produit a[data-item-card-button]. Les prix
6 +# s'affichent sans session (adresse de livraison par défaut d'Instacart) —
7 +# prix courant et prix d'origine exposés en texte lecteur d'écran
8 +# (« Current price: … » / « Original Price: … »).
9 +# Collections : /store/adonis/collections/{slug} (~20 produits rendus/page).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Product, parse_price
18 +from .base import BaseConnector
19 +
20 +_ID_RE = re.compile(r"/products/(\d+)")
21 +_CURRENT_RE = re.compile(r"Current price:\s*(.+)", re.I)
22 +_ORIGINAL_RE = re.compile(r"Original Price:\s*(.+)", re.I)
23 +
24 +# collections Instacart d'Adonis : (slug, catégorie canonique)
25 +COLLECTIONS = [
26 + ("n-produce-56955", "Fruits et légumes"),
27 + ("n-meat-seafood-98116", "Viandes et volailles"),
28 + ("n-dairy-eggs-49119", "Produits laitiers et œufs"),
29 + ("n-deli-35601", "Charcuteries et fromages"),
30 + ("n-bakery-91773", "Boulangerie"),
31 + ("n-frozen-44158", "Surgelés"),
32 + ("n-dry-goods-pasta-75048", "Garde-manger"),
33 + ("n-canned-goods-soups-12716", "Garde-manger"),
34 + ("n-beverages-8671", "Boissons"),
35 + ("n-snacks-candy-4336", "Collations et confiseries"),
36 + ("n-prepared-foods-84844", "Prêt-à-manger"),
37 +]
38 +
39 +
40 +class AdonisConnector(BaseConnector):
41 + source_id = "adonis"
42 + request_delay = 1.0
43 +
44 + # -- extraction d'une collection --------------------------------------------
45 + def _parse(self, html: str, category: str, slug: str) -> list[Product]:
46 + soup = BeautifulSoup(html, "html.parser")
47 + products: list[Product] = []
48 + for card in soup.select("a[data-item-card-button][href]"):
49 + href = card["href"]
50 + m = _ID_RE.search(href)
51 + title = card.select_one("h3")
52 + if not m or title is None:
53 + continue
54 + name = title.get_text(" ", strip=True)
55 + if not name:
56 + continue
57 + # prix : textes lecteur d'écran, robustes face aux classes minifiées
58 + text = card.get_text("\n", strip=True)
59 + cur_m = _CURRENT_RE.search(text)
60 + orig_m = _ORIGINAL_RE.search(text)
61 + price_label = cur_m.group(1).strip() if cur_m else ""
62 + price = parse_price(price_label)
63 + regular = parse_price(orig_m.group(1).strip()) if orig_m else None
64 + # format / prix unitaire : bloc qui suit le titre
65 + size_label, unit_label = "", ""
66 + after = title.find_next_sibling("div")
67 + if after is not None:
68 + after_text = after.get_text(" ", strip=True)
69 + if "$" in after_text:
70 + unit_label = after_text
71 + else:
72 + size_label = after_text
73 + # produits vendus au poids : le prix affiché est par kilogramme
74 + if not size_label and "per kilogram" in price_label.lower():
75 + size_label = "1 kg"
76 + in_stock: bool | None = None
77 + low = text.lower()
78 + if "out of stock" in low:
79 + in_stock = False
80 + elif "in stock" in low:
81 + in_stock = True
82 + img = card.select_one('img[data-testid="item-card-image"][src]')
83 + products.append(Product(
84 + source=self.source_id,
85 + external_id=m.group(1),
86 + url=(f"https://livraison.groupeadonis.ca{href}"
87 + if href.startswith("/") else href),
88 + name=name,
89 + category=category,
90 + category_raw=slug,
91 + size_label=size_label,
92 + price=price,
93 + regular_price=regular,
94 + price_label=price_label,
95 + on_sale=regular is not None,
96 + unit_price_label=unit_label,
97 + in_stock=in_stock,
98 + images=[img["src"]] if img is not None else [],
99 + ))
100 + return products
101 +
102 + # -- contrat ---------------------------------------------------------------
103 + def fetch(self) -> list[Product]:
104 + products: list[Product] = []
105 + seen: set[str] = set()
106 + for slug, category in COLLECTIONS:
107 + url = f"https://livraison.groupeadonis.ca/store/adonis/collections/{slug}"
108 + try:
109 + html = self.get_scrapfly(
110 + url, render_js=True,
111 + wait_for_selector="a[data-item-card-button]")
112 + except Exception: # une collection qui casse ne bloque pas le reste
113 + continue
114 + for prod in self._parse(html, category, slug):
115 + if prod.uid not in seen:
116 + seen.add(prod.uid)
117 + products.append(prod)
118 + return products
added foodka/connectors/akhavan.py +113 −0
@@ -0,0 +1,113 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Akhavan (akhavanfood.com — supermarché moyen-oriental,
3 +# Montréal / NDG)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# WooCommerce : l'API Store publique (/wp-json/wc/store/v1/products) répond
6 +# sans authentification — noms, prix (en cents, minor units), solde, stock,
7 +# catégories et images. ~700 produits au total ; on pagine par 100 avec un
8 +# plafond max_pages pour garder la synchro modeste. Aucun rendu JS requis.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as _html
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Product, normalize_category
18 +
19 +BASE = "https://akhavanfood.com"
20 +API = f"{BASE}/wp-json/wc/store/v1/products"
21 +
22 +from .base import BaseConnector
23 +
24 +# format dans le nom : « Mix Shoor – Kambiz – 670 g (شور مخلوط) » -> « 670 g »
25 +_SIZE_IN_NAME_RE = re.compile(
26 + r"(\d+(?:[.,]\d+)?)\s*(kg|g|gr|ml|l|lb|lbs|oz)\b", re.IGNORECASE)
27 +
28 +
29 +def _minor(value: str | None, unit: int) -> float | None:
30 + """Prix WooCommerce Store API : chaîne en unités mineures (« 599 » = 5,99 $)."""
31 + if not value:
32 + return None
33 + try:
34 + price = int(value) / (10 ** unit)
35 + except ValueError:
36 + return None
37 + return price if price > 0 else None
38 +
39 +
40 +class AkhavanConnector(BaseConnector):
41 + source_id = "akhavan"
42 +
43 + per_page: int = 100
44 + max_pages: int = 4 # 4 x 100 = ~400 produits par synchro
45 +
46 + def _parse_item(self, item: dict) -> Product | None:
47 + name = _html.unescape(item.get("name") or "").strip()
48 + if not name or not item.get("id"):
49 + return None
50 +
51 + prices = item.get("prices") or {}
52 + unit = int(prices.get("currency_minor_unit") or 2)
53 + price = _minor(prices.get("price"), unit)
54 + regular = _minor(prices.get("regular_price"), unit)
55 + if price is None:
56 + return None # pas de prix affiché = produit inutilisable
57 +
58 + cats = [_html.unescape(c.get("name") or "")
59 + for c in (item.get("categories") or [])]
60 + category_raw = " / ".join(c for c in cats if c)
61 +
62 + brands = item.get("brands") or []
63 + brand = _html.unescape(brands[0].get("name") or "") if brands else ""
64 +
65 + m = _SIZE_IN_NAME_RE.search(name)
66 + size_label = ""
67 + if m:
68 + u = m.group(2).lower()
69 + size_label = f"{m.group(1)} {'g' if u == 'gr' else u}"
70 +
71 + desc_html = item.get("short_description") or ""
72 + description = BeautifulSoup(desc_html, "html.parser").get_text(
73 + " ", strip=True) if desc_html else ""
74 +
75 + images = [img.get("src") for img in (item.get("images") or [])
76 + if img.get("src")]
77 +
78 + return Product(
79 + source=self.source_id,
80 + external_id=str(item["id"]),
81 + url=item.get("permalink") or f"{BASE}/?p={item['id']}",
82 + name=name,
83 + brand=brand,
84 + category=normalize_category(category_raw),
85 + category_raw=category_raw,
86 + size_label=size_label,
87 + price=price,
88 + regular_price=regular if regular and price and regular > price else None,
89 + on_sale=bool(item.get("on_sale")),
90 + in_stock=item.get("is_in_stock"),
91 + description=description,
92 + images=images[:3],
93 + )
94 +
95 + def fetch(self) -> list[Product]:
96 + products: list[Product] = []
97 + seen: set[str] = set()
98 + for page in range(1, self.max_pages + 1):
99 + try:
100 + data = self.get(
101 + API, params={"per_page": self.per_page, "page": page}).json()
102 + except Exception: # une page qui casse ne bloque pas le reste
103 + break
104 + if not isinstance(data, list) or not data:
105 + break
106 + for item in data:
107 + prod = self._parse_item(item)
108 + if prod is not None and prod.uid not in seen:
109 + seen.add(prod.uid)
110 + products.append(prod)
111 + if len(data) < self.per_page:
112 + break
113 + return products
added foodka/connectors/aliments_merci.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Aliments Merci (épicerie locale et vrac en ligne)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique WooCommerce -> Store API publique (voir _woocommerce.py)
5 +# -----------------------------------------------------------------------------
6 +from ._woocommerce import WooStoreConnector
7 +
8 +
9 +class AlimentsMerciConnector(WooStoreConnector):
10 + source_id = "aliments_merci"
11 + domain = "alimentsmerci.com"
added foodka/connectors/aubut.py +112 −0
@@ -0,0 +1,112 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Aubut (aubut.ca — grossiste cash & carry, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Site maison (Vue.js) : les grilles de catégories (/produits/<cat>?p=N,
5 +# 32 tuiles/page) embarquent un composant <app-product-ecommerce> dont
6 +# l'attribut :product contient TOUT le produit en JSON (prix, format, solde,
7 +# inventaire, image) — parsing trivial et fiable.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Product
16 +from .base import BaseConnector
17 +
18 +BASE = "https://www.aubut.ca"
19 +
20 +# (slug de catégorie, catégorie canonique)
21 +CATEGORIES = [
22 + ("fruits-et-legumes-9", "Fruits et légumes"),
23 + ("viandes-poissons-frais-8", "Viandes et volailles"),
24 + ("charcuteries-7", "Charcuteries et fromages"),
25 + ("produits-laitiers-6", "Produits laitiers et œufs"),
26 + ("boulangerie-5", "Boulangerie"),
27 + ("surgele-4", "Surgelés"),
28 + ("epicerie-1", "Garde-manger"),
29 + ("boissons-2", "Boissons"),
30 +]
31 +
32 +
33 +def _price(value) -> float | None:
34 + """« 31.99 » (chaîne du JSON Aubut) -> float, None si vide/zéro."""
35 + try:
36 + price = float(value)
37 + except (TypeError, ValueError):
38 + return None
39 + return price if price > 0 else None
40 +
41 +
42 +class AubutConnector(BaseConnector):
43 + source_id = "aubut"
44 +
45 + max_categories: int | None = None # borne pour les tests
46 + pages_per_category: int = 2 # 32 tuiles/page -> ~512 produits max
47 +
48 + def _parse_tiles(self, html: str, category: str, slug: str) -> list[Product]:
49 + soup = BeautifulSoup(html, "html.parser")
50 + products: list[Product] = []
51 + for tag in soup.select("app-product-ecommerce[\\:product]"):
52 + try:
53 + data = json.loads(tag[":product"])
54 + except (ValueError, KeyError):
55 + continue
56 + name = (data.get("title") or "").strip()
57 + pid = str(data.get("product_id") or data.get("id") or "")
58 + if not name or not pid:
59 + continue
60 +
61 + # prix « unité » ; *_special > 0 = prix en solde
62 + regular = _price(data.get("unite_price"))
63 + special = _price(data.get("unite_price_special"))
64 + price = special if special is not None else regular
65 + if special is None:
66 + regular = None # pas de solde -> pas de prix régulier distinct
67 +
68 + image = data.get("picture_thumb_path") or ""
69 + in_stock = None
70 + if str(data.get("has_inventaire") or "") in ("0", "1"):
71 + in_stock = data.get("has_inventaire") == "1"
72 +
73 + products.append(Product(
74 + source=self.source_id,
75 + external_id=pid,
76 + url=data.get("detail") or f"{BASE}/produits/details/{data.get('slug', '')}-{pid}",
77 + name=name,
78 + brand=(data.get("brand_title") or "").strip(),
79 + category=category,
80 + category_raw=slug,
81 + size_label=(data.get("unite_format") or "").strip(),
82 + price=price,
83 + regular_price=regular,
84 + on_sale=special is not None,
85 + unit_price_label=(data.get("price_100") or "").strip(),
86 + in_stock=in_stock,
87 + images=[image] if image.startswith("http") else [],
88 + ))
89 + return products
90 +
91 + def fetch(self) -> list[Product]:
92 + products: list[Product] = []
93 + seen: set[str] = set()
94 + categories = CATEGORIES[: self.max_categories]
95 + for slug, category in categories:
96 + for page in range(1, self.pages_per_category + 1):
97 + url = f"{BASE}/produits/{slug}"
98 + if page > 1:
99 + url += f"?p={page}"
100 + try:
101 + html = self.get(url).text
102 + except Exception: # une page qui casse ne bloque pas le reste
103 + break
104 + new = 0
105 + for prod in self._parse_tiles(html, category, slug):
106 + if prod.uid not in seen:
107 + seen.add(prod.uid)
108 + products.append(prod)
109 + new += 1
110 + if new == 0: # page vide ou répétée : catégorie épuisée
111 + break
112 + return products
added foodka/connectors/avril.py +117 −0
@@ -0,0 +1,117 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Avril Supermarché Santé (avril.ca)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Magento 2 (thème Hyvä/Alpine) : les sous-catégories d'épicerie
5 +# (/fr/epicerie/<sous-cat>.html?p=N, 24 cartes/page) sont rendues côté
6 +# serveur. Cloudflare en façade mais le GET direct passe ; repli Scrapfly
7 +# (ASP) si 403. NB : dans le balisage Avril, .price = prix courant et
8 +# .special-price = prix régulier barré (nommage inversé).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Product, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://avril.ca"
20 +
21 +# (slug de sous-catégorie d'épicerie, catégorie canonique)
22 +CATEGORIES = [
23 + ("fruits-legumes-biologiques", "Fruits et légumes"),
24 + ("viandes-substituts", "Viandes et volailles"),
25 + ("poissons-fruits-de-mer", "Poissons et fruits de mer"),
26 + ("produits-laitiers-oeufs", "Produits laitiers et œufs"),
27 + ("boulangerie", "Boulangerie"),
28 + ("garde-manger", "Garde-manger"),
29 + ("boissons", "Boissons"),
30 + ("pret-a-manger", "Prêt-à-manger"),
31 +]
32 +
33 +_IMG_RE = re.compile(r"initImage\('([^']+)'\)")
34 +
35 +
36 +class AvrilConnector(BaseConnector):
37 + source_id = "avril"
38 +
39 + max_categories: int | None = None # borne pour les tests
40 + pages_per_category: int = 2 # 24 cartes/page -> ~384 produits max
41 +
42 + def _get_html(self, url: str) -> str:
43 + """GET direct d'abord ; Scrapfly (ASP) si Cloudflare bloque (403)."""
44 + try:
45 + return self.get(url).text
46 + except Exception:
47 + return self.get_scrapfly(url)
48 +
49 + def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]:
50 + soup = BeautifulSoup(html, "html.parser")
51 + products: list[Product] = []
52 + for card in soup.select("form.product-item"):
53 + link = card.select_one("a.product-item-link[href]")
54 + pid_el = card.select_one('input[name="product"][value]')
55 + if link is None or pid_el is None:
56 + continue
57 + name = link.get_text(" ", strip=True)
58 + pid = str(pid_el["value"])
59 + if not name or not pid:
60 + continue
61 +
62 + weight_el = card.select_one(".product-item-weight")
63 + size_label = ""
64 + if weight_el is not None:
65 + size_label = weight_el.get_text(" ", strip=True).lstrip("| ").strip()
66 +
67 + brand_el = card.select_one(".product-info-brand")
68 + price_el = card.select_one(".price-container .price")
69 + # nommage Avril inversé : .special-price = prix régulier barré
70 + regular_el = card.select_one(".price-container .special-price")
71 +
72 + image = ""
73 + img = card.select_one("img.product-image-photo")
74 + if img is not None:
75 + m = _IMG_RE.search(str(img.get("x-data") or ""))
76 + if m:
77 + image = m.group(1)
78 +
79 + products.append(Product(
80 + source=self.source_id,
81 + external_id=pid,
82 + url=str(link["href"]),
83 + name=name,
84 + brand=brand_el.get_text(" ", strip=True) if brand_el else "",
85 + category=category,
86 + category_raw=f"epicerie/{slug}",
87 + size_label=size_label,
88 + price_label=price_el.get_text(" ", strip=True) if price_el else "",
89 + regular_price=(parse_price(regular_el.get_text(" ", strip=True))
90 + if regular_el else None),
91 + on_sale=regular_el is not None,
92 + images=[image] if image.startswith("http") else [],
93 + ))
94 + return products
95 +
96 + def fetch(self) -> list[Product]:
97 + products: list[Product] = []
98 + seen: set[str] = set()
99 + categories = CATEGORIES[: self.max_categories]
100 + for slug, category in categories:
101 + for page in range(1, self.pages_per_category + 1):
102 + url = f"{BASE}/fr/epicerie/{slug}.html"
103 + if page > 1:
104 + url += f"?p={page}"
105 + try:
106 + html = self._get_html(url)
107 + except Exception: # une page qui casse ne bloque pas le reste
108 + break
109 + new = 0
110 + for prod in self._parse_cards(html, category, slug):
111 + if prod.uid not in seen:
112 + seen.add(prod.uid)
113 + products.append(prod)
114 + new += 1
115 + if new == 0: # page vide ou répétée : catégorie épuisée
116 + break
117 + return products
added foodka/connectors/base.py +159 −0
@@ -0,0 +1,159 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (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 +# 1. requests direct (sites/API sans protection)
6 +# 2. Scrapfly (anti-bot ASP + rendu JavaScript — Metro, Walmart…) [préféré]
7 +# 3. Firecrawl (rendu JavaScript, solution de repli)
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +import os
13 +import time
14 +
15 +import requests
16 +
17 +from ..schema import Product
18 +
19 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
20 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
21 + "FoodKaBot/1.0 (+https://www.food-ka.com/bot; contact@spboucher.ai)")
22 +
23 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
24 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
25 +
26 +
27 +class BaseConnector:
28 + """Un connecteur = un adaptateur propre à une bannière d'épicerie.
29 +
30 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
31 + la liste complète des produits actuellement affichés sur le site.
32 + Le pipeline (ingest.py) s'occupe du diff avec la base de données.
33 + """
34 +
35 + source_id: str = ""
36 + request_delay: float = 0.6 # politesse entre requêtes
37 + timeout: int = 30
38 + use_detail_cache: bool = True # cache BD des pages détail
39 +
40 + def __init__(self) -> None:
41 + self.session = requests.Session()
42 + self.session.headers["User-Agent"] = USER_AGENT
43 + self._last_request = 0.0
44 + self._detail_con = None
45 +
46 + # -- backend 1 : requests direct ------------------------------------------
47 + def get(self, url: str, **kw) -> requests.Response:
48 + """GET direct avec throttling poli."""
49 + wait = self.request_delay - (time.time() - self._last_request)
50 + if wait > 0:
51 + time.sleep(wait)
52 + resp = self.session.get(url, timeout=self.timeout, **kw)
53 + self._last_request = time.time()
54 + resp.raise_for_status()
55 + return resp
56 +
57 + def post(self, url: str, **kw) -> requests.Response:
58 + """POST direct avec throttling poli (API JSON internes)."""
59 + wait = self.request_delay - (time.time() - self._last_request)
60 + if wait > 0:
61 + time.sleep(wait)
62 + resp = self.session.post(url, timeout=self.timeout, **kw)
63 + self._last_request = time.time()
64 + resp.raise_for_status()
65 + return resp
66 +
67 + # -- backend 2 : Scrapfly (préféré pour les sites protégés) ---------------
68 + def get_scrapfly(self, url: str, *, render_js: bool = False,
69 + asp: bool = True, country: str = "ca",
70 + headers: dict | None = None,
71 + wait_for_selector: str | None = None,
72 + retries: int = 2) -> str:
73 + """Récupère une page via Scrapfly — contourne Cloudflare/Akamai (ASP)
74 + et peut rendre le JavaScript. Le backend le plus robuste.
75 +
76 + Nécessite SCRAPFLY_API_KEY dans l'environnement (.env).
77 + Retourne le corps de la réponse (HTML ou JSON brut).
78 + """
79 + key = os.environ.get("SCRAPFLY_API_KEY")
80 + if not key:
81 + raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")
82 + params: dict = {
83 + "key": key, "url": url, "country": country,
84 + "asp": str(asp).lower(), "render_js": str(render_js).lower(),
85 + }
86 + if wait_for_selector:
87 + params["wait_for_selector"] = wait_for_selector
88 + if headers:
89 + for i, (hk, hv) in enumerate(headers.items()):
90 + params[f"headers[{hk}]"] = hv
91 + last_exc: Exception | None = None
92 + for attempt in range(retries + 1):
93 + try:
94 + resp = requests.get(SCRAPFLY_API, params=params, timeout=160)
95 + resp.raise_for_status()
96 + data = resp.json()
97 + result = data.get("result") or {}
98 + status = result.get("status_code")
99 + if status and int(status) >= 400:
100 + raise RuntimeError(f"scrapfly: statut amont {status} pour {url}")
101 + return result.get("content") or ""
102 + except Exception as exc: # réessai : proxys rotatifs = transitoire
103 + last_exc = exc
104 + time.sleep(2 * (attempt + 1))
105 + raise RuntimeError(f"scrapfly: échec après {retries + 1} tentatives: {last_exc}")
106 +
107 + def get_scrapfly_json(self, url: str, **kw) -> dict | list:
108 + """Variante JSON de get_scrapfly (API internes derrière anti-bot)."""
109 + body = self.get_scrapfly(url, **kw)
110 + return json.loads(body)
111 +
112 + # -- backend 3 : Firecrawl (repli rendu JavaScript) ------------------------
113 + def get_rendered(self, url: str, *, wait_ms: int = 0) -> str:
114 + """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
115 +
116 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
117 + """
118 + key = os.environ.get("FIRECRAWL_API_KEY")
119 + if not key:
120 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
121 + payload: dict = {"url": url, "formats": ["html"]}
122 + if wait_ms:
123 + payload["waitFor"] = wait_ms
124 + resp = requests.post(
125 + FIRECRAWL_API,
126 + json=payload,
127 + headers={"Authorization": f"Bearer {key}"},
128 + timeout=120,
129 + )
130 + resp.raise_for_status()
131 + data = resp.json()
132 + return (data.get("data") or {}).get("html", "")
133 +
134 + # -- cache des pages détail -------------------------------------------------
135 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
136 + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
137 + le produit est nouveau ou si sa clé (hash du contenu liste) a changé.
138 +
139 + Permet d'extraire les champs riches (description, valeurs nutritives…)
140 + sans revisiter chaque fiche produit à chaque synchronisation.
141 + `fetch_fn` doit retourner un dict JSON-sérialisable.
142 + """
143 + if not self.use_detail_cache:
144 + return fetch_fn() or {}
145 + from .. import db
146 + if self._detail_con is None:
147 + self._detail_con = db.connect()
148 + cached = db.get_cached_detail(self._detail_con, self.source_id,
149 + str(external_id), key)
150 + if cached is not None:
151 + return cached
152 + payload = fetch_fn() or {}
153 + db.put_cached_detail(self._detail_con, self.source_id,
154 + str(external_id), key, payload)
155 + return payload
156 +
157 + # -- contrat --------------------------------------------------------------
158 + def fetch(self) -> list[Product]:
159 + raise NotImplementedError
added foodka/connectors/bocoboco.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur BocoBoco (épicerie zéro déchet, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique WooCommerce -> Store API publique (voir _woocommerce.py)
5 +# -----------------------------------------------------------------------------
6 +from ._woocommerce import WooStoreConnector
7 +
8 +
9 +class BocoBocoConnector(WooStoreConnector):
10 + source_id = "bocoboco"
11 + domain = "bocoboco.ca"
added foodka/connectors/boite_a_grains.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur La Boîte à Grains (aliments naturels et bio, Gatineau)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique Shopify -> /products.json direct (voir _shopify.py)
5 +# -----------------------------------------------------------------------------
6 +from ._shopify import ShopifyConnector
7 +
8 +
9 +class BoiteAGrainsConnector(ShopifyConnector):
10 + source_id = "boite_a_grains"
11 + domain = "laboiteagrains.com"
added foodka/connectors/club_entrepot.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Club Entrepôt (Wholesale Club, bannière grossiste Loblaw)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Même plateforme Next.js que Maxi/Provigo -> Scrapfly (voir _loblaw.py)
5 +# -----------------------------------------------------------------------------
6 +from ._loblaw import LoblawConnector
7 +
8 +
9 +class ClubEntrepotConnector(LoblawConnector):
10 + source_id = "club_entrepot"
11 + domain = "www.wholesaleclub.ca"
added foodka/connectors/costco.py +110 −0
@@ -0,0 +1,110 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Costco Canada (costco.ca, épicerie livrée)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Nouveau catalogue Next.js (App Router) derrière Akamai : les tuiles produit
5 +# sont montées côté client — rendu JavaScript via Scrapfly + attente du
6 +# sélecteur [data-testid^="ProductTile"]. Chaque page feuille (ex. crackers)
7 +# affiche une grille de tuiles avec prix ; les pages « rayon » (ex. snacks)
8 +# ne contiennent que des vignettes de sous-catégories, on cible donc les
9 +# feuilles directement. Numéro d'article Costco = id dans l'URL produit.
10 +# ⚠️ Le frais (produce, dairy, meat…) est « Warehouse Only » : aucun prix en
11 +# ligne (« Warehouse pricing may vary ») — on ne couvre que l'épicerie livrée
12 +# (Costco Grocery, non périssable) et on ignore les tuiles sans prix.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import re
17 +
18 +from bs4 import BeautifulSoup
19 +
20 +from ..schema import Product, parse_price
21 +from .base import BaseConnector
22 +
23 +# format dans le nom : "567 g", "2 × 425 g", "40 x 28 g", "1.36 kg"…
24 +_SIZE_IN_NAME = re.compile(
25 + r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b",
26 + re.IGNORECASE)
27 +
28 +# pages feuilles (grille produits) de l'épicerie livrée : (slug, catégorie)
29 +CATEGORIES = [
30 + ("canned-food", "Garde-manger"),
31 + ("cereal", "Garde-manger"),
32 + ("pasta", "Garde-manger"),
33 + ("oils-vinegars", "Garde-manger"),
34 + ("soup", "Garde-manger"),
35 + ("spreads-butters", "Garde-manger"),
36 + ("baking-packaged-food", "Garde-manger"),
37 + ("soda-soft-drinks", "Boissons"),
38 + ("juice", "Boissons"),
39 + ("ground-coffee", "Boissons"),
40 + ("tea", "Boissons"),
41 + ("crackers", "Collations et confiseries"),
42 + ("chocolates", "Collations et confiseries"),
43 + ("candy", "Collations et confiseries"),
44 + ("fruit-nuts", "Collations et confiseries"),
45 + ("organic", "Bio et santé"),
46 + ("laundry-detergent", "Maison et entretien"),
47 +]
48 +
49 +
50 +class CostcoConnector(BaseConnector):
51 + source_id = "costco"
52 + request_delay = 1.0
53 +
54 + # -- extraction d'une grille -----------------------------------------------
55 + def _parse(self, html: str, category: str, slug: str) -> list[Product]:
56 + soup = BeautifulSoup(html, "html.parser")
57 + products: list[Product] = []
58 + for tile in soup.select('div[data-testid^="ProductTile_"]'):
59 + pid = tile.get("data-testid", "").split("_", 1)[-1]
60 + link = tile.select_one('a[data-testid="Link"][href]')
61 + if not pid or link is None:
62 + continue
63 + name = link.get_text(" ", strip=True)
64 + if not name:
65 + continue
66 + price_el = tile.select_one('[data-testid^="Text_Price"]')
67 + price = parse_price(price_el.get_text(" ", strip=True)) if price_el else None
68 + if price is None: # tuile « Warehouse Only » : pas de prix en ligne
69 + continue
70 + img = tile.select_one('[data-testid^="ProductImage"] img[src]')
71 + size_m = _SIZE_IN_NAME.search(name)
72 + text = tile.get_text(" ", strip=True)
73 + in_stock: bool | None = None
74 + if "Out of Stock" in text:
75 + in_stock = False
76 + elif "In Stock" in text or "Add to Cart" in text:
77 + in_stock = True
78 + href = link["href"]
79 + products.append(Product(
80 + source=self.source_id,
81 + external_id=str(pid),
82 + url=href if href.startswith("http") else f"https://www.costco.ca{href}",
83 + name=name,
84 + category=category,
85 + category_raw=slug.replace("-", " "),
86 + size_label=size_m.group(1) if size_m else "",
87 + price=price,
88 + in_stock=in_stock,
89 + details={"item_number": pid},
90 + images=[img["src"]] if img is not None else [],
91 + ))
92 + return products
93 +
94 + # -- contrat ---------------------------------------------------------------
95 + def fetch(self) -> list[Product]:
96 + products: list[Product] = []
97 + seen: set[str] = set()
98 + for slug, category in CATEGORIES:
99 + url = f"https://www.costco.ca/{slug}.html"
100 + try:
101 + html = self.get_scrapfly(
102 + url, render_js=True,
103 + wait_for_selector='[data-testid^="ProductTile"]')
104 + except Exception: # une catégorie qui casse ne bloque pas le reste
105 + continue
106 + for prod in self._parse(html, category, slug):
107 + if prod.uid not in seen:
108 + seen.add(prod.uid)
109 + products.append(prod)
110 + return products
added foodka/connectors/epipresto.py +12 −0
@@ -0,0 +1,12 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur ÉpiPresto (épicerie en ligne, produits de spécialité)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique Shopify -> /products.json direct ; product_type souvent vide,
5 +# la base retombe alors sur les tags pour la catégorie (voir _shopify.py).
6 +# -----------------------------------------------------------------------------
7 +from ._shopify import ShopifyConnector
8 +
9 +
10 +class EpiPrestoConnector(ShopifyConnector):
11 + source_id = "epipresto"
12 + domain = "epipresto.ca"
added foodka/connectors/giant_tiger.py +39 −0
@@ -0,0 +1,39 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Giant Tiger (magasin escompte, rayons épicerie)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique Shopify -> le catalogue complet mêle vêtements et articles de
5 +# maison ; on cible donc les collections « épicerie » (voir _shopify.py).
6 +# -----------------------------------------------------------------------------
7 +from ..schema import Product
8 +from ._shopify import ShopifyConnector
9 +
10 +# product_type Giant Tiger que normalize_category ne reconnaît pas
11 +_TYPE_MAP = {
12 + "non-perishable": "Garde-manger",
13 + "fish": "Poissons et fruits de mer",
14 +}
15 +
16 +
17 +class GiantTigerConnector(ShopifyConnector):
18 + source_id = "giant_tiger"
19 + domain = "www.gianttiger.com"
20 +
21 + # collections épicerie du site ; "" = déduire la catégorie du product_type
22 + collections = [
23 + ("grocery", ""),
24 + ("frozen", "Surgelés"),
25 + ("dairy-and-eggs", "Produits laitiers et œufs"),
26 + ("meat-and-seafood", "Viandes et volailles"),
27 + ("drinks-1", "Boissons"),
28 + ("chocolate-and-candy", "Collations et confiseries"),
29 + ("granola-bars-and-snacks", "Collations et confiseries"),
30 + ("international-food", "Garde-manger"),
31 + ("baby-food", "Bébé"),
32 + ]
33 +
34 + def _to_product(self, product: dict, category: str = "") -> Product | None:
35 + # rabat les product_type maison ("Non-Perishable", "Fish"…) que la
36 + # normalisation générique ne connaît pas
37 + if not category:
38 + category = _TYPE_MAP.get((product.get("product_type") or "").lower(), "")
39 + return super()._to_product(product, category)
added foodka/connectors/iga.py +154 −0
@@ -0,0 +1,154 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur IGA (Sobeys Québec — plateforme Voilà, voila.ca)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# iga.net délègue l'épicerie en ligne à voila.ca. Deux voies :
5 +# 1. (principale) API REST publique des promotions — JSON complet avec prix,
6 +# prix promo, format et catégorie, pagination par curseur, sans anti-bot.
7 +# 2. (complément, optionnelle) recherche de la SPA rendue via Scrapfly
8 +# (render_js) pour les produits hors promotion.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Product, normalize_category, parse_price
17 +from .base import BaseConnector
18 +
19 +PLP_URL = "https://voila.ca/api/product-listing-pages/v1/pages/promotions"
20 +# regionId public observé (région de livraison Québec) — requis par l'API
21 +REGION_ID = "dd7143ad-a16a-4c1d-8dee-e3c7e48dc511"
22 +
23 +# (terme de recherche, catégorie canonique) — voie complémentaire (rendu JS)
24 +SEARCHES = [
25 + ("fruits", "Fruits et légumes"),
26 + ("viande", "Viandes et volailles"),
27 + ("fromage", "Charcuteries et fromages"),
28 + ("pain", "Boulangerie"),
29 + ("surgele", "Surgelés"),
30 +]
31 +
32 +_ID_RE = re.compile(r"/([0-9]+[A-Z]{2})/?$")
33 +_SIZE_IN_TITLE = re.compile(
34 + r"(\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b", re.IGNORECASE)
35 +
36 +
37 +class IgaConnector(BaseConnector):
38 + source_id = "iga"
39 + request_delay = 1.0
40 + max_api_pages = 12 # 50 produits/page (promotions en cours)
41 + use_search = False # recherches Scrapfly complémentaires (coûteux)
42 +
43 + # -- voie 1 : API REST des promotions --------------------------------------
44 + def _fetch_promotions(self) -> list[Product]:
45 + products: list[Product] = []
46 + token: str | None = None
47 + for _ in range(self.max_api_pages):
48 + params = {"regionId": REGION_ID, "maxPageSize": 50,
49 + "maxProductsToDecorate": 50}
50 + if token:
51 + params["pageToken"] = token
52 + data = self.get(PLP_URL, params=params,
53 + headers={"Accept": "application/json",
54 + "Accept-Language": "fr-CA,fr;q=0.9"}).json()
55 + decorated = list(data.get("decoratedProducts") or [])
56 + for group in data.get("productGroups") or []:
57 + decorated.extend(group.get("decoratedProducts") or [])
58 + for p in decorated:
59 + rid = p.get("retailerProductId")
60 + name = p.get("name") or ""
61 + if not rid or not name:
62 + continue
63 + price = (p.get("price") or {}).get("amount")
64 + promo = (p.get("promoPrice") or {}).get("amount")
65 + current, regular = (promo, price) if promo else (price, None)
66 + image = p.get("image") or {}
67 + srcset = image.get("bopSrcset") or ""
68 + img_url = srcset.split(" ")[0] if srcset else ""
69 + cat_path = p.get("categoryPath") or []
70 + unit = ((p.get("unitPrice") or {}).get("price") or {}).get("amount")
71 + products.append(Product(
72 + source=self.source_id,
73 + external_id=str(rid),
74 + url=f"https://voila.ca/products/{rid}",
75 + name=name,
76 + brand=p.get("brand") or "",
77 + category=normalize_category(cat_path[0] if cat_path else ""),
78 + category_raw=" / ".join(cat_path),
79 + size_label=p.get("packSizeDescription") or "",
80 + price=float(current) if current else None,
81 + regular_price=float(regular) if regular else None,
82 + on_sale=promo is not None,
83 + in_stock=p.get("available"),
84 + keywords=[a.get("label") for a in p.get("iconAttributes") or []
85 + if isinstance(a, dict) and a.get("label")],
86 + images=[img_url] if img_url.startswith("http") else [],
87 + ))
88 + token = (data.get("metadata") or {}).get("nextPageToken")
89 + if not token or not decorated:
90 + break
91 + return products
92 +
93 + # -- voie 2 : recherche SPA rendue (complément) -----------------------------
94 + def _parse_search(self, html: str, category: str, term: str) -> list[Product]:
95 + soup = BeautifulSoup(html, "html.parser")
96 + products: list[Product] = []
97 + for link in soup.select('[data-test="fop-product-link"][href]'):
98 + href = link["href"]
99 + m = _ID_RE.search(href)
100 + if not m:
101 + continue
102 + tile = link
103 + for _ in range(6):
104 + if tile.parent is None:
105 + break
106 + tile = tile.parent
107 + if tile.select_one('[data-test="fop-price"]'):
108 + break
109 + title_el = tile.select_one('[data-test="fop-title"]') or link
110 + price_el = tile.select_one('[data-test="fop-price"]')
111 + orig_el = tile.select_one('[data-test="fop-original-price"]')
112 + unit_el = tile.select_one('[data-test="fop-price-per-unit"]')
113 + img = tile.select_one("img[src]")
114 + name = title_el.get_text(" ", strip=True)
115 + if not name:
116 + continue
117 + size_m = _SIZE_IN_TITLE.search(name)
118 + regular = parse_price(orig_el.get_text(" ", strip=True)) if orig_el else None
119 + products.append(Product(
120 + source=self.source_id,
121 + external_id=m.group(1),
122 + url=f"https://voila.ca{href}" if href.startswith("/") else href,
123 + name=name,
124 + category=category,
125 + category_raw=term,
126 + size_label=size_m.group(1) if size_m else "",
127 + price=parse_price(price_el.get_text(" ", strip=True)) if price_el else None,
128 + regular_price=regular,
129 + on_sale=regular is not None,
130 + unit_price_label=(unit_el.get_text(" ", strip=True).strip("()")
131 + if unit_el else ""),
132 + images=([img["src"]] if img is not None
133 + and str(img.get("src", "")).startswith("http") else []),
134 + ))
135 + return products
136 +
137 + def fetch(self) -> list[Product]:
138 + products = self._fetch_promotions()
139 + seen = {p.uid for p in products}
140 + if self.use_search:
141 + for term, category in SEARCHES:
142 + url = f"https://voila.ca/products?q={term.replace(' ', '+')}"
143 + try:
144 + html = self.get_scrapfly(
145 + url, render_js=True,
146 + headers={"Accept-Language": "fr-CA,fr;q=0.9"},
147 + wait_for_selector='[data-test="fop-product-link"]')
148 + except Exception:
149 + continue
150 + for prod in self._parse_search(html, category, term):
151 + if prod.uid not in seen:
152 + seen.add(prod.uid)
153 + products.append(prod)
154 + return products
added foodka/connectors/loco.py +125 −0
@@ -0,0 +1,125 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Épiceries LOCO (epicerie-loco-en-ligne.square.site —
3 +# épicerie écologique zéro déchet, Montréal)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# Square Online (SPA Vue) : aucun prix dans le HTML statique ni d'API catalogue
6 +# publique — les grilles /shop/<slug>/<id> sont rendues côté client. Stratégie :
7 +# Scrapfly render_js sur une sélection de catégories alimentaires (?limit=120,
8 +# une page suffit : ~176 produits alimentaires au total), cartes
9 +# div.product-group (titre p.w-product-title, prix « $7.30 »).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Product
18 +from .base import BaseConnector
19 +
20 +BASE = "https://epicerie-loco-en-ligne.square.site"
21 +
22 +# /product/<slug>/<id>?cp=true&category_id=… -> id (numérique ou uid Square)
23 +_ID_RE = re.compile(r"/product/[^/]+/([^/?#]+)")
24 +_PRICE_RE = re.compile(r"\$\s*(\d{1,4}(?:[.,]\d{1,2})?)")
25 +
26 +# catégories Square Online de LOCO : (slug/id, taxonomie source, catégorie canonique)
27 +COLLECTIONS = [
28 + ("Fruit/26", "Fruits", "Fruits et légumes"),
29 + ("Legume/27", "Légumes", "Fruits et légumes"),
30 + ("produits-laitiers-et-oeufs/29", "Produits laitiers et oeufs",
31 + "Produits laitiers et œufs"),
32 + ("riz-grains-et-legumineuses/16", "Riz, graines et légumineuses",
33 + "Garde-manger"),
34 + ("Cereales-et-granola/18", "Céréales, granola et flocons d'avoine",
35 + "Garde-manger"),
36 + ("Colllations/31", "Collations", "Collations et confiseries"),
37 + ("Breuvage/24", "Breuvages", "Boissons"),
38 + ("noix/39", "Noix", "Collations et confiseries"),
39 + ("tartinade-et-beurre-de-noix/22", "Tartinades et beurres de noix",
40 + "Garde-manger"),
41 + ("Pret-a-manger/25", "Prêt-à-manger", "Prêt-à-manger"),
42 +]
43 +
44 +
45 +class LocoConnector(BaseConnector):
46 + source_id = "loco"
47 + request_delay = 1.0
48 +
49 + page_limit: int = 120 # produits par grille (?limit=…) — 1 requête/catégorie
50 +
51 + # -- extraction d'une grille de catégorie -----------------------------------
52 + def _parse(self, html: str, category_raw: str, category: str) -> list[Product]:
53 + soup = BeautifulSoup(html, "html.parser")
54 + products: list[Product] = []
55 + for card in soup.select("div.product-group"):
56 + link = card.select_one('a[href^="/product/"]')
57 + if link is None:
58 + continue
59 + m = _ID_RE.search(link.get("href", ""))
60 + if m is None:
61 + continue
62 + title = card.select_one("p.w-product-title")
63 + name = title.get_text(" ", strip=True) if title else ""
64 + if not name:
65 + img_alt = card.select_one("img[alt]")
66 + name = str(img_alt["alt"]).strip() if img_alt else ""
67 + if not name:
68 + continue
69 + # prix « $7.30 » ou plage de formats vrac « $4.55 - $6.55 »
70 + # (prix du plus petit format) ; un solde affiche un prix barré
71 + # dans un élément <s>/<del>.
72 + price_el = card.select_one(".product-price__wrapper") or card
73 + price_label = price_el.get_text(" ", strip=True)
74 + amounts = [float(v.replace(",", "."))
75 + for v in _PRICE_RE.findall(price_label)]
76 + if not amounts:
77 + continue # carte sans prix affiché : on l'écarte
78 + price = min(amounts)
79 + struck = price_el.select_one("s, del")
80 + regular = (max(amounts)
81 + if struck is not None and max(amounts) > price else None)
82 +
83 + low = card.get_text(" ", strip=True).lower()
84 + in_stock: bool | None = None
85 + if "épuisé" in low or "sold out" in low:
86 + in_stock = False
87 +
88 + img = card.select_one("img[src]")
89 + image = str(img["src"]) if img else ""
90 + href = str(link["href"]).split("?", 1)[0]
91 +
92 + products.append(Product(
93 + source=self.source_id,
94 + external_id=m.group(1),
95 + url=f"{BASE}{href}",
96 + name=name,
97 + category=category,
98 + category_raw=category_raw,
99 + price=price,
100 + regular_price=regular,
101 + price_label=price_label,
102 + on_sale=regular is not None,
103 + in_stock=in_stock,
104 + images=[image] if image.startswith("http") else [],
105 + ))
106 + return products
107 +
108 + # -- contrat ---------------------------------------------------------------
109 + def fetch(self) -> list[Product]:
110 + products: list[Product] = []
111 + seen: set[str] = set()
112 + for slug, category_raw, category in COLLECTIONS:
113 + url = (f"{BASE}/shop/{slug}?page=1&limit={self.page_limit}"
114 + "&sort_by=category_order&sort_order=asc")
115 + try:
116 + html = self.get_scrapfly(
117 + url, render_js=True,
118 + wait_for_selector="a.product-image__link")
119 + except Exception: # une catégorie qui casse ne bloque pas le reste
120 + continue
121 + for prod in self._parse(html, category_raw, category):
122 + if prod.uid not in seen:
123 + seen.add(prod.uid)
124 + products.append(prod)
125 + return products
added foodka/connectors/maturin.py +108 −0
@@ -0,0 +1,108 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Maturin (maturin.ca — produits québécois en ligne)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Site maison rendu côté serveur : /categorie/<cat>?page=N (24 cartes/page,
5 +# défilement infini côté client = simple paramètre page). Cartes
6 +# .masonry-product : titre, producteur (marque), prix « À partir de X$ / fmt »,
7 +# image /image/crop/....
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Product, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://maturin.ca"
19 +
20 +# (slug de catégorie, catégorie canonique)
21 +CATEGORIES = [
22 + ("fruits-et-legumes", "Fruits et légumes"),
23 + ("boucherie", "Viandes et volailles"),
24 + ("poissonnerie", "Poissons et fruits de mer"),
25 + ("produits-laitiers", "Produits laitiers et œufs"),
26 + ("boulangerie-et-desserts", "Boulangerie"),
27 + ("garde-manger", "Garde-manger"),
28 + ("breuvages", "Boissons"),
29 + ("mets-cuisines", "Prêt-à-manger"),
30 +]
31 +
32 +# « À partir de 7.60$ / 350g » -> (prix, format)
33 +_PRICE_FMT_RE = re.compile(r"(\d+(?:[.,]\d{1,2})?\s*\$)(?:\s*/\s*(\S+))?")
34 +
35 +
36 +class MaturinConnector(BaseConnector):
37 + source_id = "maturin"
38 +
39 + max_categories: int | None = None # borne pour les tests
40 + pages_per_category: int = 2 # 24 cartes/page -> ~384 produits max
41 +
42 + def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]:
43 + soup = BeautifulSoup(html, "html.parser")
44 + products: list[Product] = []
45 + for card in soup.select("div.masonry-product[data-product]"):
46 + pid = str(card.get("data-product") or "")
47 + title = card.select_one(".product-title a[href]")
48 + if not pid or title is None:
49 + continue
50 + name = title.get_text(" ", strip=True)
51 + if not name:
52 + continue
53 + brand_el = card.select_one(".product-subtitle a")
54 +
55 + price_label, size_label = "", ""
56 + price_el = card.select_one(".product-price span")
57 + if price_el is not None:
58 + text = price_el.get_text(" ", strip=True)
59 + m = _PRICE_FMT_RE.search(text)
60 + if m:
61 + price_label = m.group(1)
62 + size_label = m.group(2) or ""
63 +
64 + img = card.select_one("img.product-image")
65 + src = str((img.get("data-src") or img.get("src") or "")) if img else ""
66 + if src.startswith("/") and "none.png" not in src:
67 + image = f"{BASE}{src}"
68 + else:
69 + image = src if src.startswith("http") else ""
70 +
71 + href = str(title["href"])
72 + products.append(Product(
73 + source=self.source_id,
74 + external_id=pid,
75 + url=f"{BASE}{href}" if href.startswith("/") else href,
76 + name=name,
77 + brand=brand_el.get_text(" ", strip=True) if brand_el else "",
78 + category=category,
79 + category_raw=slug,
80 + size_label=size_label,
81 + price=parse_price(price_label),
82 + price_label=price_label,
83 + images=[image] if image else [],
84 + ))
85 + return products
86 +
87 + def fetch(self) -> list[Product]:
88 + products: list[Product] = []
89 + seen: set[str] = set()
90 + categories = CATEGORIES[: self.max_categories]
91 + for slug, category in categories:
92 + for page in range(1, self.pages_per_category + 1):
93 + url = f"{BASE}/categorie/{slug}"
94 + if page > 1:
95 + url += f"?url=categorie/{slug}&page={page}"
96 + try:
97 + html = self.get(url).text
98 + except Exception: # une page qui casse ne bloque pas le reste
99 + break
100 + new = 0
101 + for prod in self._parse_cards(html, category, slug):
102 + if prod.uid not in seen:
103 + seen.add(prod.uid)
104 + products.append(prod)
105 + new += 1
106 + if new == 0: # page vide ou répétée : catégorie épuisée
107 + break
108 + return products
added foodka/connectors/maxi.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Maxi (bannière escompte Loblaw)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Site Next.js derrière Cloudflare -> Scrapfly (voir _loblaw.py)
5 +# -----------------------------------------------------------------------------
6 +from ._loblaw import LoblawConnector
7 +
8 +
9 +class MaxiConnector(LoblawConnector):
10 + source_id = "maxi"
11 + domain = "www.maxi.ca"
added foodka/connectors/mayrand.py +119 −0
@@ -0,0 +1,119 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Mayrand (mayrand.ca — grossiste alimentaire)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# HubSpot CMS : chaque page de rayon (/fr/nos-produits/<rayon>) rend TOUS les
5 +# produits côté serveur (la pagination est purement JavaScript) — un seul GET
6 +# par rayon suffit. Cartes : .product-card-wrapper (prix, format, image, SKU).
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import re
11 +
12 +from bs4 import BeautifulSoup
13 +
14 +from ..schema import Product, parse_price
15 +from .base import BaseConnector
16 +
17 +BASE = "https://mayrand.ca"
18 +
19 +# (slug de rayon, catégorie canonique)
20 +DEPARTMENTS = [
21 + ("fruits-et-legumes", "Fruits et légumes"),
22 + ("boucherie", "Viandes et volailles"),
23 + ("poissonnerie", "Poissons et fruits de mer"),
24 + ("charcuterie", "Charcuteries et fromages"),
25 + ("produits-laitiers", "Produits laitiers et œufs"),
26 + ("boulangerie", "Boulangerie"),
27 + ("surgele", "Surgelés"),
28 + ("epicerie", "Garde-manger"),
29 + ("boisson", "Boissons"),
30 +]
31 +
32 +_PAREN_RE = re.compile(r"\(([^)]+)\)") # « caisse (11.34kg) » -> « 11.34kg »
33 +_SKU_RE = re.compile(r"-(\d+)/?$") # slug produit : ...-<sku>
34 +
35 +
36 +class MayrandConnector(BaseConnector):
37 + source_id = "mayrand"
38 +
39 + max_departments: int | None = None # borne pour les tests
40 + max_per_department: int = 50 # ~450 produits/synchro au total
41 +
42 + def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]:
43 + soup = BeautifulSoup(html, "html.parser")
44 + products: list[Product] = []
45 + for card in soup.select("div.product-card-wrapper"):
46 + link = card.select_one("a.product_link[href]")
47 + if link is None:
48 + continue
49 + name = link.get_text(" ", strip=True)
50 + href = link["href"]
51 + sku_el = card.select_one(".product_id")
52 + sku = sku_el.get_text(strip=True) if sku_el else ""
53 + if not sku:
54 + m = _SKU_RE.search(href)
55 + sku = m.group(1) if m else ""
56 + if not name or not sku:
57 + continue
58 +
59 + # premier bloc de prix (« unité » avant « caisse » quand les deux existent)
60 + price_el = card.select_one(".unit_price")
61 + regular = None
62 + price_label = ""
63 + if price_el is not None:
64 + deleted = price_el.select_one("del.price-discount")
65 + if deleted is not None: # en solde : <span> prix, <del> régulier
66 + regular = parse_price(deleted.get_text(" ", strip=True))
67 + span = price_el.select_one("span")
68 + price_label = span.get_text(" ", strip=True) if span else ""
69 + else:
70 + price_label = price_el.get_text(" ", strip=True)
71 +
72 + qty_el = card.select_one(".unit_quantity")
73 + qty_text = qty_el.get_text(" ", strip=True) if qty_el else ""
74 + m = _PAREN_RE.search(qty_text)
75 + size_label = m.group(1) if m else ""
76 +
77 + unit_el = card.select_one(".unit-price-ref")
78 + img = card.select_one("img.product_image[src]")
79 + image = str(img["src"]) if img else ""
80 + brand = (card.get("data-brand") or "").strip()
81 + if brand.lower() == "null":
82 + brand = ""
83 +
84 + products.append(Product(
85 + source=self.source_id,
86 + external_id=sku,
87 + url=f"{BASE}/fr/nos-produits/{href.lstrip('/')}",
88 + name=name,
89 + brand=brand,
90 + category=category,
91 + category_raw=slug,
92 + size_label=size_label,
93 + price_label=price_label,
94 + regular_price=regular,
95 + on_sale=card.get("data-sale") == "OnSale",
96 + unit_price_label=unit_el.get_text(" ", strip=True) if unit_el else "",
97 + details={"format": qty_text} if qty_text else {},
98 + images=[image] if image.startswith("http") else [],
99 + ))
100 + return products
101 +
102 + def fetch(self) -> list[Product]:
103 + products: list[Product] = []
104 + seen: set[str] = set()
105 + departments = DEPARTMENTS[: self.max_departments]
106 + for slug, category in departments:
107 + url = f"{BASE}/fr/nos-produits/{slug}"
108 + try:
109 + html = self.get(url).text
110 + except Exception: # un rayon qui casse ne bloque pas le reste
111 + continue
112 + kept = 0
113 + for prod in self._parse_cards(html, category, slug):
114 + if prod.uid in seen or kept >= self.max_per_department:
115 + continue
116 + seen.add(prod.uid)
117 + products.append(prod)
118 + kept += 1
119 + return products
added foodka/connectors/metro.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Metro (metro.ca)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Anti-bot agressif -> Scrapfly (voir _metro.py)
5 +# -----------------------------------------------------------------------------
6 +from ._metro import MetroPlatformConnector
7 +
8 +
9 +class MetroConnector(MetroPlatformConnector):
10 + source_id = "metro"
11 + domain = "www.metro.ca"
added foodka/connectors/nuvo.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Nuvo (épicerie en ligne québécoise)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique Shopify -> /products.json direct (voir _shopify.py)
5 +# -----------------------------------------------------------------------------
6 +from ._shopify import ShopifyConnector
7 +
8 +
9 +class NuvoConnector(ShopifyConnector):
10 + source_id = "nuvo"
11 + domain = "marchenuvo.ca"
added foodka/connectors/pa.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur PA Supermarché (épicerie indépendante, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Boutique Shopify -> /products.json direct (voir _shopify.py)
5 +# -----------------------------------------------------------------------------
6 +from ._shopify import ShopifyConnector
7 +
8 +
9 +class PASupermarcheConnector(ShopifyConnector):
10 + source_id = "pa"
11 + domain = "www.supermarchepa.com"
added foodka/connectors/provigo.py +11 −0
@@ -0,0 +1,11 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Provigo (bannière Loblaw au Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Site Next.js derrière Cloudflare -> Scrapfly (voir _loblaw.py)
5 +# -----------------------------------------------------------------------------
6 +from ._loblaw import LoblawConnector
7 +
8 +
9 +class ProvigoConnector(LoblawConnector):
10 + source_id = "provigo"
11 + domain = "www.provigo.ca"
added foodka/connectors/superc.py +12 −0
@@ -0,0 +1,12 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Super C (superc.ca, bannière escompte de Metro)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Même plateforme que metro.ca -> Scrapfly (voir _metro.py)
5 +# -----------------------------------------------------------------------------
6 +from ._metro import MetroPlatformConnector
7 +
8 +
9 +class SuperCConnector(MetroPlatformConnector):
10 + source_id = "superc"
11 + domain = "www.superc.ca"
12 + aisle_prefix = "/allees" # superc.ca n'a pas le préfixe /epicerie-en-ligne
added foodka/connectors/tau.py +118 −0
@@ -0,0 +1,118 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Tau (marchestau.com — supermarchés santé)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# k-eCommerce : les grilles de catégories sont chargées en AJAX (aucun prix
5 +# dans le HTML), mais les FICHES produit sont rendues côté serveur
6 +# (« 16,99$ CAD », fil d'Ariane, marque, image). Stratégie : sitemap
7 +# kSitemap-1.xml (~14 000 URLs), filtrage des slugs produit (suffixe de
8 +# format « -227gr »), échantillon réparti sur tout le catalogue, puis une
9 +# requête par fiche (throttling de BaseConnector).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Product, normalize_category, parse_price
18 +from .base import BaseConnector
19 +
20 +BASE = "https://marchestau.com"
21 +SITEMAP = f"{BASE}/kSitemap-1.xml"
22 +
23 +_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")
24 +# slug produit : se termine par un format, ex. « -227gr », « -1.36l », « -60un »
25 +_SLUG_SIZE_RE = re.compile(
26 + r"-(\d+(?:[.,]\d+)?)\s*(gr|g|kg|ml|l|lb|oz|un)$", re.IGNORECASE)
27 +_CODE_RE = re.compile(r"(\d{6,})")
28 +
29 +
30 +class TauConnector(BaseConnector):
31 + source_id = "tau"
32 +
33 + max_products: int = 180 # une requête par fiche : rester raisonnable
34 +
35 + def _product_urls(self) -> list[str]:
36 + """URLs de fiches produit, échantillonnées uniformément (tout l'alphabet)."""
37 + xml = self.get(SITEMAP).text
38 + urls = [u for u in _LOC_RE.findall(xml)
39 + if _SLUG_SIZE_RE.search(u.rsplit("/", 1)[-1])]
40 + if len(urls) <= self.max_products:
41 + return urls
42 + step = len(urls) / self.max_products
43 + return [urls[int(i * step)] for i in range(self.max_products)]
44 +
45 + def _parse_product(self, html: str, url: str) -> Product | None:
46 + soup = BeautifulSoup(html, "html.parser")
47 + h1 = soup.select_one(".product-detail h1") or soup.select_one("h1")
48 + if h1 is None:
49 + return None
50 + name = h1.get_text(" ", strip=True)
51 + if not name:
52 + return None
53 +
54 + code_el = soup.select_one(".product-details-code")
55 + code = ""
56 + if code_el is not None:
57 + m = _CODE_RE.search(code_el.get_text())
58 + code = m.group(1) if m else ""
59 + slug = url.rstrip("/").rsplit("/", 1)[-1]
60 + external_id = code or slug
61 +
62 + # prix courant + prix régulier barré (solde)
63 + price_el = soup.select_one(".price-current")
64 + regular_el = soup.select_one(
65 + ".price-regular, .price-before, .single-price-display del, "
66 + ".single-price-display s")
67 + unit_el = soup.select_one(".price-per-unit small")
68 +
69 + # fil d'Ariane : Accueil > Catalogue > <cat> > <sous-cat> > <produit>
70 + crumbs = [li.get_text(" ", strip=True)
71 + for li in soup.select("ul.breadcrumb li a")]
72 + crumbs = [c for c in crumbs
73 + if c and c not in ("Page d'accueil", "Catalogue")]
74 + category_raw = " / ".join(crumbs)
75 +
76 + brand_el = soup.select_one(".product-brand a")
77 + img = soup.select_one("#product-detail-gallery-main-img[src]")
78 + image = str(img["src"]) if img else ""
79 +
80 + # format depuis le slug : « mures-noires-227gr » -> « 227 g »
81 + size_label = ""
82 + m = _SLUG_SIZE_RE.search(slug)
83 + if m:
84 + unit = m.group(2).lower()
85 + size_label = f"{m.group(1)} {'g' if unit == 'gr' else unit}"
86 +
87 + desc_el = soup.select_one(".product-details-desc")
88 + return Product(
89 + source=self.source_id,
90 + external_id=external_id,
91 + url=url,
92 + name=name,
93 + brand=brand_el.get_text(" ", strip=True) if brand_el else "",
94 + category=normalize_category(category_raw),
95 + category_raw=category_raw,
96 + size_label=size_label,
97 + price_label=price_el.get_text(" ", strip=True) if price_el else "",
98 + regular_price=(parse_price(regular_el.get_text(" ", strip=True))
99 + if regular_el else None),
100 + on_sale=regular_el is not None,
101 + unit_price_label=unit_el.get_text(" ", strip=True) if unit_el else "",
102 + description=desc_el.get_text(" ", strip=True) if desc_el else "",
103 + images=[image] if image.startswith("http") else [],
104 + )
105 +
106 + def fetch(self) -> list[Product]:
107 + products: list[Product] = []
108 + seen: set[str] = set()
109 + for url in self._product_urls():
110 + try:
111 + html = self.get(url).text
112 + except Exception: # une fiche qui casse ne bloque pas le reste
113 + continue
114 + prod = self._parse_product(html, url)
115 + if prod is not None and prod.uid not in seen:
116 + seen.add(prod.uid)
117 + products.append(prod)
118 + return products
added foodka/connectors/tt.py +130 −0
@@ -0,0 +1,130 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur T&T Supermarket (tntsupermarket.com — supermarché
3 +# asiatique, magasin de Montréal / Saint-Laurent)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# Adobe Commerce (Magento PWA) headless derrière Akamai : requêtes directes
6 +# refusées (403), mais l'API GraphQL /graphql répond via Scrapfly ASP en GET
7 +# — SANS rendu JavaScript (1 requête légère par catégorie). Prix courant et
8 +# prix régulier dans price_range.minimum_price, stock, image et url_key.
9 +# Catégories via categoryList (uid base64) ; magasin « default » (EN).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import re
15 +import urllib.parse
16 +
17 +from ..schema import Product
18 +from .base import BaseConnector
19 +
20 +BASE = "https://www.tntsupermarket.com"
21 +GRAPHQL = f"{BASE}/graphql"
22 +
23 +# format en fin de nom : « Rice Cracker (180g) » -> « 180 g »
24 +_SIZE_IN_NAME_RE = re.compile(
25 + r"\(([^()]*?(\d+(?:[.,]\d+)?)\s*(kg|g|ml|l|lb|lbs|oz)\b[^()]*)\)",
26 + re.IGNORECASE)
27 +
28 +# catégories T&T : (uid GraphQL, taxonomie source, catégorie canonique)
29 +COLLECTIONS = [
30 + ("NTAzNQ==", "Fruits & Vegetables", "Fruits et légumes"),
31 + ("NTA1MQ==", "Meat & Seafood / Beef", "Viandes et volailles"),
32 + ("NTA1Mw==", "Meat & Seafood / Fish, Shrimps, Lobsters & Crabs",
33 + "Poissons et fruits de mer"),
34 + ("NDk5MQ==", "Dairy & Frozen / Dairy & Plant-Based Milk",
35 + "Produits laitiers et œufs"),
36 + ("NDk4Nw==", "Pantry & Sauce", "Garde-manger"),
37 + ("NDk4OA==", "Snacks & Drinks", "Collations et confiseries"),
38 + ("NTE3Ng==", "T&T Bakery", "Boulangerie"),
39 + ("NDk5NQ==", "Dairy & Frozen / Frozen Meals", "Surgelés"),
40 +]
41 +
42 +_QUERY = """query {
43 + products(filter: {category_uid: {eq: "%s"}} pageSize: %d currentPage: 1) {
44 + total_count
45 + items {
46 + sku
47 + name
48 + url_key
49 + stock_status
50 + price_range {
51 + minimum_price {
52 + final_price { value }
53 + regular_price { value }
54 + }
55 + }
56 + small_image { url }
57 + }
58 + }
59 +}"""
60 +
61 +
62 +class TntConnector(BaseConnector):
63 + source_id = "tt"
64 + request_delay = 1.0
65 +
66 + page_size: int = 50 # produits par catégorie — 1 requête GraphQL chacune
67 +
68 + # -- une catégorie = une requête GraphQL (GET, ASP sans rendu JS) -----------
69 + def _fetch_collection(self, uid: str) -> list[dict]:
70 + query = _QUERY % (uid, self.page_size)
71 + url = f"{GRAPHQL}?query={urllib.parse.quote(query)}"
72 + body = self.get_scrapfly(
73 + url, render_js=False, asp=True,
74 + headers={"Content-Type": "application/json"})
75 + data = json.loads(body)
76 + return ((data.get("data") or {}).get("products") or {}).get("items") or []
77 +
78 + def _parse_item(self, item: dict, category_raw: str,
79 + category: str) -> Product | None:
80 + name = (item.get("name") or "").strip()
81 + sku = str(item.get("sku") or "").strip()
82 + if not name or not sku:
83 + return None
84 + minimum = ((item.get("price_range") or {}).get("minimum_price")) or {}
85 + price = (minimum.get("final_price") or {}).get("value")
86 + regular = (minimum.get("regular_price") or {}).get("value")
87 + if not price or price <= 0:
88 + return None # pas de prix affiché = produit inutilisable
89 +
90 + size_label = ""
91 + m = _SIZE_IN_NAME_RE.search(name)
92 + if m:
93 + size_label = f"{m.group(2)} {m.group(3).lower()}"
94 +
95 + url_key = item.get("url_key") or ""
96 + image = ((item.get("small_image") or {}).get("url")) or ""
97 + stock = item.get("stock_status")
98 +
99 + return Product(
100 + source=self.source_id,
101 + external_id=sku,
102 + url=f"{BASE}/eng/{url_key}.html" if url_key else BASE,
103 + name=name,
104 + category=category,
105 + category_raw=category_raw,
106 + size_label=size_label,
107 + price=float(price),
108 + regular_price=(float(regular)
109 + if regular and regular > price else None),
110 + on_sale=bool(regular and regular > price),
111 + in_stock=(True if stock == "IN_STOCK"
112 + else False if stock == "OUT_OF_STOCK" else None),
113 + images=[image] if image.startswith("http") else [],
114 + )
115 +
116 + # -- contrat ---------------------------------------------------------------
117 + def fetch(self) -> list[Product]:
118 + products: list[Product] = []
119 + seen: set[str] = set()
120 + for uid, category_raw, category in COLLECTIONS:
121 + try:
122 + items = self._fetch_collection(uid)
123 + except Exception: # une catégorie qui casse ne bloque pas le reste
124 + continue
125 + for item in items:
126 + prod = self._parse_item(item, category_raw, category)
127 + if prod is not None and prod.uid not in seen:
128 + seen.add(prod.uid)
129 + products.append(prod)
130 + return products
added foodka/connectors/walmart.py +128 −0
@@ -0,0 +1,128 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Walmart Canada (walmart.ca, rayon épicerie)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Site Next.js derrière Akamai/PerimeterX (curl direct → HTTP 418) : on lit la
5 +# grille produits embarquée dans <script id="__NEXT_DATA__"> via Scrapfly ASP
6 +# (pas de rendu JavaScript nécessaire — le JSON est côté serveur).
7 +# URLs de rayon : /fr/browse/epicerie/{slug}/10019_{id} — seul l'id compte,
8 +# le slug est décoratif ; pagination par ?page=N (55 produits/page).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import json
13 +import re
14 +
15 +from ..schema import Product, parse_price
16 +from .base import BaseConnector
17 +
18 +_NEXT_RE = re.compile(
19 + r'<script id="__NEXT_DATA__" type="application/json"[^>]*>(.*?)</script>',
20 + re.S)
21 +# format dans le nom du produit : "567 g", "2 L", "12 x 355 ml"…
22 +_SIZE_IN_NAME = re.compile(
23 + r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b",
24 + re.IGNORECASE)
25 +
26 +# rayons épicerie Walmart : (slug décoratif, id de catégorie, catégorie canonique)
27 +CATEGORIES = [
28 + ("fruits-et-legumes-frais", "6000194327370", "Fruits et légumes"),
29 + ("charcuteries-et-repas-prepares", "6000194327356", "Charcuteries et fromages"),
30 + ("produits-laitiers-oeufs", "6000194327369", "Produits laitiers et œufs"),
31 + ("pains-et-boulangerie", "6000194327359", "Boulangerie"),
32 + ("aliments-surgeles", "6000194326337", "Surgelés"),
33 + ("garde-manger", "6000194326346", "Garde-manger"),
34 + ("boissons", "6000194326336", "Boissons"),
35 + ("collations-bonbons", "6000194328523", "Collations et confiseries"),
36 +]
37 +
38 +
39 +class WalmartConnector(BaseConnector):
40 + source_id = "walmart"
41 + pages_per_category = 2 # 55 produits/page → ~880 produits/sync
42 +
43 + # -- récupération ----------------------------------------------------------
44 + def _items(self, url: str) -> list[dict]:
45 + """Tuiles produit d'une page de rayon (JSON __NEXT_DATA__)."""
46 + html = self.get_scrapfly(url)
47 + m = _NEXT_RE.search(html)
48 + if not m:
49 + return []
50 + data = json.loads(m.group(1))
51 + try:
52 + sr = data["props"]["pageProps"]["initialData"]["searchResult"]
53 + except (KeyError, TypeError):
54 + return []
55 + items: list[dict] = []
56 + for stack in sr.get("itemStacks") or []:
57 + items.extend(stack.get("items") or [])
58 + return items
59 +
60 + # -- transformation --------------------------------------------------------
61 + def _item_to_product(self, item: dict, category: str) -> Product | None:
62 + if item.get("__typename") not in (None, "Product"):
63 + return None
64 + pid = item.get("usItemId") or item.get("id")
65 + name = item.get("name") or ""
66 + if not pid or not name:
67 + return None
68 + info = item.get("priceInfo") or {}
69 + # `price` est le prix courant numérique ; linePrice le libellé affiché
70 + price = parse_price(item.get("price"))
71 + if price is None:
72 + price = parse_price(info.get("linePrice"))
73 + was = parse_price(info.get("wasPrice"))
74 + link = item.get("canonicalUrl") or ""
75 + image = (item.get("imageInfo") or {}).get("thumbnailUrl") \
76 + or item.get("image") or ""
77 + cat_path = [p.get("name") for p in
78 + ((item.get("category") or {}).get("path") or [])
79 + if isinstance(p, dict) and p.get("name")]
80 + size_m = _SIZE_IN_NAME.search(name)
81 + out_of_stock = item.get("isOutOfStock")
82 + return Product(
83 + source=self.source_id,
84 + external_id=str(pid),
85 + url=f"https://www.walmart.ca{link}" if link.startswith("/") else link,
86 + name=name,
87 + brand=item.get("brand") or "",
88 + category=category,
89 + category_raw=" > ".join(cat_path),
90 + size_label=size_m.group(1) if size_m else "",
91 + price=price,
92 + regular_price=was,
93 + price_label=info.get("linePriceDisplay") or info.get("linePrice") or "",
94 + on_sale=bool(was and price and was > price),
95 + in_stock=(not out_of_stock) if out_of_stock is not None else None,
96 + unit_price_label=info.get("unitPrice") or "",
97 + details={"sales_unit": item.get("salesUnitType"),
98 + "seller": item.get("sellerName")},
99 + images=[image] if image else [],
100 + )
101 +
102 + # -- contrat ---------------------------------------------------------------
103 + def fetch(self) -> list[Product]:
104 + products: list[Product] = []
105 + seen: set[str] = set()
106 + for slug, cat_id, category in CATEGORIES:
107 + for page in range(1, self.pages_per_category + 1):
108 + url = (f"https://www.walmart.ca/fr/browse/epicerie/"
109 + f"{slug}/10019_{cat_id}?page={page}")
110 + try:
111 + items = self._items(url)
112 + except Exception: # un rayon qui casse ne bloque pas le reste
113 + break
114 + if not items:
115 + break
116 + new = 0
117 + for item in items:
118 + try:
119 + prod = self._item_to_product(item, category)
120 + except Exception:
121 + continue
122 + if prod and prod.uid not in seen:
123 + seen.add(prod.uid)
124 + products.append(prod)
125 + new += 1
126 + if new == 0: # page sans nouveauté = fin du rayon
127 + break
128 + return products
added foodka/db.py +293 −0
@@ -0,0 +1,293 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (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 (suivi des soldes), cache des pages détail.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import json
11 +import sqlite3
12 +import statistics
13 +import time
14 +from pathlib import Path
15 +
16 +from .schema import Product
17 +
18 +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "foodka.db"
19 +
20 +# Nombre d'exécutions consécutives où un produit doit être absent de la
21 +# source avant d'être désactivé (délai de grâce contre les ratés ponctuels).
22 +MISS_GRACE = 2
23 +
24 +# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique
25 +# (médiane >= DRIFT_MIN_BASE produits), on alerte et on suspend les retraits.
26 +DRIFT_RATIO = 0.25
27 +DRIFT_MIN_BASE = 25
28 +DRIFT_HISTORY = 5
29 +
30 +_SCHEMA = """
31 +CREATE TABLE IF NOT EXISTS products (
32 + uid TEXT PRIMARY KEY,
33 + source TEXT NOT NULL,
34 + external_id TEXT NOT NULL,
35 + url TEXT,
36 + name TEXT,
37 + brand TEXT,
38 + category TEXT,
39 + category_raw TEXT,
40 + size_label TEXT,
41 + price REAL,
42 + regular_price REAL,
43 + price_label TEXT,
44 + on_sale INTEGER DEFAULT 0,
45 + unit_price REAL,
46 + unit_price_label TEXT,
47 + in_stock INTEGER,
48 + description TEXT,
49 + keywords TEXT, -- JSON (tags source : bio, sans gluten…)
50 + details TEXT, -- JSON (champs structurés propres à la bannière)
51 + images TEXT, -- JSON
52 + content_hash TEXT,
53 + first_seen REAL,
54 + last_seen REAL,
55 + updated_at REAL,
56 + miss_count INTEGER DEFAULT 0,
57 + active INTEGER DEFAULT 1
58 +);
59 +CREATE INDEX IF NOT EXISTS idx_products_source ON products(source);
60 +CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);
61 +CREATE INDEX IF NOT EXISTS idx_products_active ON products(active);
62 +CREATE INDEX IF NOT EXISTS idx_products_sale ON products(on_sale);
63 +
64 +CREATE TABLE IF NOT EXISTS sync_log (
65 + id INTEGER PRIMARY KEY AUTOINCREMENT,
66 + source TEXT,
67 + ts REAL,
68 + found INTEGER,
69 + added INTEGER,
70 + updated INTEGER,
71 + removed INTEGER,
72 + ok INTEGER,
73 + message TEXT,
74 + stats TEXT -- JSON : taux de champs null, missed, alerte…
75 +);
76 +
77 +CREATE TABLE IF NOT EXISTS detail_cache (
78 + source TEXT NOT NULL,
79 + external_id TEXT NOT NULL,
80 + key TEXT, -- hash du contenu « liste » du produit
81 + payload TEXT, -- JSON opaque propre au connecteur
82 + fetched_at REAL,
83 + PRIMARY KEY (source, external_id)
84 +);
85 +
86 +CREATE TABLE IF NOT EXISTS price_log (
87 + uid TEXT NOT NULL,
88 + ts REAL NOT NULL,
89 + price REAL -- prix observé (NULL = retiré de l'affichage)
90 +);
91 +CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
92 +"""
93 +
94 +
95 +def connect() -> sqlite3.Connection:
96 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
97 + con = sqlite3.connect(DB_PATH)
98 + con.row_factory = sqlite3.Row
99 + con.executescript(_SCHEMA)
100 + con.commit()
101 + return con
102 +
103 +
104 +# ---------------------------------------------------------------------------
105 +# Synchronisation d'une source
106 +# ---------------------------------------------------------------------------
107 +
108 +def _drift_alert(con: sqlite3.Connection, source: str, found: int,
109 + null_price_rate: float) -> str | None:
110 + """Détecte une dérive du connecteur (chute du volume ou des prix extraits).
111 +
112 + Retourne un message d'alerte, ou None si tout est normal.
113 + """
114 + hist = con.execute(
115 + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1"
116 + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()
117 + if len(hist) < 3:
118 + return None
119 + med_found = statistics.median(r["found"] for r in hist)
120 + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:
121 + return (f"dérive: {found} produit(s) trouvé(s) contre une médiane de "
122 + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")
123 + if found >= DRIFT_MIN_BASE and null_price_rate >= 0.8:
124 + rates = []
125 + for r in hist:
126 + try:
127 + rates.append(json.loads(r["stats"] or "{}")["null_price_rate"])
128 + except (KeyError, ValueError, TypeError):
129 + continue
130 + if rates and statistics.median(rates) <= 0.3:
131 + return (f"dérive: {null_price_rate:.0%} des produits sans prix "
132 + f"(habituellement {statistics.median(rates):.0%}) — "
133 + "le format de la source a probablement changé")
134 + return None
135 +
136 +
137 +def sync_source(con: sqlite3.Connection, source: str,
138 + products: list[Product]) -> dict:
139 + """Synchronise les produits d'une source.
140 +
141 + - nouveau produit -> insertion
142 + - produit modifié -> mise à jour (comparaison de content_hash)
143 + - produit disparu -> miss_count += 1, puis active=0 après MISS_GRACE
144 + exécutions consécutives (délai de grâce)
145 + - dérive détectée -> alerte consignée, retraits suspendus
146 + Chaque changement de prix est consigné dans price_log (suivi des soldes).
147 + """
148 + now = time.time()
149 + added = updated = 0
150 + seen_uids = set()
151 +
152 + n = len(products)
153 + null_price = sum(1 for p in products if p.price is None)
154 + null_name = sum(1 for p in products if not p.name)
155 + null_price_rate = round(null_price / n, 3) if n else 0.0
156 +
157 + alert = _drift_alert(con, source, n, null_price_rate)
158 +
159 + for prod in products:
160 + if prod.uid in seen_uids: # doublon intra-source (pagination qui déborde)
161 + continue
162 + seen_uids.add(prod.uid)
163 + h = prod.content_hash()
164 + row = con.execute("SELECT content_hash, price FROM products WHERE uid=?",
165 + (prod.uid,)).fetchone()
166 + params = dict(
167 + uid=prod.uid, source=prod.source, external_id=prod.external_id,
168 + url=prod.url, name=prod.name, brand=prod.brand,
169 + category=prod.category, category_raw=prod.category_raw,
170 + size_label=prod.size_label, price=prod.price,
171 + regular_price=prod.regular_price, price_label=prod.price_label,
172 + on_sale=int(prod.on_sale),
173 + unit_price=prod.unit_price, unit_price_label=prod.unit_price_label,
174 + in_stock=(None if prod.in_stock is None else int(prod.in_stock)),
175 + description=prod.description,
176 + keywords=json.dumps(prod.keywords, ensure_ascii=False),
177 + details=json.dumps(prod.details, ensure_ascii=False),
178 + images=json.dumps(prod.images, ensure_ascii=False),
179 + content_hash=h, now=now,
180 + )
181 + if row is None:
182 + con.execute(
183 + """INSERT INTO products (uid, source, external_id, url, name,
184 + brand, category, category_raw, size_label, price,
185 + regular_price, price_label, on_sale, unit_price,
186 + unit_price_label, in_stock, description, keywords, details,
187 + images, content_hash, first_seen, last_seen, updated_at,
188 + miss_count, active)
189 + VALUES (:uid,:source,:external_id,:url,:name,:brand,
190 + :category,:category_raw,:size_label,:price,:regular_price,
191 + :price_label,:on_sale,:unit_price,:unit_price_label,
192 + :in_stock,:description,:keywords,:details,:images,
193 + :content_hash,:now,:now,:now,0,1)""", params)
194 + if prod.price is not None: # prix initial = point de départ de l'historique
195 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
196 + (prod.uid, now, prod.price))
197 + added += 1
198 + elif row["content_hash"] != h:
199 + con.execute(
200 + """UPDATE products SET url=:url, name=:name, brand=:brand,
201 + category=:category, category_raw=:category_raw,
202 + size_label=:size_label, price=:price,
203 + regular_price=:regular_price, price_label=:price_label,
204 + on_sale=:on_sale, unit_price=:unit_price,
205 + unit_price_label=:unit_price_label, in_stock=:in_stock,
206 + description=:description, keywords=:keywords,
207 + details=:details, images=:images,
208 + content_hash=:content_hash, last_seen=:now,
209 + updated_at=:now, miss_count=0, active=1
210 + WHERE uid=:uid""", params)
211 + if prod.price != row["price"]: # changement de prix -> historique
212 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
213 + (prod.uid, now, prod.price))
214 + updated += 1
215 + else:
216 + con.execute(
217 + "UPDATE products SET last_seen=?, miss_count=0, active=1 WHERE uid=?",
218 + (now, prod.uid))
219 +
220 + # Produits de cette source qui n'apparaissent plus : délai de grâce,
221 + # puis désactivation. Suspendu si une dérive est détectée.
222 + removed = missed = 0
223 + if not alert:
224 + for r in con.execute(
225 + "SELECT uid, miss_count FROM products WHERE source=? AND active=1",
226 + (source,)).fetchall():
227 + if r["uid"] in seen_uids:
228 + continue
229 + missed += 1
230 + if r["miss_count"] + 1 >= MISS_GRACE:
231 + con.execute(
232 + "UPDATE products SET active=0, miss_count=?, updated_at=?"
233 + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"]))
234 + removed += 1
235 + else:
236 + con.execute("UPDATE products SET miss_count=miss_count+1 WHERE uid=?",
237 + (r["uid"],))
238 +
239 + stats = {
240 + "null_price_rate": null_price_rate,
241 + "null_name_rate": round(null_name / n, 3) if n else 0.0,
242 + "missed": missed,
243 + }
244 + if alert:
245 + stats["alert"] = alert
246 + con.execute(
247 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"
248 + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",
249 + (source, now, n, added, updated, removed, alert or "ok",
250 + json.dumps(stats, ensure_ascii=False)))
251 + con.commit()
252 + out = {"source": source, "found": n, "added": added,
253 + "updated": updated, "removed": removed}
254 + if alert:
255 + out["alert"] = alert
256 + return out
257 +
258 +
259 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
260 + con.execute(
261 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
262 + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
263 + con.commit()
264 +
265 +
266 +# ---------------------------------------------------------------------------
267 +# Cache des pages détail (« détail si nouveau/modifié »)
268 +# ---------------------------------------------------------------------------
269 +
270 +def get_cached_detail(con: sqlite3.Connection, source: str,
271 + external_id: str, key: str) -> dict | None:
272 + """Payload détail mis en cache si la clé (hash liste) n'a pas changé."""
273 + row = con.execute(
274 + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",
275 + (source, external_id)).fetchone()
276 + if row and row["key"] == key and row["payload"]:
277 + try:
278 + return json.loads(row["payload"])
279 + except ValueError:
280 + return None
281 + return None
282 +
283 +
284 +def put_cached_detail(con: sqlite3.Connection, source: str,
285 + external_id: str, key: str, payload: dict) -> None:
286 + con.execute(
287 + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"
288 + " VALUES (?,?,?,?,?)"
289 + " ON CONFLICT(source, external_id) DO UPDATE SET"
290 + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",
291 + (source, external_id, key, json.dumps(payload, ensure_ascii=False),
292 + time.time()))
293 + con.commit()
added foodka/ingest.py +62 −0
@@ -0,0 +1,62 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (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) = contenu toujours à 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"[food-ka] connecteur inconnu : {sid}", file=sys.stderr)
26 + continue
27 + t0 = time.time()
28 + print(f"[food-ka] sync {sid} ...")
29 + try:
30 + products = cls().fetch()
31 + finalized, dropped = [], 0
32 + for prod in products:
33 + try:
34 + finalized.append(prod.finalize())
35 + except Exception: # un produit malformé 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"[food-ka] ⚠ ALERTE {sid} : {stats['alert']}")
43 + print(f"[food-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 = 3600) -> None:
54 + """Boucle de rafraîchissement périodique (pseudo-webhook par sondage)."""
55 + while True:
56 + run()
57 + print(f"[food-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 foodka/marketstats.py +167 −0
@@ -0,0 +1,167 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# marketstats.py : agrégats du marché — source unique de /api/stats/detailed
5 +# Métriques par bannière (prix médian, part de soldes, rabais moyen…),
6 +# matrice catégorie × bannière, panier comparatif, baisses de prix récentes.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import statistics
11 +import time
12 +
13 +from . import db
14 +
15 +# Panier comparatif : produits courants repérés par mots-clés (fr + en).
16 +# Pour chaque bannière on prend le PRIX MÉDIAN des produits correspondants —
17 +# robuste face aux formats extrêmes (mini-format, caisse de grossiste).
18 +BASKET = [
19 + ("Lait", ["lait", "milk"]),
20 + ("Œufs", ["oeuf", "œuf", "egg"]),
21 + ("Pain", ["pain", "bread"]),
22 + ("Beurre", ["beurre", "butter"]),
23 + ("Bananes", ["banane", "banana"]),
24 + ("Poulet", ["poulet", "chicken"]),
25 + ("Fromage", ["fromage", "cheese"]),
26 + ("Riz", ["riz", "rice"]),
27 + ("Pâtes", ["pâtes", "pates", "spaghetti", "pasta"]),
28 + ("Café", ["café", "cafe", "coffee"]),
29 +]
30 +
31 +_PRICE_SANE = "price IS NOT NULL AND price > 0 AND price <= 2000"
32 +
33 +
34 +def _median(values: list[float]) -> float | None:
35 + return round(statistics.median(values), 2) if values else None
36 +
37 +
38 +def compute() -> dict:
39 + con = db.connect()
40 + now = time.time()
41 +
42 + # -- par bannière ----------------------------------------------------------
43 + by_source: dict[str, dict] = {}
44 + for r in con.execute(
45 + f"""SELECT source, COUNT(*) n, SUM(on_sale) sales,
46 + AVG(price) avg_price,
47 + SUM(CASE WHEN unit_price IS NOT NULL THEN 1 ELSE 0 END) with_unit,
48 + SUM(CASE WHEN {_PRICE_SANE} THEN 0 ELSE 1 END) no_price
49 + FROM products WHERE active=1 GROUP BY source"""):
50 + by_source[r["source"]] = {
51 + "source": r["source"], "n": r["n"], "sales": r["sales"] or 0,
52 + "sale_share": round((r["sales"] or 0) / r["n"], 3) if r["n"] else 0,
53 + "avg_price": round(r["avg_price"], 2) if r["avg_price"] else None,
54 + "with_unit_price": r["with_unit"], "no_price": r["no_price"],
55 + }
56 + # médiane des prix + rabais moyen (nécessite les valeurs)
57 + for src, d in by_source.items():
58 + prices = [r["price"] for r in con.execute(
59 + f"SELECT price FROM products WHERE active=1 AND source=? AND {_PRICE_SANE}",
60 + (src,))]
61 + d["median_price"] = _median(prices)
62 + discounts = [(r["regular_price"] - r["price"]) / r["regular_price"]
63 + for r in con.execute(
64 + f"""SELECT price, regular_price FROM products
65 + WHERE active=1 AND source=? AND on_sale=1
66 + AND regular_price IS NOT NULL AND {_PRICE_SANE}
67 + AND regular_price > price""", (src,))]
68 + d["avg_discount_pct"] = round(100 * statistics.mean(discounts), 1) if discounts else None
69 + d["max_discount_pct"] = round(100 * max(discounts), 1) if discounts else None
70 +
71 + # -- matrice catégorie × bannière (prix médian) ----------------------------
72 + matrix: dict[str, dict[str, dict]] = {}
73 + rows = con.execute(
74 + f"""SELECT category, source, price FROM products
75 + WHERE active=1 AND category<>'' AND category<>'Autres' AND {_PRICE_SANE}"""
76 + ).fetchall()
77 + bucket: dict[tuple[str, str], list[float]] = {}
78 + for r in rows:
79 + bucket.setdefault((r["category"], r["source"]), []).append(r["price"])
80 + for (cat, src), prices in bucket.items():
81 + matrix.setdefault(cat, {})[src] = {
82 + "median_price": _median(prices), "n": len(prices)}
83 +
84 + # -- panier comparatif ------------------------------------------------------
85 + basket: list[dict] = []
86 + for label, keywords in BASKET:
87 + like = " OR ".join(["name LIKE ?"] * len(keywords))
88 + args = [f"%{k}%" for k in keywords]
89 + per_source: dict[str, dict] = {}
90 + for r in con.execute(
91 + f"""SELECT source, price FROM products
92 + WHERE active=1 AND {_PRICE_SANE} AND ({like})""", args):
93 + per_source.setdefault(r["source"], []).append(r["price"])
94 + basket.append({
95 + "item": label,
96 + "by_source": {src: {"median_price": _median(v), "n": len(v)}
97 + for src, v in per_source.items()},
98 + })
99 + # total du panier par bannière (bannières couvrant >= 6 items sur 10)
100 + basket_totals = []
101 + for src in by_source:
102 + items = [b["by_source"][src]["median_price"] for b in basket
103 + if src in b["by_source"] and b["by_source"][src]["median_price"]]
104 + if len(items) >= 6:
105 + basket_totals.append({"source": src, "items": len(items),
106 + "total": round(sum(items), 2)})
107 + basket_totals.sort(key=lambda x: x["total"] / max(x["items"], 1))
108 +
109 + # -- baisses de prix récentes (7 jours) -------------------------------------
110 + week_ago = now - 7 * 86400
111 + drops = []
112 + for r in con.execute(
113 + """SELECT p.uid, p.name, p.source, p.images, l.ts, l.price new_price,
114 + (SELECT l2.price FROM price_log l2
115 + WHERE l2.uid = l.uid AND l2.ts < l.ts
116 + ORDER BY l2.ts DESC LIMIT 1) old_price
117 + FROM price_log l JOIN products p ON p.uid = l.uid
118 + WHERE l.ts >= ? AND p.active = 1
119 + ORDER BY l.ts DESC LIMIT 400""", (week_ago,)):
120 + old, new = r["old_price"], r["new_price"]
121 + if old and new and new < old:
122 + drops.append({"uid": r["uid"], "name": r["name"], "source": r["source"],
123 + "old_price": old, "new_price": new,
124 + "drop_pct": round(100 * (old - new) / old, 1)})
125 + drops.sort(key=lambda d: -d["drop_pct"])
126 + price_changes_7d = con.execute(
127 + "SELECT COUNT(*) c FROM price_log WHERE ts >= ?", (week_ago,)).fetchone()["c"]
128 +
129 + # -- distribution des prix (paliers) ----------------------------------------
130 + buckets = [(0, 2), (2, 5), (5, 10), (10, 20), (20, 50), (50, 2000)]
131 + distribution = []
132 + for lo, hi in buckets:
133 + c = con.execute(
134 + f"SELECT COUNT(*) c FROM products WHERE active=1 AND {_PRICE_SANE}"
135 + " AND price >= ? AND price < ?", (lo, hi)).fetchone()["c"]
136 + label = f"{lo}{hi} $" if hi <= 50 else f"{lo} $ +"
137 + distribution.append({"range": label, "n": c})
138 +
139 + # -- global ------------------------------------------------------------------
140 + g = con.execute(
141 + f"""SELECT COUNT(*) total, SUM(on_sale) on_sale,
142 + COUNT(DISTINCT source) sources,
143 + COUNT(DISTINCT category) categories,
144 + AVG(price) avg_price,
145 + COUNT(DISTINCT brand) brands
146 + FROM products WHERE active=1""").fetchone()
147 + all_prices = [r["price"] for r in con.execute(
148 + f"SELECT price FROM products WHERE active=1 AND {_PRICE_SANE}")]
149 + con.close()
150 +
151 + return {
152 + "global": {
153 + "total": g["total"], "on_sale": g["on_sale"] or 0,
154 + "sale_share": round((g["on_sale"] or 0) / g["total"], 3) if g["total"] else 0,
155 + "sources": g["sources"], "categories": g["categories"],
156 + "brands": g["brands"],
157 + "avg_price": round(g["avg_price"], 2) if g["avg_price"] else None,
158 + "median_price": _median(all_prices),
159 + "price_changes_7d": price_changes_7d,
160 + },
161 + "by_source": sorted(by_source.values(), key=lambda d: -d["n"]),
162 + "category_matrix": matrix,
163 + "basket": basket,
164 + "basket_totals": basket_totals,
165 + "price_drops": drops[:30],
166 + "price_distribution": distribution,
167 + }
added foodka/normalize.py +209 −0
@@ -0,0 +1,209 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# normalize.py : normalisation des champs bruts des connecteurs
5 +# prix, formats (450 g, 2 L, 12 un), prix unitaire ($/100 g),
6 +# catégories canoniques, marques.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import re
11 +import unicodedata
12 +
13 +__all__ = [
14 + "strip_accents", "parse_price", "parse_size", "unit_price",
15 + "format_unit_price", "normalize_category", "clean_text",
16 +]
17 +
18 +
19 +def strip_accents(text: str) -> str:
20 + return "".join(c for c in unicodedata.normalize("NFD", text or "")
21 + if unicodedata.category(c) != "Mn")
22 +
23 +
24 +def clean_text(text: str | None) -> str:
25 + """Espaces normalisés, entités résiduelles retirées."""
26 + if not text:
27 + return ""
28 + return re.sub(r"\s+", " ", text).strip()
29 +
30 +
31 +# ---------------------------------------------------------------------------
32 +# Prix — "3,49 $", "$3.49", "2 / 5,00 $", "349¢"
33 +# ---------------------------------------------------------------------------
34 +
35 +_PRICE_RE = re.compile(r"(\d{1,4}(?:[  ]\d{3})*(?:[.,]\d{1,2})?)\s*\$|\$\s*(\d{1,4}(?:[.,]\d{1,2})?)")
36 +_MULTI_RE = re.compile(r"(\d+)\s*(?:/|pour|for)\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*\$")
37 +_CENTS_RE = re.compile(r"(\d{1,3})\s*¢")
38 +
39 +
40 +def parse_price(label: str | float | int | None) -> float | None:
41 + """Extrait un prix unitaire ($ CAD) d'un libellé source.
42 +
43 + Gère "2 / 5,00 $" (prix multi-achat → prix à l'unité), "3,49 $",
44 + "$3.49" et "99 ¢". Retourne None si aucun prix affiché — jamais
45 + de prix inventé.
46 + """
47 + if label is None:
48 + return None
49 + if isinstance(label, (int, float)):
50 + return float(label) if label > 0 else None
51 + text = str(label)
52 + m = _MULTI_RE.search(text)
53 + if m:
54 + n, total = int(m.group(1)), float(m.group(2).replace(",", "."))
55 + if n > 0:
56 + return round(total / n, 2)
57 + m = _PRICE_RE.search(text)
58 + if m:
59 + raw = (m.group(1) or m.group(2)).replace(" ", "").replace(" ", "")
60 + return float(raw.replace(",", "."))
61 + m = _CENTS_RE.search(text)
62 + if m:
63 + return round(int(m.group(1)) / 100, 2)
64 + return None
65 +
66 +
67 +# ---------------------------------------------------------------------------
68 +# Formats — "450 g", "1,5 L", "12 x 355 ml", "6 un", "environ 200 g"
69 +# ---------------------------------------------------------------------------
70 +
71 +# unité canonique -> (facteur vers unité de base, unité de base)
72 +_UNITS = {
73 + "kg": (1000.0, "g"), "g": (1.0, "g"), "mg": (0.001, "g"),
74 + "lb": (453.592, "g"), "lbs": (453.592, "g"), "oz": (28.3495, "g"),
75 + "l": (1000.0, "ml"), "ml": (1.0, "ml"), "cl": (10.0, "ml"),
76 + "un": (1.0, "un"), "unite": (1.0, "un"), "unites": (1.0, "un"),
77 + "ea": (1.0, "un"), "each": (1.0, "un"), "pk": (1.0, "un"),
78 +}
79 +
80 +_SIZE_RE = re.compile(
81 + r"(?:(\d+)\s*[x×]\s*)?(\d+(?:[.,]\d+)?)\s*(kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk)\b",
82 + re.IGNORECASE)
83 +
84 +
85 +def parse_size(label: str | None) -> tuple[float, str] | None:
86 + """Extrait (quantité, unité de base) d'un format.
87 +
88 + "450 g" -> (450, "g") ; "2 L" -> (2000, "ml") ;
89 + "12 x 355 ml" -> (4260, "ml") ; "6 un" -> (6, "un").
90 + """
91 + if not label:
92 + return None
93 + m = _SIZE_RE.search(strip_accents(label))
94 + if not m:
95 + return None
96 + mult = int(m.group(1)) if m.group(1) else 1
97 + qty = float(m.group(2).replace(",", "."))
98 + factor, base = _UNITS[m.group(3).lower()]
99 + total = mult * qty * factor
100 + return (total, base) if total > 0 else None
101 +
102 +
103 +def unit_price(price: float | None, size_label: str | None) -> tuple[float, str] | None:
104 + """Prix par unité comparable : $/100 g, $/100 ml ou $/un."""
105 + if price is None:
106 + return None
107 + size = parse_size(size_label)
108 + if not size:
109 + return None
110 + qty, base = size
111 + if base == "un":
112 + return (round(price / qty, 2), "un")
113 + return (round(price / qty * 100, 3), f"100 {base}")
114 +
115 +
116 +def format_unit_price(up: tuple[float, str] | None) -> str:
117 + if not up:
118 + return ""
119 + value, base = up
120 + return f"{value:.2f} $ / {base}".replace(".", ",")
121 +
122 +
123 +# ---------------------------------------------------------------------------
124 +# Catégories canoniques — chaque bannière a sa propre taxonomie ; on la
125 +# rabat sur un ensemble commun pour permettre les filtres inter-épiceries.
126 +# ---------------------------------------------------------------------------
127 +
128 +CATEGORIES = [
129 + "Fruits et légumes",
130 + "Viandes et volailles",
131 + "Poissons et fruits de mer",
132 + "Charcuteries et fromages",
133 + "Produits laitiers et œufs",
134 + "Boulangerie",
135 + "Surgelés",
136 + "Garde-manger",
137 + "Boissons",
138 + "Collations et confiseries",
139 + "Prêt-à-manger",
140 + "Bio et santé",
141 + "Bébé",
142 + "Animaux",
143 + "Maison et entretien",
144 + "Santé et beauté",
145 + "Autres",
146 +]
147 +
148 +# mots-clés (sans accents, minuscules) -> catégorie canonique ; l'ordre compte,
149 +# le premier motif trouvé gagne.
150 +_CATEGORY_RULES: list[tuple[str, str]] = [
151 + ("fruit", "Fruits et légumes"), ("legume", "Fruits et légumes"),
152 + ("produce", "Fruits et légumes"), ("vegetable", "Fruits et légumes"),
153 + ("charcuterie", "Charcuteries et fromages"), ("deli", "Charcuteries et fromages"),
154 + ("fromage", "Charcuteries et fromages"), ("cheese", "Charcuteries et fromages"),
155 + ("viande", "Viandes et volailles"), ("volaille", "Viandes et volailles"),
156 + ("boeuf", "Viandes et volailles"), ("porc", "Viandes et volailles"),
157 + ("poulet", "Viandes et volailles"), ("meat", "Viandes et volailles"),
158 + ("poisson", "Poissons et fruits de mer"), ("fruits de mer", "Poissons et fruits de mer"),
159 + ("seafood", "Poissons et fruits de mer"), ("poissonnerie", "Poissons et fruits de mer"),
160 + ("laitier", "Produits laitiers et œufs"), ("lait", "Produits laitiers et œufs"),
161 + ("oeuf", "Produits laitiers et œufs"), ("dairy", "Produits laitiers et œufs"),
162 + ("yogourt", "Produits laitiers et œufs"), ("egg", "Produits laitiers et œufs"),
163 + ("boulangerie", "Boulangerie"), ("pain", "Boulangerie"),
164 + ("patisserie", "Boulangerie"), ("bakery", "Boulangerie"),
165 + ("surgele", "Surgelés"), ("congele", "Surgelés"), ("frozen", "Surgelés"),
166 + ("boisson", "Boissons"), ("jus", "Boissons"), ("eau", "Boissons"),
167 + ("cafe", "Boissons"), ("the ", "Boissons"), ("biere", "Boissons"),
168 + ("vin", "Boissons"), ("beverage", "Boissons"), ("drink", "Boissons"),
169 + ("collation", "Collations et confiseries"), ("croustille", "Collations et confiseries"),
170 + ("bonbon", "Collations et confiseries"), ("chocolat", "Collations et confiseries"),
171 + ("confiserie", "Collations et confiseries"), ("snack", "Collations et confiseries"),
172 + ("candy", "Collations et confiseries"), ("biscuit", "Collations et confiseries"),
173 + ("pret-a-manger", "Prêt-à-manger"), ("prets-a-manger", "Prêt-à-manger"),
174 + ("repas prepare", "Prêt-à-manger"), ("mets prepare", "Prêt-à-manger"),
175 + ("prepared", "Prêt-à-manger"), ("ready", "Prêt-à-manger"),
176 + ("biologique", "Bio et santé"), ("organic", "Bio et santé"),
177 + ("naturel", "Bio et santé"), ("sante et mieux", "Bio et santé"),
178 + ("vrac", "Bio et santé"), ("supplement", "Bio et santé"),
179 + ("bebe", "Bébé"), ("baby", "Bébé"), ("couche", "Bébé"),
180 + ("animaux", "Animaux"), ("animal", "Animaux"), ("pet", "Animaux"),
181 + ("nettoyage", "Maison et entretien"), ("entretien", "Maison et entretien"),
182 + ("menager", "Maison et entretien"), ("maison", "Maison et entretien"),
183 + ("cleaning", "Maison et entretien"), ("household", "Maison et entretien"),
184 + ("papier", "Maison et entretien"), ("lessive", "Maison et entretien"),
185 + ("beaute", "Santé et beauté"), ("pharmacie", "Santé et beauté"),
186 + ("hygiene", "Santé et beauté"), ("soin", "Santé et beauté"),
187 + ("health", "Santé et beauté"), ("beauty", "Santé et beauté"),
188 + ("cosmetique", "Santé et beauté"),
189 + ("epicerie", "Garde-manger"), ("garde-manger", "Garde-manger"),
190 + ("pantry", "Garde-manger"), ("grocery", "Garde-manger"),
191 + ("conserve", "Garde-manger"), ("pates", "Garde-manger"),
192 + ("cereale", "Garde-manger"), ("condiment", "Garde-manger"),
193 + ("sauce", "Garde-manger"), ("dejeuner", "Garde-manger"),
194 + ("cuisine du monde", "Garde-manger"), ("international", "Garde-manger"),
195 +]
196 +
197 +
198 +def normalize_category(raw: str | None) -> str:
199 + """Rabat une catégorie source (fr/en, n'importe quelle bannière) sur la
200 + taxonomie canonique Food-Ka. Retourne "Autres" si rien ne correspond."""
201 + if not raw:
202 + return "Autres"
203 + if raw in CATEGORIES:
204 + return raw
205 + key = strip_accents(raw).lower()
206 + for pattern, cat in _CATEGORY_RULES:
207 + if pattern in key:
208 + return cat
209 + return "Autres"
added foodka/pdfgen.py +804 −0
@@ -0,0 +1,804 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# pdfgen.py : génération PDF au style « éditorial sharp — marché frais »
5 +# rapport_pdf() : rapport global du marché — miroir exact de la page
6 +# Statistiques du site (tuiles héro, panier comparatif, bannières en
7 +# chiffres, matrice catégorie × bannière, distribution des prix, baisses
8 +# de prix 7 j, meilleures aubaines, journal des synchronisations).
9 +# Palette identique au site : papier crème #faf6ee, encre #14231a,
10 +# vert marché #1f7a4d, lime #d9f26b, tomate #e8542f. Logos des bannières
11 +# dessinés dans les en-têtes de tableaux (frontend/public/logos/*.png).
12 +# Polices de base (Helvetica/Courier) stylées par la mise en page —
13 +# rectangles à bordure épaisse et ombre décalée pleine, aucun emoji.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import io
18 +import time
19 +from pathlib import Path
20 +
21 +from reportlab.lib.colors import HexColor
22 +from reportlab.lib.pagesizes import letter
23 +from reportlab.lib.utils import ImageReader
24 +from reportlab.pdfgen import canvas as rl_canvas
25 +
26 +from . import db
27 +
28 +# --- palette Food-Ka (celle du site) -----------------------------------------
29 +PAPER = HexColor("#faf6ee")
30 +SURFACE = HexColor("#f7f2e7")
31 +WHITE = HexColor("#ffffff")
32 +INK = HexColor("#14231a")
33 +INK2 = HexColor("#46564b")
34 +INK3 = HexColor("#7d8a80")
35 +GREEN = HexColor("#1f7a4d")
36 +GREEN_DEEP = HexColor("#14523a")
37 +GREEN_SOFT = HexColor("#ddeedd") # ≈ rgba(46,158,99,.16) sur crème
38 +LIME = HexColor("#d9f26b")
39 +TOMATO = HexColor("#e8542f")
40 +TOMATO_SOFT = HexColor("#fbe3da")
41 +
42 +PAGE_W, PAGE_H = letter
43 +M = 40 # marge
44 +NBSP = " "
45 +LOGOS_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "logos"
46 +
47 +# Noms d'affichage des bannières (mêmes que le frontend)
48 +SOURCE_NAMES = {
49 + "metro": "Metro", "superc": "Super C", "iga": "IGA", "maxi": "Maxi",
50 + "provigo": "Provigo", "walmart": "Walmart", "costco": "Costco",
51 + "adonis": "Adonis", "pa": "PA", "giant_tiger": "Giant Tiger",
52 + "epipresto": "Epipresto", "nuvo": "Nuvo", "boite_a_grains": "Boîte à Grains",
53 + "bocoboco": "BocoBoco", "aliments_merci": "Aliments Merci",
54 + "mayrand": "Mayrand", "aubut": "Aubut", "maturin": "Maturin",
55 + "avril": "Avril", "tau": "Tau",
56 +}
57 +
58 +
59 +def _nom(src: str) -> str:
60 + return SOURCE_NAMES.get(src, src.replace("_", " ").title())
61 +
62 +
63 +# --- formats fr-CA ------------------------------------------------------------
64 +
65 +def _fmt_i(n) -> str:
66 + return f"{n:,}".replace(",", NBSP) if n is not None else "—"
67 +
68 +
69 +def _fmt_money(v) -> str:
70 + if v is None:
71 + return "—"
72 + return f"{v:,.2f}".replace(",", NBSP).replace(".", ",") + f"{NBSP}$"
73 +
74 +
75 +def _fmt_pct(v, digits: int = 0) -> str:
76 + if v is None:
77 + return "—"
78 + txt = f"{v:.{digits}f}".replace(".", ",")
79 + return f"{txt}{NBSP}%"
80 +
81 +
82 +# --- logos des bannières --------------------------------------------------------
83 +
84 +_LOGO_CACHE: dict[str, tuple[ImageReader, float] | None] = {}
85 +
86 +
87 +def _logo(src: str) -> tuple[ImageReader, float] | None:
88 + """ImageReader du logo (aplati sur blanc) + ratio largeur/hauteur.
89 + Certains fichiers .png sont en réalité des JPEG — Pillow s'en moque."""
90 + if src in _LOGO_CACHE:
91 + return _LOGO_CACHE[src]
92 + out = None
93 + try:
94 + from PIL import Image
95 + p = LOGOS_DIR / f"{src}.png"
96 + if p.exists():
97 + im = Image.open(p).convert("RGBA")
98 + fond = Image.new("RGBA", im.size, (255, 255, 255, 255))
99 + im = Image.alpha_composite(fond, im).convert("RGB")
100 + b = io.BytesIO()
101 + im.save(b, format="PNG")
102 + out = (ImageReader(io.BytesIO(b.getvalue())), im.width / im.height)
103 + except Exception:
104 + out = None
105 + _LOGO_CACHE[src] = out
106 + return out
107 +
108 +
109 +def _initiales(src: str) -> str:
110 + mots = [m for m in _nom(src).replace("·", " ").split() if m]
111 + return "".join(m[0] for m in mots[:2]).upper() or "?"
112 +
113 +
114 +def _dessine_logo(c: rl_canvas.Canvas, src: str, x: float, y: float,
115 + w: float, h: float, cadre: bool = True):
116 + """Logo de la bannière dans une boîte blanche (x,y = coin bas-gauche).
117 + Repli gracieux : jeton vert avec initiales si le fichier manque."""
118 + lg = _logo(src)
119 + if cadre:
120 + c.setFillColor(WHITE)
121 + c.setStrokeColor(INK)
122 + c.setLineWidth(0.8)
123 + c.roundRect(x, y, w, h, 2, stroke=1, fill=1)
124 + if lg is not None:
125 + img, ratio = lg
126 + iw, ih = w - 4, h - 4
127 + if iw / ih > ratio:
128 + iw = ih * ratio
129 + else:
130 + ih = iw / ratio
131 + try:
132 + c.drawImage(img, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih, mask="auto")
133 + return
134 + except Exception:
135 + pass
136 + # repli : jeton coloré avec initiales
137 + c.setFillColor(GREEN)
138 + c.roundRect(x + 1.5, y + 1.5, w - 3, h - 3, 2, stroke=0, fill=1)
139 + c.setFillColor(LIME)
140 + c.setFont("Helvetica-Bold", min(8.0, h * 0.42))
141 + c.drawCentredString(x + w / 2, y + h / 2 - min(8.0, h * 0.42) * 0.36, _initiales(src))
142 +
143 +
144 +def _en_tete_bannière(c: rl_canvas.Canvas, src: str, x: float, y_top: float,
145 + w: float, box_h: float = 24) -> float:
146 + """En-tête de colonne : logo dans une boîte blanche + nom court dessous.
147 + Retourne la hauteur totale occupée."""
148 + bw = min(w - 4, 40)
149 + _dessine_logo(c, src, x + (w - bw) / 2, y_top - box_h, bw, box_h)
150 + c.setFont("Helvetica-Bold", 5.4)
151 + c.setFillColor(INK2)
152 + nom = _nom(src)
153 + while c.stringWidth(nom, "Helvetica-Bold", 5.4) > w - 2 and len(nom) > 3:
154 + nom = nom[:-2] + "…"
155 + c.drawCentredString(x + w / 2, y_top - box_h - 7.5, nom)
156 + return box_h + 11
157 +
158 +
159 +# --- composants du style « éditorial sharp — marché frais » ---------------------
160 +
161 +class _Style:
162 + def __init__(self, c: rl_canvas.Canvas):
163 + self.c = c
164 +
165 + def fond(self):
166 + self.c.setFillColor(PAPER)
167 + self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)
168 +
169 + def logo(self, x: float, y: float, taille: float = 22) -> float:
170 + """Wordmark Food·Ka : « Food » encre + boîte encre avec « Ka » lime."""
171 + c = self.c
172 + c.setFont("Helvetica-Bold", taille)
173 + c.setFillColor(INK)
174 + c.drawString(x, y, "Food")
175 + w = c.stringWidth("Food", "Helvetica-Bold", taille)
176 + bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8
177 + c.saveState()
178 + c.translate(x + w + 3 + bw / 2, y + taille * 0.32)
179 + c.rotate(-3)
180 + c.setFillColor(INK)
181 + c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 3, stroke=0, fill=1)
182 + c.setFillColor(LIME)
183 + c.drawCentredString(0, -taille * 0.30, "Ka")
184 + c.restoreState()
185 + return x + w + 6 + bw
186 +
187 + def entete(self, titre: str) -> float:
188 + c = self.c
189 + self.logo(M, PAGE_H - M - 16)
190 + c.setFont("Courier-Bold", 8)
191 + c.setFillColor(INK3)
192 + c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper())
193 + c.drawRightString(PAGE_W - M, PAGE_H - M - 16,
194 + time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.FOOD-KA.COM"))
195 + c.setStrokeColor(INK)
196 + c.setLineWidth(2)
197 + c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)
198 + return PAGE_H - M - 44
199 +
200 + def pied(self, page: int):
201 + c = self.c
202 + c.setStrokeColor(INK)
203 + c.setLineWidth(1)
204 + c.line(M, M + 16, PAGE_W - M, M + 16)
205 + c.setFont("Helvetica", 6.5)
206 + c.setFillColor(INK3)
207 + c.drawString(M, M + 6, "Food·Ka — www.food-ka.com — © 2026 Simon-Pierre Boucher")
208 + c.setFont("Courier-Bold", 7)
209 + c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")
210 +
211 + def titre_section(self, y: float, texte: str) -> float:
212 + c = self.c
213 + c.setFont("Helvetica-Bold", 12.5)
214 + c.setFillColor(INK)
215 + c.drawString(M, y, texte.upper())
216 + c.setStrokeColor(LIME)
217 + c.setLineWidth(3)
218 + c.line(M, y - 4, M + c.stringWidth(texte.upper(), "Helvetica-Bold", 12.5), y - 4)
219 + return y - 12
220 +
221 + def sous_titre(self, y: float, texte: str) -> float:
222 + c = self.c
223 + c.setFont("Helvetica", 7.5)
224 + c.setFillColor(INK3)
225 + c.drawString(M, y, texte)
226 + return y - 14
227 +
228 + def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK,
229 + stroke=None, taille: float = 8) -> float:
230 + c = self.c
231 + w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14
232 + c.setFillColor(bg)
233 + if stroke:
234 + c.setStrokeColor(stroke)
235 + c.setLineWidth(1.2)
236 + c.roundRect(x, y, w, taille + 9, 3, stroke=1 if stroke else 0, fill=1)
237 + c.setFillColor(fg)
238 + c.setFont("Helvetica-Bold", taille)
239 + c.drawString(x + 7, y + 5, texte)
240 + return x + w + 6
241 +
242 + def boite_sharp(self, x: float, y: float, w: float, h: float,
243 + fill=SURFACE, offset: float = 3, lw: float = 1.5):
244 + """Rectangle à bordure épaisse avec ombre décalée pleine (encre)."""
245 + c = self.c
246 + c.setFillColor(INK)
247 + c.rect(x + offset, y - offset, w, h, stroke=0, fill=1)
248 + c.setFillColor(fill)
249 + c.setStrokeColor(INK)
250 + c.setLineWidth(lw)
251 + c.rect(x, y, w, h, stroke=1, fill=1)
252 +
253 +
254 +class _Rapport:
255 + """Flux multi-pages : suit y, saute de page quand l'espace manque."""
256 +
257 + def __init__(self, c: rl_canvas.Canvas, s: _Style):
258 + self.c, self.s = c, s
259 + self.page = 1
260 + self.y = 0.0
261 +
262 + def saut(self, titre: str = "Rapport du marché"):
263 + self.s.pied(self.page)
264 + self.c.showPage()
265 + self.page += 1
266 + self.s.fond()
267 + self.y = self.s.entete(titre)
268 +
269 + def besoin(self, h: float):
270 + if self.y - h < M + 30:
271 + self.saut()
272 +
273 +
274 +# ---------------------------------------------------------------------------
275 +# Rapport global du marché
276 +# ---------------------------------------------------------------------------
277 +
278 +def rapport_pdf() -> bytes:
279 + """Rapport global du marché de l'épicerie Food-Ka (multi-pages, Letter).
280 +
281 + Tous les chiffres viennent de marketstats.compute() — la même source
282 + que la page Statistiques du site — plus les aubaines et le journal des
283 + synchronisations de /api/stats.
284 + """
285 + from . import marketstats
286 + st = marketstats.compute()
287 + g = st["global"]
288 +
289 + con = db.connect()
290 + deals = [dict(r) for r in con.execute(
291 + """SELECT name, source, price, regular_price FROM products
292 + WHERE active=1 AND regular_price IS NOT NULL AND price IS NOT NULL
293 + AND price > 0 AND regular_price > price
294 + ORDER BY (regular_price - price) / regular_price DESC LIMIT 10""")]
295 + syncs = [dict(r) for r in con.execute(
296 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 10")]
297 + con.close()
298 +
299 + buf = io.BytesIO()
300 + c = rl_canvas.Canvas(buf, pagesize=letter)
301 + c.setTitle("Food-Ka — Rapport du marché de l'épicerie")
302 + s = _Style(c)
303 + r = _Rapport(c, s)
304 +
305 + # ======================= page 1 : couverture ============================
306 + s.fond()
307 + s.logo(M, PAGE_H - 128, 42)
308 + c.setFont("Courier-Bold", 9)
309 + c.setFillColor(GREEN)
310 + c.drawString(M, PAGE_H - 152, "OBSERVATOIRE — PRIX D'ÉPICERIE AU QUÉBEC")
311 + c.setFont("Helvetica-Bold", 26)
312 + c.setFillColor(INK)
313 + c.drawString(M, PAGE_H - 186, "Rapport du marché de l'épicerie")
314 + c.setFont("Helvetica", 10)
315 + c.setFillColor(INK2)
316 + c.drawString(M, PAGE_H - 204,
317 + f"L'épicerie, en chiffres — calculé en direct sur les {_fmt_i(g['total'])} "
318 + f"produits actifs de {_fmt_i(g['sources'])} bannières,")
319 + c.drawString(M, PAGE_H - 217,
320 + f"répartis dans {_fmt_i(g['categories'])} catégories et "
321 + f"{_fmt_i(g['brands'])} marques.")
322 +
323 + # --- bandeau façon reçu de caisse ---------------------------------------
324 + rx, rw, rh = M, PAGE_W - 2 * M, 96
325 + ry = PAGE_H - 240 - rh
326 + c.setFillColor(INK)
327 + c.rect(rx + 4, ry - 4, rw, rh, stroke=0, fill=1) # ombre décalée
328 + c.setFillColor(WHITE)
329 + c.setStrokeColor(INK)
330 + c.setLineWidth(1.5)
331 + c.setDash(4, 3) # bord perforé
332 + c.rect(rx, ry, rw, rh, stroke=1, fill=1)
333 + c.setDash()
334 + c.setFillColor(INK)
335 + c.setFont("Courier-Bold", 9)
336 + c.drawCentredString(PAGE_W / 2, ry + rh - 18, "* * * FOOD-KA — MARCHÉ FRAIS * * *")
337 + c.setFont("Courier", 8)
338 + lignes_recu = [
339 + time.strftime("RAPPORT GÉNÉRÉ LE %Y-%m-%d"),
340 + f"PRODUITS SUIVIS ............. {_fmt_i(g['total'])}",
341 + f"BANNIÈRES CONNECTÉES ........ {_fmt_i(g['sources'])}",
342 + f"PRIX MÉDIAN GLOBAL .......... {_fmt_money(g['median_price'])}",
343 + "MERCI ! À BIENTÔT",
344 + ]
345 + yy = ry + rh - 32
346 + for lg in lignes_recu:
347 + c.drawCentredString(PAGE_W / 2, yy, lg)
348 + yy -= 11
349 + # code-barres décoratif
350 + bx = PAGE_W / 2 - 50
351 + for i in range(40):
352 + w_bar = 1.4 if i % 3 else 2.6
353 + c.setFillColor(INK)
354 + c.rect(bx, ry + 5, w_bar, 6, stroke=0, fill=1)
355 + bx += w_bar + 1.1
356 +
357 + # --- tuiles héro (6) ------------------------------------------------------
358 + tuiles = [
359 + (_fmt_i(g["total"]), "produits suivis", True),
360 + (_fmt_pct(g["sale_share"] * 100), f"en solde ({_fmt_i(g['on_sale'])} produits)", False),
361 + (_fmt_i(g["sources"]), "bannières connectées", False),
362 + (_fmt_i(g["brands"]), "marques", False),
363 + (_fmt_money(g["median_price"]), "prix médian global", False),
364 + (_fmt_i(g["price_changes_7d"]), "changements de prix (7 j)", False),
365 + ]
366 + tw = (PAGE_W - 2 * M - 2 * 12) / 3
367 + th = 56
368 + ty0 = ry - 34
369 + for i, (val, lab, hero) in enumerate(tuiles):
370 + tx = M + (i % 3) * (tw + 12)
371 + ty = ty0 - (i // 3) * (th + 14) - th
372 + s.boite_sharp(tx, ty, tw, th, fill=GREEN_DEEP if hero else SURFACE)
373 + c.setFont("Helvetica-Bold", 16)
374 + c.setFillColor(LIME if hero else INK)
375 + c.drawString(tx + 10, ty + 28, val)
376 + c.setFont("Courier", 6.6)
377 + c.setFillColor(HexColor("#bcd8c6") if hero else INK3)
378 + c.drawString(tx + 10, ty + 12, lab.upper())
379 + y = ty0 - 2 * (th + 14) - 24
380 +
381 + # --- sommaire ------------------------------------------------------------
382 + y = s.titre_section(y, "Au sommaire")
383 + y -= 8
384 + sommaire = [
385 + "Panier comparatif — articles courants × bannières",
386 + "Bannières en chiffres — prix médians, soldes et rabais",
387 + "Prix médian par catégorie et bannière",
388 + "Distribution des prix · Baisses de prix (7 jours)",
389 + "Meilleures aubaines du moment · Journal des synchronisations",
390 + ]
391 + for item in sommaire:
392 + c.setFillColor(GREEN)
393 + c.rect(M, y - 1, 5, 5, stroke=0, fill=1)
394 + c.setFont("Helvetica", 9)
395 + c.setFillColor(INK2)
396 + c.drawString(M + 12, y, item)
397 + y -= 15
398 +
399 + # ======================= page 2 : panier comparatif ======================
400 + r.saut("Rapport du marché · panier")
401 + y = s.titre_section(r.y, "Panier comparatif")
402 + y = s.sous_titre(y, "articles courants × bannières — prix médian des produits correspondants")
403 +
404 + basket = st["basket"]
405 + totals = st["basket_totals"][:8] # déjà triés du moins cher
406 + cols = [t["source"] for t in totals]
407 + gagnant = totals[0] if totals else None
408 +
409 + if not cols:
410 + c.setFont("Helvetica-Oblique", 9)
411 + c.setFillColor(INK3)
412 + c.drawString(M, y - 12, "Pas encore assez de données pour composer le panier — "
413 + "il se remplit à mesure que les bannières sont synchronisées.")
414 + y -= 34
415 + else:
416 + item_w = 96
417 + col_w = (PAGE_W - 2 * M - item_w) / len(cols)
418 + row_h = 16.5
419 + head_h = 38
420 + n_rows = len(basket)
421 + table_h = head_h + n_rows * row_h + 22 # + ligne TOTAL
422 + y_table = y - 6
423 +
424 + # colonne gagnante teintée vert clair sur toute la hauteur
425 + if gagnant:
426 + gx = M + item_w + cols.index(gagnant["source"]) * col_w
427 + c.setFillColor(GREEN_SOFT)
428 + c.rect(gx, y_table - table_h, col_w, table_h, stroke=0, fill=1)
429 +
430 + # en-têtes : logos + noms
431 + c.setFont("Courier-Bold", 7)
432 + c.setFillColor(INK3)
433 + c.drawString(M, y_table - 12, "ARTICLE")
434 + for j, src in enumerate(cols):
435 + _en_tete_bannière(c, src, M + item_w + j * col_w, y_table - 2, col_w, box_h=24)
436 + c.setStrokeColor(INK)
437 + c.setLineWidth(1.2)
438 + c.line(M, y_table - head_h, PAGE_W - M, y_table - head_h)
439 +
440 + # rangées : le moins cher de chaque ligne en gras vert
441 + yy = y_table - head_h - 12
442 + for row in basket:
443 + vals = [row["by_source"].get(src, {}).get("median_price") for src in cols]
444 + present = [v for v in vals if v is not None]
445 + mini = min(present) if present else None
446 + c.setFont("Helvetica-Bold", 8.2)
447 + c.setFillColor(INK)
448 + c.drawString(M, yy, row["item"])
449 + for j, v in enumerate(vals):
450 + cx = M + item_w + j * col_w + col_w - 5
451 + if v is None:
452 + c.setFont("Helvetica", 8)
453 + c.setFillColor(INK3)
454 + c.drawRightString(cx, yy, "—")
455 + elif mini is not None and v == mini:
456 + c.setFont("Helvetica-Bold", 8.2)
457 + c.setFillColor(GREEN)
458 + c.drawRightString(cx, yy, _fmt_money(v))
459 + else:
460 + c.setFont("Helvetica", 8)
461 + c.setFillColor(INK2)
462 + c.drawRightString(cx, yy, _fmt_money(v))
463 + c.setStrokeColor(INK3)
464 + c.setLineWidth(0.3)
465 + c.line(M, yy - 5, PAGE_W - M, yy - 5)
466 + yy -= row_h
467 +
468 + # ligne TOTAL
469 + c.setStrokeColor(INK)
470 + c.setLineWidth(1.4)
471 + c.line(M, yy + row_h - 5, PAGE_W - M, yy + row_h - 5)
472 + yy -= 2
473 + c.setFont("Helvetica-Bold", 8.4)
474 + c.setFillColor(INK)
475 + c.drawString(M, yy, "Total du panier")
476 + par_src = {t["source"]: t for t in totals}
477 + for j, src in enumerate(cols):
478 + t = par_src.get(src)
479 + cx = M + item_w + j * col_w + col_w - 5
480 + gagne = gagnant and src == gagnant["source"]
481 + c.setFont("Helvetica-Bold", 8.6)
482 + c.setFillColor(GREEN_DEEP if gagne else INK)
483 + c.drawRightString(cx, yy, _fmt_money(t["total"]) if t else "—")
484 + if t:
485 + c.setFont("Helvetica", 5.6)
486 + c.setFillColor(GREEN if gagne else INK3)
487 + c.drawRightString(cx, yy - 8, f"{t['items']}/{len(basket)} articles")
488 + y = yy - 26
489 +
490 + # médaille dessinée (remplace le trophée emoji) + mention gagnante
491 + if gagnant:
492 + c.setFillColor(LIME)
493 + c.setStrokeColor(INK)
494 + c.setLineWidth(1.2)
495 + c.circle(M + 7, y + 5, 7, stroke=1, fill=1)
496 + c.setFillColor(INK)
497 + c.setFont("Helvetica-Bold", 8.5)
498 + c.drawCentredString(M + 7, y + 2, "1")
499 + c.setFont("Helvetica-Bold", 9.5)
500 + c.setFillColor(GREEN_DEEP)
501 + c.drawString(M + 20, y + 1,
502 + f"Panier le moins cher : {_nom(gagnant['source'])} — "
503 + f"{_fmt_money(gagnant['total'])} pour {gagnant['items']} articles")
504 + y -= 16
505 + c.setFont("Helvetica", 7)
506 + c.setFillColor(INK3)
507 + c.drawString(M, y, "Prix médian des produits correspondant à chaque article chez la "
508 + "bannière ; seules les bannières couvrant la majorité du panier "
509 + "sont comparées.")
510 + y -= 18
511 + r.y = y
512 +
513 + # ======================= page 3 : bannières en chiffres ==================
514 + r.saut("Rapport du marché · bannières")
515 + y = s.titre_section(r.y, "Bannières en chiffres")
516 + y = s.sous_titre(y, "prix médian, part de soldes et rabais par bannière")
517 +
518 + rows = st["by_source"]
519 + max_median = max((b["median_price"] or 0) for b in rows) or 0.01
520 + c.setFont("Courier-Bold", 7)
521 + c.setFillColor(INK3)
522 + c.drawString(M, y - 8, "BANNIÈRE")
523 + c.drawRightString(M + 230, y - 8, "PRODUITS")
524 + c.drawRightString(M + 300, y - 8, "PRIX MÉDIAN")
525 + c.drawRightString(M + 420, y - 8, "EN SOLDE")
526 + c.drawRightString(M + 472, y - 8, "RAB. MOYEN")
527 + c.drawRightString(PAGE_W - M, y - 8, "RAB. MAX")
528 + c.setStrokeColor(INK)
529 + c.setLineWidth(1.2)
530 + c.line(M, y - 13, PAGE_W - M, y - 13)
531 + yy = y - 27
532 + for b in rows:
533 + _dessine_logo(c, b["source"], M, yy - 4, 20, 13)
534 + c.setFont("Helvetica-Bold", 8.2)
535 + c.setFillColor(INK)
536 + c.drawString(M + 26, yy, _nom(b["source"])[:24])
537 + c.setFont("Helvetica", 8.2)
538 + c.setFillColor(INK2)
539 + c.drawRightString(M + 230, yy, _fmt_i(b["n"]))
540 + c.drawRightString(M + 300, yy, _fmt_money(b["median_price"]))
541 + # mini-barre du prix médian
542 + bx0, bw_ = M + 310, 58
543 + c.setFillColor(WHITE)
544 + c.setStrokeColor(INK)
545 + c.setLineWidth(0.5)
546 + c.rect(bx0, yy - 1, bw_, 7, stroke=1, fill=1)
547 + c.setFillColor(GREEN)
548 + c.rect(bx0, yy - 1, bw_ * min(1.0, (b["median_price"] or 0) / max_median),
549 + 7, stroke=0, fill=1)
550 + c.setFont("Helvetica", 8.2)
551 + c.setFillColor(TOMATO if b["sale_share"] > 0 else INK3)
552 + c.drawRightString(M + 420, yy, _fmt_pct(b["sale_share"] * 100))
553 + c.setFillColor(INK2)
554 + c.drawRightString(M + 472, yy, _fmt_pct(b["avg_discount_pct"], 1))
555 + c.drawRightString(PAGE_W - M, yy, _fmt_pct(b["max_discount_pct"], 1))
556 + c.setStrokeColor(INK3)
557 + c.setLineWidth(0.3)
558 + c.line(M, yy - 6, PAGE_W - M, yy - 6)
559 + yy -= 17.5
560 + r.y = yy - 8
561 +
562 + # ======================= page 4 : matrice catégorie × bannière ===========
563 + r.saut("Rapport du marché · catégories")
564 + y = s.titre_section(r.y, "Prix médian par catégorie et bannière")
565 + mat_srcs = [b["source"] for b in rows[:9]]
566 + y = s.sous_titre(y, f"{len(mat_srcs)} plus grandes bannières — le moins cher "
567 + "de chaque rangée en vert")
568 +
569 + cat_w = 108
570 + mcol_w = (PAGE_W - 2 * M - cat_w) / max(1, len(mat_srcs))
571 + y_mat = y - 4
572 + c.setFont("Courier-Bold", 7)
573 + c.setFillColor(INK3)
574 + c.drawString(M, y_mat - 12, "CATÉGORIE")
575 + for j, src in enumerate(mat_srcs):
576 + _en_tete_bannière(c, src, M + cat_w + j * mcol_w, y_mat - 2, mcol_w, box_h=22)
577 + c.setStrokeColor(INK)
578 + c.setLineWidth(1.2)
579 + c.line(M, y_mat - 36, PAGE_W - M, y_mat - 36)
580 +
581 + mat_rows = sorted(
582 + st["category_matrix"].items(),
583 + key=lambda kv: -sum(cell["n"] for cell in kv[1].values()))
584 + row_h = 15.5
585 + yy = y_mat - 48
586 + for cat, per in mat_rows:
587 + if yy < M + 40:
588 + break
589 + vals = [per.get(src, {}).get("median_price") for src in mat_srcs]
590 + present = [v for v in vals if v is not None]
591 + mini = min(present) if present else None
592 + maxi_v = max(present) if present else None
593 + for j, v in enumerate(vals):
594 + cx0 = M + cat_w + j * mcol_w
595 + if v is not None and mini is not None:
596 + if v == mini:
597 + c.setFillColor(GREEN_SOFT)
598 + c.rect(cx0, yy - 4.5, mcol_w, row_h - 2, stroke=0, fill=1)
599 + elif maxi_v is not None and maxi_v > mini:
600 + t = (v - mini) / (maxi_v - mini)
601 + c.saveState()
602 + c.setFillColor(TOMATO)
603 + c.setFillAlpha(0.05 + 0.18 * t)
604 + c.rect(cx0, yy - 4.5, mcol_w, row_h - 2, stroke=0, fill=1)
605 + c.restoreState()
606 + c.setFont("Helvetica-Bold", 7.4)
607 + c.setFillColor(INK)
608 + nom_cat = cat if len(cat) <= 26 else cat[:25] + "…"
609 + c.drawString(M, yy, nom_cat)
610 + for j, v in enumerate(vals):
611 + cx = M + cat_w + j * mcol_w + mcol_w - 4
612 + if v is None:
613 + c.setFont("Helvetica", 7)
614 + c.setFillColor(INK3)
615 + c.drawRightString(cx, yy, "—")
616 + elif mini is not None and v == mini:
617 + c.setFont("Helvetica-Bold", 7.2)
618 + c.setFillColor(GREEN_DEEP)
619 + c.drawRightString(cx, yy, _fmt_money(v))
620 + else:
621 + c.setFont("Helvetica", 7)
622 + c.setFillColor(INK2)
623 + c.drawRightString(cx, yy, _fmt_money(v))
624 + c.setStrokeColor(INK3)
625 + c.setLineWidth(0.3)
626 + c.line(M, yy - 4.5, PAGE_W - M, yy - 4.5)
627 + yy -= row_h
628 + # légende
629 + yy -= 6
630 + c.setFillColor(GREEN_SOFT)
631 + c.rect(M, yy - 2, 9, 7, stroke=0, fill=1)
632 + c.setFont("Helvetica", 7)
633 + c.setFillColor(INK3)
634 + c.drawString(M + 13, yy, "moins cher")
635 + c.saveState()
636 + c.setFillColor(TOMATO)
637 + c.setFillAlpha(0.22)
638 + c.rect(M + 70, yy - 2, 9, 7, stroke=0, fill=1)
639 + c.restoreState()
640 + c.drawString(M + 83, yy, "plus cher")
641 + r.y = yy - 16
642 +
643 + # ======================= distribution des prix ===========================
644 + r.besoin(160)
645 + y = s.titre_section(r.y, "Distribution des prix")
646 + y = s.sous_titre(y, "produits actifs par palier de prix")
647 + dist = st["price_distribution"]
648 + dist_max = max((b["n"] for b in dist), default=1) or 1
649 + yy = y - 8
650 + for b in dist:
651 + c.setFont("Helvetica-Bold", 8.2)
652 + c.setFillColor(INK)
653 + c.drawString(M, yy, b["range"])
654 + bx0, bw_ = M + 70, PAGE_W - 2 * M - 130
655 + c.setFillColor(WHITE)
656 + c.setStrokeColor(INK)
657 + c.setLineWidth(0.8)
658 + c.rect(bx0, yy - 2, bw_, 10, stroke=1, fill=1)
659 + c.setFillColor(GREEN)
660 + c.rect(bx0, yy - 2, max(2, bw_ * b["n"] / dist_max), 10, stroke=0, fill=1)
661 + c.setFont("Courier-Bold", 8)
662 + c.setFillColor(INK)
663 + c.drawRightString(PAGE_W - M, yy, _fmt_i(b["n"]))
664 + yy -= 18
665 + r.y = yy - 12
666 +
667 + # ======================= baisses de prix (7 j) ===========================
668 + drops = st["price_drops"][:20]
669 + r.besoin(60 + max(1, len(drops)) * 15)
670 + y = s.titre_section(r.y, "Baisses de prix (7 jours)")
671 + y = s.sous_titre(y, "produits dont le prix relevé a diminué depuis la dernière synchronisation")
672 + if not drops:
673 + c.setFont("Helvetica-Oblique", 9)
674 + c.setFillColor(INK3)
675 + c.drawString(M, y - 10, "Aucune baisse détectée encore — l'historique se construit "
676 + "à chaque synchronisation.")
677 + r.y = y - 32
678 + else:
679 + yy = y - 10
680 + for dr in drops:
681 + r.y = yy
682 + r.besoin(20)
683 + yy = r.y
684 + _dessine_logo(c, dr["source"], M, yy - 3.5, 18, 12)
685 + c.setFont("Helvetica", 8)
686 + c.setFillColor(INK)
687 + c.drawString(M + 24, yy, dr["name"][:52] + ("…" if len(dr["name"]) > 52 else ""))
688 + # ancien prix barré -> nouveau prix
689 + ax = M + 300
690 + c.setFont("Helvetica", 8)
691 + c.setFillColor(INK3)
692 + old_txt = _fmt_money(dr["old_price"])
693 + c.drawString(ax, yy, old_txt)
694 + ow = c.stringWidth(old_txt, "Helvetica", 8)
695 + c.setStrokeColor(INK3)
696 + c.setLineWidth(0.8)
697 + c.line(ax - 1, yy + 2.6, ax + ow + 1, yy + 2.6)
698 + # flèche dessinée (pas de glyphe → en Helvetica)
699 + fx = ax + ow + 6
700 + c.setStrokeColor(INK2)
701 + c.setLineWidth(1)
702 + c.line(fx, yy + 2.6, fx + 10, yy + 2.6)
703 + c.setFillColor(INK2)
704 + p = c.beginPath()
705 + p.moveTo(fx + 10, yy + 5.1)
706 + p.lineTo(fx + 14, yy + 2.6)
707 + p.lineTo(fx + 10, yy + 0.1)
708 + p.close()
709 + c.drawPath(p, stroke=0, fill=1)
710 + c.setFont("Helvetica-Bold", 8.4)
711 + c.setFillColor(GREEN_DEEP)
712 + c.drawString(fx + 19, yy, _fmt_money(dr["new_price"]))
713 + # pourcentage en tomate
714 + pct = "-" + f"{dr['drop_pct']:.1f}".replace(".", ",") + f"{NBSP}%"
715 + pw = c.stringWidth(pct, "Helvetica-Bold", 7.5) + 10
716 + c.setFillColor(TOMATO_SOFT)
717 + c.setStrokeColor(TOMATO)
718 + c.setLineWidth(1)
719 + c.roundRect(PAGE_W - M - pw, yy - 3.5, pw, 13, 2, stroke=1, fill=1)
720 + c.setFillColor(TOMATO)
721 + c.setFont("Helvetica-Bold", 7.5)
722 + c.drawRightString(PAGE_W - M - 5, yy, pct)
723 + yy -= 16.5
724 + r.y = yy - 10
725 +
726 + # ======================= meilleures aubaines =============================
727 + if deals:
728 + r.besoin(70 + len(deals) * 17)
729 + y = s.titre_section(r.y, "Meilleures aubaines du moment")
730 + y = s.sous_titre(y, "rabais relatif le plus fort, toutes bannières confondues — top 10")
731 + yy = y - 10
732 + for d_ in deals:
733 + pct_v = 100 * (d_["regular_price"] - d_["price"]) / d_["regular_price"]
734 + _dessine_logo(c, d_["source"], M, yy - 3.5, 18, 12)
735 + c.setFont("Helvetica", 8)
736 + c.setFillColor(INK)
737 + nom_p = d_["name"] or ""
738 + c.drawString(M + 24, yy, nom_p[:46] + ("…" if len(nom_p) > 46 else ""))
739 + c.setFont("Helvetica", 7)
740 + c.setFillColor(INK3)
741 + c.drawString(M + 262, yy, _nom(d_["source"])[:16])
742 + # prix régulier barré + prix soldé
743 + reg_txt = _fmt_money(d_["regular_price"])
744 + c.setFont("Helvetica", 8)
745 + c.setFillColor(INK3)
746 + rx0 = M + 360
747 + c.drawString(rx0, yy, reg_txt)
748 + rw_ = c.stringWidth(reg_txt, "Helvetica", 8)
749 + c.setStrokeColor(INK3)
750 + c.setLineWidth(0.8)
751 + c.line(rx0 - 1, yy + 2.6, rx0 + rw_ + 1, yy + 2.6)
752 + c.setFont("Helvetica-Bold", 8.6)
753 + c.setFillColor(GREEN_DEEP)
754 + c.drawString(rx0 + rw_ + 8, yy, _fmt_money(d_["price"]))
755 + pct = "-" + f"{pct_v:.0f}".replace(".", ",") + f"{NBSP}%"
756 + pw = c.stringWidth(pct, "Helvetica-Bold", 7.5) + 10
757 + c.setFillColor(TOMATO)
758 + c.roundRect(PAGE_W - M - pw, yy - 3.5, pw, 13, 2, stroke=0, fill=1)
759 + c.setFillColor(WHITE)
760 + c.setFont("Helvetica-Bold", 7.5)
761 + c.drawRightString(PAGE_W - M - 5, yy, pct)
762 + yy -= 17
763 + r.y = yy - 10
764 +
765 + # ======================= journal des synchronisations ====================
766 + if syncs:
767 + r.besoin(60 + len(syncs) * 15)
768 + y = s.titre_section(r.y, "Dernières synchronisations")
769 + y = s.sous_titre(y, "10 derniers passages des connecteurs de bannières")
770 + yy = y - 10
771 + for lg in syncs:
772 + ok = bool(lg.get("ok"))
773 + c.setFillColor(GREEN if ok else TOMATO)
774 + c.circle(M + 4, yy + 2.6, 3.4, stroke=0, fill=1)
775 + c.setFont("Helvetica-Bold", 8.2)
776 + c.setFillColor(INK)
777 + c.drawString(M + 14, yy, _nom(lg["source"])[:20])
778 + c.setFont("Courier", 7.5)
779 + c.setFillColor(INK3)
780 + c.drawString(M + 130, yy, time.strftime("%Y-%m-%d %H:%M",
781 + time.localtime(lg["ts"])))
782 + c.setFont("Helvetica", 8)
783 + c.setFillColor(INK2)
784 + detail = (f"{_fmt_i(lg['found'])} produits trouvés · "
785 + f"{_fmt_i(lg['added'])} ajoutés · {_fmt_i(lg['updated'])} mis à jour · "
786 + f"{_fmt_i(lg['removed'])} retirés")
787 + c.drawString(M + 235, yy, detail)
788 + c.setFont("Helvetica-Bold", 7.5)
789 + c.setFillColor(GREEN_DEEP if ok else TOMATO)
790 + c.drawRightString(PAGE_W - M, yy, "OK" if ok else "ÉCHEC")
791 + yy -= 15.5
792 + r.y = yy - 6
793 +
794 + # note finale
795 + r.besoin(30)
796 + c.setFont("Helvetica", 7.5)
797 + c.setFillColor(INK3)
798 + c.drawString(M, r.y - 4, "Données recalculées à chaque synchronisation — rapport non "
799 + "contractuel, généré automatiquement à partir des prix publiés "
800 + "par les bannières.")
801 +
802 + s.pied(r.page)
803 + c.save()
804 + return buf.getvalue()
added foodka/schema.py +110 −0
@@ -0,0 +1,110 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# schema.py : modèle de données standardisé (Product) + enrichissement central
5 +# -----------------------------------------------------------------------------
6 +"""Schéma standard d'un produit d'épicerie (Product) et normalisation.
7 +
8 +Chaque connecteur, peu importe la bannière (Metro, IGA, Maxi…), doit
9 +produire des objets `Product` conformes à ce schéma. La méthode
10 +`finalize()` applique ensuite la couche de normalisation commune
11 +(foodka/normalize.py) : prix, prix unitaire $/100 g, catégorie
12 +canonique… — les connecteurs restent simples et remplissent les
13 +champs bruts.
14 +"""
15 +from __future__ import annotations
16 +
17 +import hashlib
18 +import json
19 +from dataclasses import dataclass, field, asdict
20 +
21 +from .normalize import ( # ré-exportés pour les connecteurs
22 + clean_text,
23 + format_unit_price,
24 + normalize_category,
25 + parse_price,
26 + parse_size,
27 + strip_accents,
28 + unit_price,
29 +)
30 +
31 +__all__ = [
32 + "Product", "normalize_category", "parse_price", "parse_size",
33 + "clean_text", "strip_accents",
34 +]
35 +
36 +
37 +@dataclass
38 +class Product:
39 + """Produit d'épicerie standardisé Food-Ka."""
40 +
41 + source: str # id de la bannière (voir data/sources.json)
42 + external_id: str # identifiant/SKU chez la source
43 + url: str # fiche produit chez la source
44 + name: str = "" # ex. "Beurre d'arachide croquant"
45 + brand: str = "" # ex. "Kraft"
46 + category: str = "" # catégorie canonique Food-Ka
47 + category_raw: str = "" # taxonomie originale de la bannière
48 + size_label: str = "" # format affiché, ex. "500 g", "2 L"
49 + price: float | None = None # prix courant ($ CAD), rabais inclus
50 + regular_price: float | None = None # prix régulier si le produit est en solde
51 + price_label: str = "" # texte original (ex. "2 / 5,00 $")
52 + on_sale: bool = False # produit en promotion
53 + unit_price: float | None = None # prix par unité comparable
54 + unit_price_label: str = "" # ex. "0,70 $ / 100 g"
55 + in_stock: bool | None = None # None = inconnu
56 + description: str = ""
57 + keywords: list[str] = field(default_factory=list) # tags source (bio, sans gluten…)
58 + details: dict = field(default_factory=dict) # champs structurés (JSON)
59 + images: list[str] = field(default_factory=list) # URLs absolues
60 +
61 + @property
62 + def uid(self) -> str:
63 + return f"{self.source}:{self.external_id}"
64 +
65 + def content_hash(self) -> str:
66 + """Hash du contenu pour la détection de changements (pseudo-webhook)."""
67 + payload = asdict(self)
68 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
69 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
70 +
71 + def finalize(self) -> "Product":
72 + """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
73 +
74 + Idempotent ; ne remplace jamais une valeur explicite du connecteur.
75 + """
76 + self.name = clean_text(self.name)
77 + self.brand = clean_text(self.brand)
78 + self.size_label = clean_text(self.size_label)
79 + self.description = clean_text(self.description)
80 +
81 + if not self.category:
82 + self.category = normalize_category(self.category_raw)
83 +
84 + if self.price is None:
85 + self.price = parse_price(self.price_label)
86 +
87 + # cohérence solde : un "prix régulier" égal ou inférieur au prix
88 + # courant n'apporte rien — on le laisse tomber.
89 + if self.regular_price is not None and self.price is not None:
90 + if self.regular_price <= self.price:
91 + self.regular_price = None
92 + else:
93 + self.on_sale = True
94 + if self.regular_price is None and not self.on_sale:
95 + self.on_sale = False
96 +
97 + if self.unit_price is None:
98 + up = unit_price(self.price, self.size_label)
99 + if up:
100 + self.unit_price = up[0]
101 + if not self.unit_price_label:
102 + self.unit_price_label = format_unit_price(up)
103 +
104 + # garde-fou : prix absurdes (0 $, > 2000 $) = donnée cassée, pas un prix
105 + if self.price is not None and not (0 < self.price <= 2000):
106 + self.price = None
107 + self.regular_price = None
108 + self.on_sale = False
109 +
110 + return self
added foodka/web.py +231 −0
@@ -0,0 +1,231 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (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, Response
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="Food-Ka API", version="1.0",
24 + description="Agrégateur de produits d'épicerie — province de Québec")
25 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
26 + allow_methods=["*"], allow_headers=["*"])
27 +
28 +_sync_lock = threading.Lock()
29 +
30 +# tris supportés -> clause SQL (liste blanche, jamais d'injection)
31 +_SORTS = {
32 + "price_asc": "price IS NULL, price ASC",
33 + "price_desc": "price IS NULL, price DESC",
34 + "unit_price": "unit_price IS NULL, unit_price ASC",
35 + "discount": "(CASE WHEN regular_price IS NOT NULL AND price IS NOT NULL"
36 + " THEN (regular_price - price) / regular_price ELSE 0 END) DESC",
37 + "name": "name COLLATE NOCASE ASC",
38 + "recent": "first_seen DESC",
39 +}
40 +
41 +
42 +def _row_to_dict(row) -> dict:
43 + d = dict(row)
44 + d["keywords"] = json.loads(d.get("keywords") or "[]")
45 + d["images"] = json.loads(d.get("images") or "[]")
46 + d["details"] = json.loads(d.get("details") or "{}")
47 + d["on_sale"] = bool(d.get("on_sale"))
48 + if d.get("in_stock") is not None:
49 + d["in_stock"] = bool(d["in_stock"])
50 + return d
51 +
52 +
53 +@app.get("/api/products")
54 +def list_products(
55 + category: str | None = None,
56 + source: str | None = None,
57 + brand: str | None = None,
58 + price_max: float | None = None,
59 + price_min: float | None = None,
60 + on_sale: int | None = None, # 1 = en solde seulement
61 + in_stock: int | None = None, # 1 / 0
62 + q: str | None = None,
63 + sort: str = "price_asc",
64 + active: int = 1,
65 + limit: int = Query(60, le=500),
66 + offset: int = 0,
67 +):
68 + con = db.connect()
69 + sql = "SELECT * FROM products WHERE 1=1"
70 + args: list = []
71 + if active in (0, 1):
72 + sql += " AND active=?"; args.append(active)
73 + if category:
74 + sql += " AND category=?"; args.append(category)
75 + if source:
76 + sql += " AND source=?"; args.append(source)
77 + if brand:
78 + sql += " AND brand LIKE ?"; args.append(f"%{brand}%")
79 + if price_max is not None:
80 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
81 + if price_min is not None:
82 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
83 + if on_sale == 1:
84 + sql += " AND on_sale=1"
85 + if in_stock in (0, 1):
86 + sql += " AND in_stock=?"; args.append(in_stock)
87 + if q:
88 + sql += " AND (name LIKE ? OR brand LIKE ? OR category_raw LIKE ?)"
89 + args += [f"%{q}%"] * 3
90 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
91 + sql += f" ORDER BY {_SORTS.get(sort, _SORTS['price_asc'])} LIMIT ? OFFSET ?"
92 + args += [limit, offset]
93 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
94 + con.close()
95 + return {"total": total, "count": len(rows), "products": rows}
96 +
97 +
98 +@app.get("/api/products/{uid}")
99 +def get_product(uid: str):
100 + con = db.connect()
101 + row = con.execute("SELECT * FROM products WHERE uid=?", (uid,)).fetchone()
102 + d = None
103 + if row is not None:
104 + d = _row_to_dict(row)
105 + # historique de prix (suivi des soldes)
106 + d["price_history"] = [dict(r) for r in con.execute(
107 + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 12",
108 + (uid,)).fetchall()]
109 + # le même produit ailleurs : comparaison inter-bannières par nom
110 + if d["name"]:
111 + d["compare"] = [_row_to_dict(r) for r in con.execute(
112 + """SELECT * FROM products WHERE active=1 AND uid<>? AND name LIKE ?
113 + ORDER BY price IS NULL, price ASC LIMIT 8""",
114 + (uid, f"%{d['name'].split('(')[0].strip()[:40]}%")).fetchall()]
115 + else:
116 + d["compare"] = []
117 + con.close()
118 + if d is None:
119 + raise HTTPException(404, "Produit introuvable")
120 + return d
121 +
122 +
123 +@app.get("/api/facets")
124 +def facets(category: str | None = None):
125 + """Valeurs distinctes pour construire les filtres du frontend."""
126 + con = db.connect()
127 + brand_sql = ("SELECT brand, COUNT(*) n FROM products"
128 + " WHERE active=1 AND brand<>''")
129 + brand_args: list = []
130 + if category:
131 + brand_sql += " AND category=?"
132 + brand_args.append(category)
133 + out = {
134 + "categories": [dict(r) for r in con.execute(
135 + "SELECT category, COUNT(*) n FROM products WHERE active=1 AND category<>''"
136 + " GROUP BY category ORDER BY n DESC")],
137 + "brands": [dict(r) for r in con.execute(
138 + brand_sql + " GROUP BY brand ORDER BY n DESC LIMIT 60", brand_args)],
139 + "sources": [dict(r) for r in con.execute(
140 + "SELECT source, COUNT(*) n FROM products WHERE active=1"
141 + " GROUP BY source ORDER BY n DESC")],
142 + "on_sale": con.execute(
143 + "SELECT COUNT(*) c FROM products WHERE active=1 AND on_sale=1"
144 + ).fetchone()["c"],
145 + }
146 + con.close()
147 + return out
148 +
149 +
150 +@app.get("/api/sources")
151 +def sources():
152 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
153 + con = db.connect()
154 + counts = {r["source"]: r["n"] for r in con.execute(
155 + "SELECT source, COUNT(*) n FROM products WHERE active=1 GROUP BY source")}
156 + last = {r["source"]: r["ts"] for r in con.execute(
157 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
158 + con.close()
159 + for s in registry:
160 + s["active_products"] = counts.get(s["id"], 0)
161 + s["last_sync"] = last.get(s["id"])
162 + return {"sources": registry}
163 +
164 +
165 +@app.get("/api/stats")
166 +def stats():
167 + con = db.connect()
168 + row = con.execute(
169 + """SELECT COUNT(*) total,
170 + SUM(on_sale) on_sale,
171 + COUNT(DISTINCT source) sources,
172 + COUNT(DISTINCT category) categories,
173 + AVG(price) avg_price
174 + FROM products WHERE active=1""").fetchone()
175 + by_source = [dict(r) for r in con.execute(
176 + """SELECT source, COUNT(*) n, SUM(on_sale) sales, AVG(price) avg_price
177 + FROM products WHERE active=1 GROUP BY source ORDER BY n DESC""")]
178 + by_category = [dict(r) for r in con.execute(
179 + """SELECT category, COUNT(*) n, AVG(price) avg_price
180 + FROM products WHERE active=1 AND category<>''
181 + GROUP BY category ORDER BY n DESC""")]
182 + # meilleures aubaines du moment (rabais relatif le plus fort)
183 + deals = [_row_to_dict(r) for r in con.execute(
184 + """SELECT * FROM products
185 + WHERE active=1 AND regular_price IS NOT NULL AND price IS NOT NULL
186 + ORDER BY (regular_price - price) / regular_price DESC LIMIT 24""")]
187 + log = [dict(r) for r in con.execute(
188 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
189 + con.close()
190 + return {**dict(row), "by_source": by_source, "by_category": by_category,
191 + "deals": deals, "recent_syncs": log}
192 +
193 +
194 +@app.get("/api/stats/detailed")
195 +def stats_detailed():
196 + """Agrégats du marché (source unique : foodka/marketstats.py)."""
197 + from . import marketstats
198 + return marketstats.compute()
199 +
200 +
201 +@app.get("/api/stats/rapport.pdf")
202 +def rapport_pdf():
203 + """Rapport PDF du marché — mêmes chiffres que la page Statistiques."""
204 + from . import pdfgen
205 + return Response(
206 + content=pdfgen.rapport_pdf(),
207 + media_type="application/pdf",
208 + headers={"Content-Disposition":
209 + 'attachment; filename="foodka-rapport-marche.pdf"'})
210 +
211 +
212 +@app.post("/api/sync")
213 +def trigger_sync(background: BackgroundTasks, source: str | None = None):
214 + """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
215 + def _job():
216 + with _sync_lock:
217 + ingest.run([source] if source else None)
218 + background.add_task(_job)
219 + return {"status": "démarré", "source": source or "toutes"}
220 +
221 +
222 +# --- Frontend React (build Vite) --------------------------------------------
223 +if FRONTEND_DIST.exists():
224 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
225 +
226 + @app.get("/{full_path:path}")
227 + def spa(full_path: str):
228 + target = FRONTEND_DIST / full_path
229 + if full_path and target.is_file():
230 + return FileResponse(target)
231 + return FileResponse(FRONTEND_DIST / "index.html")
added frontend/index.html +27 −0
@@ -0,0 +1,27 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
4 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +---------------------------------------------------------------------------- -->
6 +<html lang="fr">
7 + <head>
8 + <meta charset="UTF-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 + <title>Food·Ka — Tous les prix d'épicerie du Québec</title>
11 + <meta name="description" content="Food·Ka agrège les produits et circulaires des grandes bannières d'épicerie du Québec — Metro, IGA, Maxi, Super C, Provigo et plus — prix, soldes et prix unitaires, toujours à jour." />
12 + <meta name="theme-color" content="#faf6ee" />
13 + <meta name="mobile-web-app-capable" content="yes" />
14 + <meta name="apple-mobile-web-app-capable" content="yes" />
15 + <meta name="apple-mobile-web-app-status-bar-style" content="default" />
16 + <meta name="apple-mobile-web-app-title" content="Food·Ka" />
17 + <link rel="manifest" href="/manifest.webmanifest" />
18 + <link rel="preconnect" href="https://fonts.googleapis.com" />
19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
20 + <link 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;700&display=swap" rel="stylesheet" />
21 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%2314523a'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='32' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3EFK%3C/text%3E%3C/svg%3E" />
22 + </head>
23 + <body>
24 + <div id="root"></div>
25 + <script type="module" src="/src/main.tsx"></script>
26 + </body>
27 +</html>
added frontend/package-lock.json +1834 −0
@@ -0,0 +1,1834 @@
1 +{
2 + "name": "food-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "food-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "react": "^18.3.1",
12 + "react-dom": "^18.3.1",
13 + "react-router-dom": "^6.26.0"
14 + },
15 + "devDependencies": {
16 + "@types/react": "^18.3.3",
17 + "@types/react-dom": "^18.3.0",
18 + "@vitejs/plugin-react": "^4.3.1",
19 + "typescript": "^5.5.4",
20 + "vite": "^5.4.0"
21 + }
22 + },
23 + "node_modules/@babel/code-frame": {
24 + "version": "7.29.7",
25 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
26 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
27 + "dev": true,
28 + "license": "MIT",
29 + "dependencies": {
30 + "@babel/helper-validator-identifier": "^7.29.7",
31 + "js-tokens": "^4.0.0",
32 + "picocolors": "^1.1.1"
33 + },
34 + "engines": {
35 + "node": ">=6.9.0"
36 + }
37 + },
38 + "node_modules/@babel/compat-data": {
39 + "version": "7.29.7",
40 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
41 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
42 + "dev": true,
43 + "license": "MIT",
44 + "engines": {
45 + "node": ">=6.9.0"
46 + }
47 + },
48 + "node_modules/@babel/core": {
49 + "version": "7.29.7",
50 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
51 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
52 + "dev": true,
53 + "license": "MIT",
54 + "dependencies": {
55 + "@babel/code-frame": "^7.29.7",
56 + "@babel/generator": "^7.29.7",
57 + "@babel/helper-compilation-targets": "^7.29.7",
58 + "@babel/helper-module-transforms": "^7.29.7",
59 + "@babel/helpers": "^7.29.7",
60 + "@babel/parser": "^7.29.7",
61 + "@babel/template": "^7.29.7",
62 + "@babel/traverse": "^7.29.7",
63 + "@babel/types": "^7.29.7",
64 + "@jridgewell/remapping": "^2.3.5",
65 + "convert-source-map": "^2.0.0",
66 + "debug": "^4.1.0",
67 + "gensync": "^1.0.0-beta.2",
68 + "json5": "^2.2.3",
69 + "semver": "^6.3.1"
70 + },
71 + "engines": {
72 + "node": ">=6.9.0"
73 + },
74 + "funding": {
75 + "type": "opencollective",
76 + "url": "https://opencollective.com/babel"
77 + }
78 + },
79 + "node_modules/@babel/generator": {
80 + "version": "7.29.8",
81 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
82 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
83 + "dev": true,
84 + "license": "MIT",
85 + "dependencies": {
86 + "@babel/parser": "^7.29.8",
87 + "@babel/types": "^7.29.8",
88 + "@jridgewell/gen-mapping": "^0.3.12",
89 + "@jridgewell/trace-mapping": "^0.3.28",
90 + "jsesc": "^3.0.2"
91 + },
92 + "engines": {
93 + "node": ">=6.9.0"
94 + }
95 + },
96 + "node_modules/@babel/helper-compilation-targets": {
97 + "version": "7.29.7",
98 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
99 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
100 + "dev": true,
101 + "license": "MIT",
102 + "dependencies": {
103 + "@babel/compat-data": "^7.29.7",
104 + "@babel/helper-validator-option": "^7.29.7",
105 + "browserslist": "^4.24.0",
106 + "lru-cache": "^5.1.1",
107 + "semver": "^6.3.1"
108 + },
109 + "engines": {
110 + "node": ">=6.9.0"
111 + }
112 + },
113 + "node_modules/@babel/helper-globals": {
114 + "version": "7.29.7",
115 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
116 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
117 + "dev": true,
118 + "license": "MIT",
119 + "engines": {
120 + "node": ">=6.9.0"
121 + }
122 + },
123 + "node_modules/@babel/helper-module-imports": {
124 + "version": "7.29.7",
125 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
126 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
127 + "dev": true,
128 + "license": "MIT",
129 + "dependencies": {
130 + "@babel/traverse": "^7.29.7",
131 + "@babel/types": "^7.29.7"
132 + },
133 + "engines": {
134 + "node": ">=6.9.0"
135 + }
136 + },
137 + "node_modules/@babel/helper-module-transforms": {
138 + "version": "7.29.7",
139 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
140 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
141 + "dev": true,
142 + "license": "MIT",
143 + "dependencies": {
144 + "@babel/helper-module-imports": "^7.29.7",
145 + "@babel/helper-validator-identifier": "^7.29.7",
146 + "@babel/traverse": "^7.29.7"
147 + },
148 + "engines": {
149 + "node": ">=6.9.0"
150 + },
151 + "peerDependencies": {
152 + "@babel/core": "^7.0.0"
153 + }
154 + },
155 + "node_modules/@babel/helper-plugin-utils": {
156 + "version": "7.29.7",
157 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
158 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
159 + "dev": true,
160 + "license": "MIT",
161 + "engines": {
162 + "node": ">=6.9.0"
163 + }
164 + },
165 + "node_modules/@babel/helper-string-parser": {
166 + "version": "7.29.7",
167 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
168 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
169 + "dev": true,
170 + "license": "MIT",
171 + "engines": {
172 + "node": ">=6.9.0"
173 + }
174 + },
175 + "node_modules/@babel/helper-validator-identifier": {
176 + "version": "7.29.7",
177 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
178 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
179 + "dev": true,
180 + "license": "MIT",
181 + "engines": {
182 + "node": ">=6.9.0"
183 + }
184 + },
185 + "node_modules/@babel/helper-validator-option": {
186 + "version": "7.29.7",
187 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
188 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
189 + "dev": true,
190 + "license": "MIT",
191 + "engines": {
192 + "node": ">=6.9.0"
193 + }
194 + },
195 + "node_modules/@babel/helpers": {
196 + "version": "7.29.7",
197 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
198 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
199 + "dev": true,
200 + "license": "MIT",
201 + "dependencies": {
202 + "@babel/template": "^7.29.7",
203 + "@babel/types": "^7.29.7"
204 + },
205 + "engines": {
206 + "node": ">=6.9.0"
207 + }
208 + },
209 + "node_modules/@babel/parser": {
210 + "version": "7.29.8",
211 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
212 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
213 + "dev": true,
214 + "license": "MIT",
215 + "dependencies": {
216 + "@babel/types": "^7.29.8"
217 + },
218 + "bin": {
219 + "parser": "bin/babel-parser.js"
220 + },
221 + "engines": {
222 + "node": ">=6.0.0"
223 + }
224 + },
225 + "node_modules/@babel/plugin-transform-react-jsx-self": {
226 + "version": "7.29.7",
227 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
228 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
229 + "dev": true,
230 + "license": "MIT",
231 + "dependencies": {
232 + "@babel/helper-plugin-utils": "^7.29.7"
233 + },
234 + "engines": {
235 + "node": ">=6.9.0"
236 + },
237 + "peerDependencies": {
238 + "@babel/core": "^7.0.0-0"
239 + }
240 + },
241 + "node_modules/@babel/plugin-transform-react-jsx-source": {
242 + "version": "7.29.7",
243 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
244 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
245 + "dev": true,
246 + "license": "MIT",
247 + "dependencies": {
248 + "@babel/helper-plugin-utils": "^7.29.7"
249 + },
250 + "engines": {
251 + "node": ">=6.9.0"
252 + },
253 + "peerDependencies": {
254 + "@babel/core": "^7.0.0-0"
255 + }
256 + },
257 + "node_modules/@babel/template": {
258 + "version": "7.29.7",
259 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
260 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
261 + "dev": true,
262 + "license": "MIT",
263 + "dependencies": {
264 + "@babel/code-frame": "^7.29.7",
265 + "@babel/parser": "^7.29.7",
266 + "@babel/types": "^7.29.7"
267 + },
268 + "engines": {
269 + "node": ">=6.9.0"
270 + }
271 + },
272 + "node_modules/@babel/traverse": {
273 + "version": "7.29.8",
274 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
275 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
276 + "dev": true,
277 + "license": "MIT",
278 + "dependencies": {
279 + "@babel/code-frame": "^7.29.7",
280 + "@babel/generator": "^7.29.8",
281 + "@babel/helper-globals": "^7.29.7",
282 + "@babel/parser": "^7.29.8",
283 + "@babel/template": "^7.29.7",
284 + "@babel/types": "^7.29.8",
285 + "debug": "^4.3.1"
286 + },
287 + "engines": {
288 + "node": ">=6.9.0"
289 + }
290 + },
291 + "node_modules/@babel/types": {
292 + "version": "7.29.8",
293 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
294 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
295 + "dev": true,
296 + "license": "MIT",
297 + "dependencies": {
298 + "@babel/helper-string-parser": "^7.29.7",
299 + "@babel/helper-validator-identifier": "^7.29.7"
300 + },
301 + "engines": {
302 + "node": ">=6.9.0"
303 + }
304 + },
305 + "node_modules/@esbuild/aix-ppc64": {
306 + "version": "0.21.5",
307 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
308 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
309 + "cpu": [
310 + "ppc64"
311 + ],
312 + "dev": true,
313 + "license": "MIT",
314 + "optional": true,
315 + "os": [
316 + "aix"
317 + ],
318 + "engines": {
319 + "node": ">=12"
320 + }
321 + },
322 + "node_modules/@esbuild/android-arm": {
323 + "version": "0.21.5",
324 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
325 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
326 + "cpu": [
327 + "arm"
328 + ],
329 + "dev": true,
330 + "license": "MIT",
331 + "optional": true,
332 + "os": [
333 + "android"
334 + ],
335 + "engines": {
336 + "node": ">=12"
337 + }
338 + },
339 + "node_modules/@esbuild/android-arm64": {
340 + "version": "0.21.5",
341 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
342 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
343 + "cpu": [
344 + "arm64"
345 + ],
346 + "dev": true,
347 + "license": "MIT",
348 + "optional": true,
349 + "os": [
350 + "android"
351 + ],
352 + "engines": {
353 + "node": ">=12"
354 + }
355 + },
356 + "node_modules/@esbuild/android-x64": {
357 + "version": "0.21.5",
358 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
359 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
360 + "cpu": [
361 + "x64"
362 + ],
363 + "dev": true,
364 + "license": "MIT",
365 + "optional": true,
366 + "os": [
367 + "android"
368 + ],
369 + "engines": {
370 + "node": ">=12"
371 + }
372 + },
373 + "node_modules/@esbuild/darwin-arm64": {
374 + "version": "0.21.5",
375 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
376 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
377 + "cpu": [
378 + "arm64"
379 + ],
380 + "dev": true,
381 + "license": "MIT",
382 + "optional": true,
383 + "os": [
384 + "darwin"
385 + ],
386 + "engines": {
387 + "node": ">=12"
388 + }
389 + },
390 + "node_modules/@esbuild/darwin-x64": {
391 + "version": "0.21.5",
392 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
393 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
394 + "cpu": [
395 + "x64"
396 + ],
397 + "dev": true,
398 + "license": "MIT",
399 + "optional": true,
400 + "os": [
401 + "darwin"
402 + ],
403 + "engines": {
404 + "node": ">=12"
405 + }
406 + },
407 + "node_modules/@esbuild/freebsd-arm64": {
408 + "version": "0.21.5",
409 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
410 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
411 + "cpu": [
412 + "arm64"
413 + ],
414 + "dev": true,
415 + "license": "MIT",
416 + "optional": true,
417 + "os": [
418 + "freebsd"
419 + ],
420 + "engines": {
421 + "node": ">=12"
422 + }
423 + },
424 + "node_modules/@esbuild/freebsd-x64": {
425 + "version": "0.21.5",
426 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
427 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
428 + "cpu": [
429 + "x64"
430 + ],
431 + "dev": true,
432 + "license": "MIT",
433 + "optional": true,
434 + "os": [
435 + "freebsd"
436 + ],
437 + "engines": {
438 + "node": ">=12"
439 + }
440 + },
441 + "node_modules/@esbuild/linux-arm": {
442 + "version": "0.21.5",
443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
444 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
445 + "cpu": [
446 + "arm"
447 + ],
448 + "dev": true,
449 + "license": "MIT",
450 + "optional": true,
451 + "os": [
452 + "linux"
453 + ],
454 + "engines": {
455 + "node": ">=12"
456 + }
457 + },
458 + "node_modules/@esbuild/linux-arm64": {
459 + "version": "0.21.5",
460 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
461 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
462 + "cpu": [
463 + "arm64"
464 + ],
465 + "dev": true,
466 + "license": "MIT",
467 + "optional": true,
468 + "os": [
469 + "linux"
470 + ],
471 + "engines": {
472 + "node": ">=12"
473 + }
474 + },
475 + "node_modules/@esbuild/linux-ia32": {
476 + "version": "0.21.5",
477 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
478 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
479 + "cpu": [
480 + "ia32"
481 + ],
482 + "dev": true,
483 + "license": "MIT",
484 + "optional": true,
485 + "os": [
486 + "linux"
487 + ],
488 + "engines": {
489 + "node": ">=12"
490 + }
491 + },
492 + "node_modules/@esbuild/linux-loong64": {
493 + "version": "0.21.5",
494 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
495 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
496 + "cpu": [
497 + "loong64"
498 + ],
499 + "dev": true,
500 + "license": "MIT",
501 + "optional": true,
502 + "os": [
503 + "linux"
504 + ],
505 + "engines": {
506 + "node": ">=12"
507 + }
508 + },
509 + "node_modules/@esbuild/linux-mips64el": {
510 + "version": "0.21.5",
511 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
512 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
513 + "cpu": [
514 + "mips64el"
515 + ],
516 + "dev": true,
517 + "license": "MIT",
518 + "optional": true,
519 + "os": [
520 + "linux"
521 + ],
522 + "engines": {
523 + "node": ">=12"
524 + }
525 + },
526 + "node_modules/@esbuild/linux-ppc64": {
527 + "version": "0.21.5",
528 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
529 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
530 + "cpu": [
531 + "ppc64"
532 + ],
533 + "dev": true,
534 + "license": "MIT",
535 + "optional": true,
536 + "os": [
537 + "linux"
538 + ],
539 + "engines": {
540 + "node": ">=12"
541 + }
542 + },
543 + "node_modules/@esbuild/linux-riscv64": {
544 + "version": "0.21.5",
545 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
546 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
547 + "cpu": [
548 + "riscv64"
549 + ],
550 + "dev": true,
551 + "license": "MIT",
552 + "optional": true,
553 + "os": [
554 + "linux"
555 + ],
556 + "engines": {
557 + "node": ">=12"
558 + }
559 + },
560 + "node_modules/@esbuild/linux-s390x": {
561 + "version": "0.21.5",
562 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
563 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
564 + "cpu": [
565 + "s390x"
566 + ],
567 + "dev": true,
568 + "license": "MIT",
569 + "optional": true,
570 + "os": [
571 + "linux"
572 + ],
573 + "engines": {
574 + "node": ">=12"
575 + }
576 + },
577 + "node_modules/@esbuild/linux-x64": {
578 + "version": "0.21.5",
579 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
580 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
581 + "cpu": [
582 + "x64"
583 + ],
584 + "dev": true,
585 + "license": "MIT",
586 + "optional": true,
587 + "os": [
588 + "linux"
589 + ],
590 + "engines": {
591 + "node": ">=12"
592 + }
593 + },
594 + "node_modules/@esbuild/netbsd-x64": {
595 + "version": "0.21.5",
596 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
597 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
598 + "cpu": [
599 + "x64"
600 + ],
601 + "dev": true,
602 + "license": "MIT",
603 + "optional": true,
604 + "os": [
605 + "netbsd"
606 + ],
607 + "engines": {
608 + "node": ">=12"
609 + }
610 + },
611 + "node_modules/@esbuild/openbsd-x64": {
612 + "version": "0.21.5",
613 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
614 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
615 + "cpu": [
616 + "x64"
617 + ],
618 + "dev": true,
619 + "license": "MIT",
620 + "optional": true,
621 + "os": [
622 + "openbsd"
623 + ],
624 + "engines": {
625 + "node": ">=12"
626 + }
627 + },
628 + "node_modules/@esbuild/sunos-x64": {
629 + "version": "0.21.5",
630 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
631 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
632 + "cpu": [
633 + "x64"
634 + ],
635 + "dev": true,
636 + "license": "MIT",
637 + "optional": true,
638 + "os": [
639 + "sunos"
640 + ],
641 + "engines": {
642 + "node": ">=12"
643 + }
644 + },
645 + "node_modules/@esbuild/win32-arm64": {
646 + "version": "0.21.5",
647 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
648 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
649 + "cpu": [
650 + "arm64"
651 + ],
652 + "dev": true,
653 + "license": "MIT",
654 + "optional": true,
655 + "os": [
656 + "win32"
657 + ],
658 + "engines": {
659 + "node": ">=12"
660 + }
661 + },
662 + "node_modules/@esbuild/win32-ia32": {
663 + "version": "0.21.5",
664 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
665 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
666 + "cpu": [
667 + "ia32"
668 + ],
669 + "dev": true,
670 + "license": "MIT",
671 + "optional": true,
672 + "os": [
673 + "win32"
674 + ],
675 + "engines": {
676 + "node": ">=12"
677 + }
678 + },
679 + "node_modules/@esbuild/win32-x64": {
680 + "version": "0.21.5",
681 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
682 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
683 + "cpu": [
684 + "x64"
685 + ],
686 + "dev": true,
687 + "license": "MIT",
688 + "optional": true,
689 + "os": [
690 + "win32"
691 + ],
692 + "engines": {
693 + "node": ">=12"
694 + }
695 + },
696 + "node_modules/@jridgewell/gen-mapping": {
697 + "version": "0.3.13",
698 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
699 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
700 + "dev": true,
701 + "license": "MIT",
702 + "dependencies": {
703 + "@jridgewell/sourcemap-codec": "^1.5.0",
704 + "@jridgewell/trace-mapping": "^0.3.24"
705 + }
706 + },
707 + "node_modules/@jridgewell/remapping": {
708 + "version": "2.3.5",
709 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
710 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
711 + "dev": true,
712 + "license": "MIT",
713 + "dependencies": {
714 + "@jridgewell/gen-mapping": "^0.3.5",
715 + "@jridgewell/trace-mapping": "^0.3.24"
716 + }
717 + },
718 + "node_modules/@jridgewell/resolve-uri": {
719 + "version": "3.1.2",
720 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
721 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
722 + "dev": true,
723 + "license": "MIT",
724 + "engines": {
725 + "node": ">=6.0.0"
726 + }
727 + },
728 + "node_modules/@jridgewell/sourcemap-codec": {
729 + "version": "1.5.5",
730 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
731 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
732 + "dev": true,
733 + "license": "MIT"
734 + },
735 + "node_modules/@jridgewell/trace-mapping": {
736 + "version": "0.3.31",
737 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
738 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
739 + "dev": true,
740 + "license": "MIT",
741 + "dependencies": {
742 + "@jridgewell/resolve-uri": "^3.1.0",
743 + "@jridgewell/sourcemap-codec": "^1.4.14"
744 + }
745 + },
746 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
747 + "version": "1.5.1",
748 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
749 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
750 + "cpu": [
751 + "x64"
752 + ],
753 + "dev": true,
754 + "libc": [
755 + "glibc"
756 + ],
757 + "license": "MIT",
758 + "optional": true,
759 + "os": [
760 + "linux"
761 + ],
762 + "engines": {
763 + "node": "^22.20 || ^24.12 || >=25"
764 + }
765 + },
766 + "node_modules/@remix-run/router": {
767 + "version": "1.23.3",
768 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
769 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
770 + "license": "MIT",
771 + "engines": {
772 + "node": ">=14.0.0"
773 + }
774 + },
775 + "node_modules/@rolldown/pluginutils": {
776 + "version": "1.0.0-beta.27",
777 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
778 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
779 + "dev": true,
780 + "license": "MIT"
781 + },
782 + "node_modules/@rollup/rollup-android-arm-eabi": {
783 + "version": "4.62.4",
784 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
785 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
786 + "cpu": [
787 + "arm"
788 + ],
789 + "dev": true,
790 + "license": "MIT",
791 + "optional": true,
792 + "os": [
793 + "android"
794 + ]
795 + },
796 + "node_modules/@rollup/rollup-android-arm64": {
797 + "version": "4.62.4",
798 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
799 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
800 + "cpu": [
801 + "arm64"
802 + ],
803 + "dev": true,
804 + "license": "MIT",
805 + "optional": true,
806 + "os": [
807 + "android"
808 + ]
809 + },
810 + "node_modules/@rollup/rollup-darwin-arm64": {
811 + "version": "4.62.4",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
813 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
814 + "cpu": [
815 + "arm64"
816 + ],
817 + "dev": true,
818 + "license": "MIT",
819 + "optional": true,
820 + "os": [
821 + "darwin"
822 + ]
823 + },
824 + "node_modules/@rollup/rollup-darwin-x64": {
825 + "version": "4.62.4",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
827 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
828 + "cpu": [
829 + "x64"
830 + ],
831 + "dev": true,
832 + "license": "MIT",
833 + "optional": true,
834 + "os": [
835 + "darwin"
836 + ]
837 + },
838 + "node_modules/@rollup/rollup-freebsd-arm64": {
839 + "version": "4.62.4",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
841 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
842 + "cpu": [
843 + "arm64"
844 + ],
845 + "dev": true,
846 + "license": "MIT",
847 + "optional": true,
848 + "os": [
849 + "freebsd"
850 + ]
851 + },
852 + "node_modules/@rollup/rollup-freebsd-x64": {
853 + "version": "4.62.4",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
855 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
856 + "cpu": [
857 + "x64"
858 + ],
859 + "dev": true,
860 + "license": "MIT",
861 + "optional": true,
862 + "os": [
863 + "freebsd"
864 + ]
865 + },
866 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
867 + "version": "4.62.4",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
869 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
870 + "cpu": [
871 + "arm"
872 + ],
873 + "dev": true,
874 + "libc": [
875 + "glibc"
876 + ],
877 + "license": "MIT",
878 + "optional": true,
879 + "os": [
880 + "linux"
881 + ]
882 + },
883 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
884 + "version": "4.62.4",
885 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
886 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
887 + "cpu": [
888 + "arm"
889 + ],
890 + "dev": true,
891 + "libc": [
892 + "musl"
893 + ],
894 + "license": "MIT",
895 + "optional": true,
896 + "os": [
897 + "linux"
898 + ]
899 + },
900 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
901 + "version": "4.62.4",
902 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
903 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
904 + "cpu": [
905 + "arm64"
906 + ],
907 + "dev": true,
908 + "libc": [
909 + "glibc"
910 + ],
911 + "license": "MIT",
912 + "optional": true,
913 + "os": [
914 + "linux"
915 + ]
916 + },
917 + "node_modules/@rollup/rollup-linux-arm64-musl": {
918 + "version": "4.62.4",
919 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
920 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
921 + "cpu": [
922 + "arm64"
923 + ],
924 + "dev": true,
925 + "libc": [
926 + "musl"
927 + ],
928 + "license": "MIT",
929 + "optional": true,
930 + "os": [
931 + "linux"
932 + ]
933 + },
934 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
935 + "version": "4.62.4",
936 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
937 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
938 + "cpu": [
939 + "loong64"
940 + ],
941 + "dev": true,
942 + "libc": [
943 + "glibc"
944 + ],
945 + "license": "MIT",
946 + "optional": true,
947 + "os": [
948 + "linux"
949 + ]
950 + },
951 + "node_modules/@rollup/rollup-linux-loong64-musl": {
952 + "version": "4.62.4",
953 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
954 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
955 + "cpu": [
956 + "loong64"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "musl"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ]
967 + },
968 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
969 + "version": "4.62.4",
970 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
971 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
972 + "cpu": [
973 + "ppc64"
974 + ],
975 + "dev": true,
976 + "libc": [
977 + "glibc"
978 + ],
979 + "license": "MIT",
980 + "optional": true,
981 + "os": [
982 + "linux"
983 + ]
984 + },
985 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
986 + "version": "4.62.4",
987 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
988 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
989 + "cpu": [
990 + "ppc64"
991 + ],
992 + "dev": true,
993 + "libc": [
994 + "musl"
995 + ],
996 + "license": "MIT",
997 + "optional": true,
998 + "os": [
999 + "linux"
1000 + ]
1001 + },
1002 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1003 + "version": "4.62.4",
1004 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1005 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1006 + "cpu": [
1007 + "riscv64"
1008 + ],
1009 + "dev": true,
1010 + "libc": [
1011 + "glibc"
1012 + ],
1013 + "license": "MIT",
1014 + "optional": true,
1015 + "os": [
1016 + "linux"
1017 + ]
1018 + },
1019 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1020 + "version": "4.62.4",
1021 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1022 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1023 + "cpu": [
1024 + "riscv64"
1025 + ],
1026 + "dev": true,
1027 + "libc": [
1028 + "musl"
1029 + ],
1030 + "license": "MIT",
1031 + "optional": true,
1032 + "os": [
1033 + "linux"
1034 + ]
1035 + },
1036 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1037 + "version": "4.62.4",
1038 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1039 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1040 + "cpu": [
1041 + "s390x"
1042 + ],
1043 + "dev": true,
1044 + "libc": [
1045 + "glibc"
1046 + ],
1047 + "license": "MIT",
1048 + "optional": true,
1049 + "os": [
1050 + "linux"
1051 + ]
1052 + },
1053 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1054 + "version": "4.62.4",
1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1056 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1057 + "cpu": [
1058 + "x64"
1059 + ],
1060 + "dev": true,
1061 + "libc": [
1062 + "glibc"
1063 + ],
1064 + "license": "MIT",
1065 + "optional": true,
1066 + "os": [
1067 + "linux"
1068 + ]
1069 + },
1070 + "node_modules/@rollup/rollup-linux-x64-musl": {
1071 + "version": "4.62.4",
1072 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1073 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1074 + "cpu": [
1075 + "x64"
1076 + ],
1077 + "dev": true,
1078 + "libc": [
1079 + "musl"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "linux"
1085 + ]
1086 + },
1087 + "node_modules/@rollup/rollup-openbsd-x64": {
1088 + "version": "4.62.4",
1089 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1090 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1091 + "cpu": [
1092 + "x64"
1093 + ],
1094 + "dev": true,
1095 + "license": "MIT",
1096 + "optional": true,
1097 + "os": [
1098 + "openbsd"
1099 + ]
1100 + },
1101 + "node_modules/@rollup/rollup-openharmony-arm64": {
1102 + "version": "4.62.4",
1103 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1104 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1105 + "cpu": [
1106 + "arm64"
1107 + ],
1108 + "dev": true,
1109 + "license": "MIT",
1110 + "optional": true,
1111 + "os": [
1112 + "openharmony"
1113 + ]
1114 + },
1115 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1116 + "version": "4.62.4",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1118 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1119 + "cpu": [
1120 + "arm64"
1121 + ],
1122 + "dev": true,
1123 + "license": "MIT",
1124 + "optional": true,
1125 + "os": [
1126 + "win32"
1127 + ]
1128 + },
1129 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1130 + "version": "4.62.4",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1132 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1133 + "cpu": [
1134 + "ia32"
1135 + ],
1136 + "dev": true,
1137 + "license": "MIT",
1138 + "optional": true,
1139 + "os": [
1140 + "win32"
1141 + ]
1142 + },
1143 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1144 + "version": "4.62.4",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1146 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1147 + "cpu": [
1148 + "x64"
1149 + ],
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "optional": true,
1153 + "os": [
1154 + "win32"
1155 + ]
1156 + },
1157 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1158 + "version": "4.62.4",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1160 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1161 + "cpu": [
1162 + "x64"
1163 + ],
1164 + "dev": true,
1165 + "license": "MIT",
1166 + "optional": true,
1167 + "os": [
1168 + "win32"
1169 + ]
1170 + },
1171 + "node_modules/@types/babel__core": {
1172 + "version": "7.20.5",
1173 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1174 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1175 + "dev": true,
1176 + "license": "MIT",
1177 + "dependencies": {
1178 + "@babel/parser": "^7.20.7",
1179 + "@babel/types": "^7.20.7",
1180 + "@types/babel__generator": "*",
1181 + "@types/babel__template": "*",
1182 + "@types/babel__traverse": "*"
1183 + }
1184 + },
1185 + "node_modules/@types/babel__generator": {
1186 + "version": "7.27.0",
1187 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1188 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1189 + "dev": true,
1190 + "license": "MIT",
1191 + "dependencies": {
1192 + "@babel/types": "^7.0.0"
1193 + }
1194 + },
1195 + "node_modules/@types/babel__template": {
1196 + "version": "7.4.4",
1197 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1198 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1199 + "dev": true,
1200 + "license": "MIT",
1201 + "dependencies": {
1202 + "@babel/parser": "^7.1.0",
1203 + "@babel/types": "^7.0.0"
1204 + }
1205 + },
1206 + "node_modules/@types/babel__traverse": {
1207 + "version": "7.28.0",
1208 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1209 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1210 + "dev": true,
1211 + "license": "MIT",
1212 + "dependencies": {
1213 + "@babel/types": "^7.28.2"
1214 + }
1215 + },
1216 + "node_modules/@types/estree": {
1217 + "version": "1.0.9",
1218 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1219 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1220 + "dev": true,
1221 + "license": "MIT"
1222 + },
1223 + "node_modules/@types/prop-types": {
1224 + "version": "15.7.15",
1225 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1226 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1227 + "dev": true,
1228 + "license": "MIT"
1229 + },
1230 + "node_modules/@types/react": {
1231 + "version": "18.3.31",
1232 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1233 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1234 + "dev": true,
1235 + "license": "MIT",
1236 + "dependencies": {
1237 + "@types/prop-types": "*",
1238 + "csstype": "^3.2.2"
1239 + }
1240 + },
1241 + "node_modules/@types/react-dom": {
1242 + "version": "18.3.7",
1243 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1244 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1245 + "dev": true,
1246 + "license": "MIT",
1247 + "peerDependencies": {
1248 + "@types/react": "^18.0.0"
1249 + }
1250 + },
1251 + "node_modules/@vitejs/plugin-react": {
1252 + "version": "4.7.0",
1253 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1254 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1255 + "dev": true,
1256 + "license": "MIT",
1257 + "dependencies": {
1258 + "@babel/core": "^7.28.0",
1259 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1260 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1261 + "@rolldown/pluginutils": "1.0.0-beta.27",
1262 + "@types/babel__core": "^7.20.5",
1263 + "react-refresh": "^0.17.0"
1264 + },
1265 + "engines": {
1266 + "node": "^14.18.0 || >=16.0.0"
1267 + },
1268 + "peerDependencies": {
1269 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1270 + }
1271 + },
1272 + "node_modules/baseline-browser-mapping": {
1273 + "version": "2.11.13",
1274 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
1275 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
1276 + "dev": true,
1277 + "license": "Apache-2.0",
1278 + "bin": {
1279 + "baseline-browser-mapping": "dist/cli.cjs"
1280 + },
1281 + "engines": {
1282 + "node": ">=6.0.0"
1283 + }
1284 + },
1285 + "node_modules/browserslist": {
1286 + "version": "4.28.8",
1287 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1288 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1289 + "dev": true,
1290 + "funding": [
1291 + {
1292 + "type": "opencollective",
1293 + "url": "https://opencollective.com/browserslist"
1294 + },
1295 + {
1296 + "type": "tidelift",
1297 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1298 + },
1299 + {
1300 + "type": "github",
1301 + "url": "https://github.com/sponsors/ai"
1302 + }
1303 + ],
1304 + "license": "MIT",
1305 + "dependencies": {
1306 + "baseline-browser-mapping": "^2.11.12",
1307 + "caniuse-lite": "^1.0.30001809",
1308 + "electron-to-chromium": "^1.5.402",
1309 + "node-releases": "^2.0.53",
1310 + "update-browserslist-db": "^1.3.0"
1311 + },
1312 + "bin": {
1313 + "browserslist": "cli.js"
1314 + },
1315 + "engines": {
1316 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1317 + }
1318 + },
1319 + "node_modules/caniuse-lite": {
1320 + "version": "1.0.30001809",
1321 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1322 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1323 + "dev": true,
1324 + "funding": [
1325 + {
1326 + "type": "opencollective",
1327 + "url": "https://opencollective.com/browserslist"
1328 + },
1329 + {
1330 + "type": "tidelift",
1331 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1332 + },
1333 + {
1334 + "type": "github",
1335 + "url": "https://github.com/sponsors/ai"
1336 + }
1337 + ],
1338 + "license": "CC-BY-4.0"
1339 + },
1340 + "node_modules/convert-source-map": {
1341 + "version": "2.0.0",
1342 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1343 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1344 + "dev": true,
1345 + "license": "MIT"
1346 + },
1347 + "node_modules/csstype": {
1348 + "version": "3.2.3",
1349 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1350 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1351 + "dev": true,
1352 + "license": "MIT"
1353 + },
1354 + "node_modules/debug": {
1355 + "version": "4.4.3",
1356 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1357 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1358 + "dev": true,
1359 + "license": "MIT",
1360 + "dependencies": {
1361 + "ms": "^2.1.3"
1362 + },
1363 + "engines": {
1364 + "node": ">=6.0"
1365 + },
1366 + "peerDependenciesMeta": {
1367 + "supports-color": {
1368 + "optional": true
1369 + }
1370 + }
1371 + },
1372 + "node_modules/electron-to-chromium": {
1373 + "version": "1.5.405",
1374 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
1375 + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
1376 + "dev": true,
1377 + "license": "ISC"
1378 + },
1379 + "node_modules/esbuild": {
1380 + "version": "0.21.5",
1381 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1382 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1383 + "dev": true,
1384 + "hasInstallScript": true,
1385 + "license": "MIT",
1386 + "bin": {
1387 + "esbuild": "bin/esbuild"
1388 + },
1389 + "engines": {
1390 + "node": ">=12"
1391 + },
1392 + "optionalDependencies": {
1393 + "@esbuild/aix-ppc64": "0.21.5",
1394 + "@esbuild/android-arm": "0.21.5",
1395 + "@esbuild/android-arm64": "0.21.5",
1396 + "@esbuild/android-x64": "0.21.5",
1397 + "@esbuild/darwin-arm64": "0.21.5",
1398 + "@esbuild/darwin-x64": "0.21.5",
1399 + "@esbuild/freebsd-arm64": "0.21.5",
1400 + "@esbuild/freebsd-x64": "0.21.5",
1401 + "@esbuild/linux-arm": "0.21.5",
1402 + "@esbuild/linux-arm64": "0.21.5",
1403 + "@esbuild/linux-ia32": "0.21.5",
1404 + "@esbuild/linux-loong64": "0.21.5",
1405 + "@esbuild/linux-mips64el": "0.21.5",
1406 + "@esbuild/linux-ppc64": "0.21.5",
1407 + "@esbuild/linux-riscv64": "0.21.5",
1408 + "@esbuild/linux-s390x": "0.21.5",
1409 + "@esbuild/linux-x64": "0.21.5",
1410 + "@esbuild/netbsd-x64": "0.21.5",
1411 + "@esbuild/openbsd-x64": "0.21.5",
1412 + "@esbuild/sunos-x64": "0.21.5",
1413 + "@esbuild/win32-arm64": "0.21.5",
1414 + "@esbuild/win32-ia32": "0.21.5",
1415 + "@esbuild/win32-x64": "0.21.5"
1416 + }
1417 + },
1418 + "node_modules/escalade": {
1419 + "version": "3.2.0",
1420 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1421 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1422 + "dev": true,
1423 + "license": "MIT",
1424 + "engines": {
1425 + "node": ">=6"
1426 + }
1427 + },
1428 + "node_modules/fsevents": {
1429 + "version": "2.3.3",
1430 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1431 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1432 + "dev": true,
1433 + "hasInstallScript": true,
1434 + "license": "MIT",
1435 + "optional": true,
1436 + "os": [
1437 + "darwin"
1438 + ],
1439 + "engines": {
1440 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1441 + }
1442 + },
1443 + "node_modules/gensync": {
1444 + "version": "1.0.0-beta.2",
1445 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1446 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1447 + "dev": true,
1448 + "license": "MIT",
1449 + "engines": {
1450 + "node": ">=6.9.0"
1451 + }
1452 + },
1453 + "node_modules/js-tokens": {
1454 + "version": "4.0.0",
1455 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1456 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1457 + "license": "MIT"
1458 + },
1459 + "node_modules/jsesc": {
1460 + "version": "3.1.0",
1461 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1462 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1463 + "dev": true,
1464 + "license": "MIT",
1465 + "bin": {
1466 + "jsesc": "bin/jsesc"
1467 + },
1468 + "engines": {
1469 + "node": ">=6"
1470 + }
1471 + },
1472 + "node_modules/json5": {
1473 + "version": "2.2.3",
1474 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1475 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1476 + "dev": true,
1477 + "license": "MIT",
1478 + "bin": {
1479 + "json5": "lib/cli.js"
1480 + },
1481 + "engines": {
1482 + "node": ">=6"
1483 + }
1484 + },
1485 + "node_modules/loose-envify": {
1486 + "version": "1.4.0",
1487 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1488 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1489 + "license": "MIT",
1490 + "dependencies": {
1491 + "js-tokens": "^3.0.0 || ^4.0.0"
1492 + },
1493 + "bin": {
1494 + "loose-envify": "cli.js"
1495 + }
1496 + },
1497 + "node_modules/lru-cache": {
1498 + "version": "5.1.1",
1499 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1500 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1501 + "dev": true,
1502 + "license": "ISC",
1503 + "dependencies": {
1504 + "yallist": "^3.0.2"
1505 + }
1506 + },
1507 + "node_modules/ms": {
1508 + "version": "2.1.3",
1509 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1510 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1511 + "dev": true,
1512 + "license": "MIT"
1513 + },
1514 + "node_modules/nanoid": {
1515 + "version": "3.3.18",
1516 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1517 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1518 + "dev": true,
1519 + "funding": [
1520 + {
1521 + "type": "github",
1522 + "url": "https://github.com/sponsors/ai"
1523 + }
1524 + ],
1525 + "license": "MIT",
1526 + "bin": {
1527 + "nanoid": "bin/nanoid.cjs"
1528 + },
1529 + "engines": {
1530 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1531 + }
1532 + },
1533 + "node_modules/node-releases": {
1534 + "version": "2.0.53",
1535 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1536 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1537 + "dev": true,
1538 + "license": "MIT",
1539 + "engines": {
1540 + "node": ">=18"
1541 + }
1542 + },
1543 + "node_modules/picocolors": {
1544 + "version": "1.1.1",
1545 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1546 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1547 + "dev": true,
1548 + "license": "ISC"
1549 + },
1550 + "node_modules/postcss": {
1551 + "version": "8.5.26",
1552 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1553 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1554 + "dev": true,
1555 + "funding": [
1556 + {
1557 + "type": "opencollective",
1558 + "url": "https://opencollective.com/postcss/"
1559 + },
1560 + {
1561 + "type": "tidelift",
1562 + "url": "https://tidelift.com/funding/github/npm/postcss"
1563 + },
1564 + {
1565 + "type": "github",
1566 + "url": "https://github.com/sponsors/ai"
1567 + }
1568 + ],
1569 + "license": "MIT",
1570 + "dependencies": {
1571 + "nanoid": "^3.3.17",
1572 + "picocolors": "^1.1.1",
1573 + "source-map-js": "^1.2.1"
1574 + },
1575 + "engines": {
1576 + "node": "^10 || ^12 || >=14"
1577 + }
1578 + },
1579 + "node_modules/react": {
1580 + "version": "18.3.1",
1581 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1582 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1583 + "license": "MIT",
1584 + "dependencies": {
1585 + "loose-envify": "^1.1.0"
1586 + },
1587 + "engines": {
1588 + "node": ">=0.10.0"
1589 + }
1590 + },
1591 + "node_modules/react-dom": {
1592 + "version": "18.3.1",
1593 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1594 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1595 + "license": "MIT",
1596 + "dependencies": {
1597 + "loose-envify": "^1.1.0",
1598 + "scheduler": "^0.23.2"
1599 + },
1600 + "peerDependencies": {
1601 + "react": "^18.3.1"
1602 + }
1603 + },
1604 + "node_modules/react-refresh": {
1605 + "version": "0.17.0",
1606 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1607 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1608 + "dev": true,
1609 + "license": "MIT",
1610 + "engines": {
1611 + "node": ">=0.10.0"
1612 + }
1613 + },
1614 + "node_modules/react-router": {
1615 + "version": "6.30.4",
1616 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1617 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1618 + "license": "MIT",
1619 + "dependencies": {
1620 + "@remix-run/router": "1.23.3"
1621 + },
1622 + "engines": {
1623 + "node": ">=14.0.0"
1624 + },
1625 + "peerDependencies": {
1626 + "react": ">=16.8"
1627 + }
1628 + },
1629 + "node_modules/react-router-dom": {
1630 + "version": "6.30.4",
1631 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1632 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1633 + "license": "MIT",
1634 + "dependencies": {
1635 + "@remix-run/router": "1.23.3",
1636 + "react-router": "6.30.4"
1637 + },
1638 + "engines": {
1639 + "node": ">=14.0.0"
1640 + },
1641 + "peerDependencies": {
1642 + "react": ">=16.8",
1643 + "react-dom": ">=16.8"
1644 + }
1645 + },
1646 + "node_modules/rollup": {
1647 + "version": "4.62.4",
1648 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1649 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1650 + "dev": true,
1651 + "license": "MIT",
1652 + "dependencies": {
1653 + "@types/estree": "1.0.9"
1654 + },
1655 + "bin": {
1656 + "rollup": "dist/bin/rollup"
1657 + },
1658 + "engines": {
1659 + "node": ">=18.0.0",
1660 + "npm": ">=8.0.0"
1661 + },
1662 + "optionalDependencies": {
1663 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1664 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1665 + "@rollup/rollup-android-arm64": "4.62.4",
1666 + "@rollup/rollup-darwin-arm64": "4.62.4",
1667 + "@rollup/rollup-darwin-x64": "4.62.4",
1668 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1669 + "@rollup/rollup-freebsd-x64": "4.62.4",
1670 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1671 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1672 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1673 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1674 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1675 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1676 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1677 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1678 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1679 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1680 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1681 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1682 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1683 + "@rollup/rollup-openbsd-x64": "4.62.4",
1684 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1685 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1686 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1687 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1688 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1689 + "fsevents": "~2.3.2"
1690 + }
1691 + },
1692 + "node_modules/scheduler": {
1693 + "version": "0.23.2",
1694 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1695 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1696 + "license": "MIT",
1697 + "dependencies": {
1698 + "loose-envify": "^1.1.0"
1699 + }
1700 + },
1701 + "node_modules/semver": {
1702 + "version": "6.3.1",
1703 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1704 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1705 + "dev": true,
1706 + "license": "ISC",
1707 + "bin": {
1708 + "semver": "bin/semver.js"
1709 + }
1710 + },
1711 + "node_modules/source-map-js": {
1712 + "version": "1.2.1",
1713 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1714 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1715 + "dev": true,
1716 + "license": "BSD-3-Clause",
1717 + "engines": {
1718 + "node": ">=0.10.0"
1719 + }
1720 + },
1721 + "node_modules/typescript": {
1722 + "version": "5.9.3",
1723 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1724 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1725 + "dev": true,
1726 + "license": "Apache-2.0",
1727 + "bin": {
1728 + "tsc": "bin/tsc",
1729 + "tsserver": "bin/tsserver"
1730 + },
1731 + "engines": {
1732 + "node": ">=14.17"
1733 + }
1734 + },
1735 + "node_modules/update-browserslist-db": {
1736 + "version": "1.3.1",
1737 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
1738 + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
1739 + "dev": true,
1740 + "funding": [
1741 + {
1742 + "type": "opencollective",
1743 + "url": "https://opencollective.com/browserslist"
1744 + },
1745 + {
1746 + "type": "tidelift",
1747 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1748 + },
1749 + {
1750 + "type": "github",
1751 + "url": "https://github.com/sponsors/ai"
1752 + }
1753 + ],
1754 + "license": "MIT",
1755 + "dependencies": {
1756 + "escalade": "^3.2.0",
1757 + "picocolors": "^1.1.1"
1758 + },
1759 + "bin": {
1760 + "update-browserslist-db": "cli.js"
1761 + },
1762 + "peerDependencies": {
1763 + "browserslist": ">= 4.21.0"
1764 + }
1765 + },
1766 + "node_modules/vite": {
1767 + "version": "5.4.21",
1768 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1769 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1770 + "dev": true,
1771 + "license": "MIT",
1772 + "dependencies": {
1773 + "esbuild": "^0.21.3",
1774 + "postcss": "^8.4.43",
1775 + "rollup": "^4.20.0"
1776 + },
1777 + "bin": {
1778 + "vite": "bin/vite.js"
1779 + },
1780 + "engines": {
1781 + "node": "^18.0.0 || >=20.0.0"
1782 + },
1783 + "funding": {
1784 + "url": "https://github.com/vitejs/vite?sponsor=1"
1785 + },
1786 + "optionalDependencies": {
1787 + "fsevents": "~2.3.3"
1788 + },
1789 + "peerDependencies": {
1790 + "@types/node": "^18.0.0 || >=20.0.0",
1791 + "less": "*",
1792 + "lightningcss": "^1.21.0",
1793 + "sass": "*",
1794 + "sass-embedded": "*",
1795 + "stylus": "*",
1796 + "sugarss": "*",
1797 + "terser": "^5.4.0"
1798 + },
1799 + "peerDependenciesMeta": {
1800 + "@types/node": {
1801 + "optional": true
1802 + },
1803 + "less": {
1804 + "optional": true
1805 + },
1806 + "lightningcss": {
1807 + "optional": true
1808 + },
1809 + "sass": {
1810 + "optional": true
1811 + },
1812 + "sass-embedded": {
1813 + "optional": true
1814 + },
1815 + "stylus": {
1816 + "optional": true
1817 + },
1818 + "sugarss": {
1819 + "optional": true
1820 + },
1821 + "terser": {
1822 + "optional": true
1823 + }
1824 + }
1825 + },
1826 + "node_modules/yallist": {
1827 + "version": "3.1.1",
1828 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1829 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1830 + "dev": true,
1831 + "license": "ISC"
1832 + }
1833 + }
1834 +}
added frontend/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "food-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/react": "^18.3.3",
19 + "@types/react-dom": "^18.3.0",
20 + "@vitejs/plugin-react": "^4.3.1",
21 + "typescript": "^5.5.4",
22 + "vite": "^5.4.0"
23 + }
24 +}
added frontend/public/logos/adonis.png +0 −0

Binary file not shown.

added frontend/public/logos/akhavan.png +0 −0

Binary file not shown.

added frontend/public/logos/aliments_merci.png +0 −0

Binary file not shown.

added frontend/public/logos/aubut.png +0 −0

Binary file not shown.

added frontend/public/logos/avril.png +0 −0

Binary file not shown.

added frontend/public/logos/bocoboco.png +0 −0

Binary file not shown.

added frontend/public/logos/boite_a_grains.png +0 −0

Binary file not shown.

added frontend/public/logos/club_entrepot.png +0 −0

Binary file not shown.

added frontend/public/logos/costco.png +0 −0

Binary file not shown.

added frontend/public/logos/epipresto.png +0 −0

Binary file not shown.

added frontend/public/logos/frenco.png +0 −0

Binary file not shown.

added frontend/public/logos/giant_tiger.png +0 −0

Binary file not shown.

added frontend/public/logos/iga.png +0 −0

Binary file not shown.

added frontend/public/logos/loco.png +0 −0

Binary file not shown.

added frontend/public/logos/maturin.png +0 −0

Binary file not shown.

added frontend/public/logos/maxi.png +0 −0

Binary file not shown.

added frontend/public/logos/mayrand.png +0 −0

Binary file not shown.

added frontend/public/logos/metro.png +0 −0

Binary file not shown.

added frontend/public/logos/nuvo.png +0 −0

Binary file not shown.

added frontend/public/logos/pa.png +0 −0

Binary file not shown.

added frontend/public/logos/provigo.png +0 −0

Binary file not shown.

added frontend/public/logos/superc.png +0 −0

Binary file not shown.

added frontend/public/logos/tau.png +0 −0

Binary file not shown.

added frontend/public/logos/tt.png +0 −0

Binary file not shown.

added frontend/public/logos/walmart.png +0 −0

Binary file not shown.

added frontend/public/manifest.webmanifest +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "name": "Food·Ka — Tous les prix d'épicerie du Québec",
3 + "short_name": "Food·Ka",
4 + "description": "Tous les prix d'épicerie du Québec. Un seul endroit.",
5 + "start_url": "/",
6 + "display": "standalone",
7 + "background_color": "#faf6ee",
8 + "theme_color": "#faf6ee",
9 + "lang": "fr",
10 + "icons": [
11 + {
12 + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect width='512' height='512' rx='96' fill='%2314523a'/%3E%3Ctext x='256' y='356' font-family='Arial Black,sans-serif' font-size='256' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3EFK%3C/text%3E%3C/svg%3E",
13 + "sizes": "512x512",
14 + "type": "image/svg+xml",
15 + "purpose": "any"
16 + }
17 + ]
18 +}
added frontend/src/App.tsx +200 −0
@@ -0,0 +1,200 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (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, useLocation } from "react-router-dom";
8 +import { fetchFacets, fetchSources, fetchStats, fmtPrice, registerSourceNames, sourceName } from "./api";
9 +import CookieConsent from "./components/CookieConsent";
10 +import Home from "./pages/Home";
11 +import ProductPage from "./pages/Product";
12 +import PrivacyPage from "./pages/Privacy";
13 +import SourcesPage from "./pages/Sources";
14 +import StatsPage from "./pages/Stats";
15 +
16 +function Ticker() {
17 + const [items, setItems] = useState<string[]>([]);
18 +
19 + useEffect(() => {
20 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
21 + .then(([stats, facets, src]) => {
22 + registerSourceNames(src.sources);
23 + const parts: string[] = [
24 + `${stats.total} produits suivis`,
25 + `${stats.sources} bannières`,
26 + `${stats.on_sale} soldes actifs`,
27 + ];
28 + if (stats.avg_price != null)
29 + parts.push(`Prix moyen ${fmtPrice(stats.avg_price)}`);
30 + for (const s of facets.sources.slice(0, 10))
31 + parts.push(`${sourceName(s.source)} · ${s.n}`);
32 + parts.push("Mise à jour automatique");
33 + setItems(parts);
34 + })
35 + .catch(() => setItems(["Food·Ka — tous les prix d'épicerie du Québec"]));
36 + }, []);
37 +
38 + if (items.length === 0) return null;
39 + // contenu doublé pour une boucle de défilement continue
40 + return (
41 + <div className="ticker" aria-hidden="true">
42 + <div className="ticker-track">
43 + {[...items, ...items].map((t, i) => (
44 + <span key={i}>{t}</span>
45 + ))}
46 + </div>
47 + </div>
48 + );
49 +}
50 +
51 +const NAV_LINKS = [
52 + { to: "/", label: "Produits", icon: "🛒", end: true },
53 + { to: "/aubaines", label: "Aubaines", icon: "🔥", end: false },
54 + { to: "/sources", label: "Sources", icon: "🗂", end: false },
55 + { to: "/stats", label: "Stats", icon: "📊", end: false },
56 + { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },
57 +];
58 +
59 +function Header() {
60 + const [open, setOpen] = useState(false);
61 + const location = useLocation();
62 +
63 + // fermer le menu à chaque navigation + verrouiller le défilement en dessous
64 + useEffect(() => { setOpen(false); }, [location]);
65 + useEffect(() => {
66 + document.body.style.overflow = open ? "hidden" : "";
67 + return () => { document.body.style.overflow = ""; };
68 + }, [open]);
69 +
70 + return (
71 + <>
72 + <header className="header">
73 + <div className="container header-inner">
74 + <NavLink to="/" className="brand" aria-label="Food·Ka — accueil">
75 + <span className="brand-mark" aria-hidden="true">
76 + {/* panier d'épicerie */}
77 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
78 + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
79 + <path d="M5 9l2.5-5M19 9l-2.5-5" />
80 + <path d="M2.5 9h19l-1.8 9.2a2 2 0 0 1-2 1.8H6.3a2 2 0 0 1-2-1.8Z" />
81 + <path d="M9 13v3M12 13v3M15 13v3" />
82 + </svg>
83 + </span>
84 + Food<span className="ka">Ka</span>
85 + <span className="brand-tag">Épicerie — tout le Québec, toujours à jour</span>
86 + </NavLink>
87 + <nav className="nav" aria-label="Navigation principale">
88 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
89 + Produits
90 + </NavLink>
91 + <NavLink to="/aubaines" className={({ isActive }) => (isActive ? "active" : "")}>
92 + Aubaines
93 + </NavLink>
94 + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
95 + Sources
96 + </NavLink>
97 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
98 + Stats
99 + </NavLink>
100 + </nav>
101 + <button
102 + className={`menu-btn ${open ? "open" : ""}`}
103 + aria-expanded={open}
104 + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
105 + onClick={() => setOpen(!open)}
106 + >
107 + <span /><span /><span />
108 + </button>
109 + </div>
110 +
111 + {/* menu déroulant mobile */}
112 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">
113 + {NAV_LINKS.map((l, i) => (
114 + <NavLink
115 + key={l.to}
116 + to={l.to}
117 + end={l.end}
118 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
119 + className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}
120 + onClick={() => setOpen(false)}
121 + >
122 + <span className="mm-ico" aria-hidden="true">{l.icon}</span>
123 + {l.label}
124 + <span className="mm-arrow" aria-hidden="true">→</span>
125 + </NavLink>
126 + ))}
127 + <div className="mm-foot">
128 + Agrégateur indépendant — mis à jour automatiquement, chaque fiche
129 + renvoie à la page produit originale.
130 + </div>
131 + </div>
132 + </header>
133 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
134 + <Ticker />
135 + </>
136 + );
137 +}
138 +
139 +function Footer() {
140 + return (
141 + <footer className="footer">
142 + <div className="container">
143 + <div className="fbrand">
144 + Food<span className="ka">Ka</span>
145 + </div>
146 + <div className="frow">
147 + <div>
148 + <b>Agrégateur indépendant</b> de produits d'épicerie de la province de Québec.
149 + Les prix proviennent des sites publics des bannières et sont rafraîchis
150 + automatiquement — chaque fiche renvoie vers la page produit originale.
151 + </div>
152 + </div>
153 + <div className="fmono">
154 + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai
155 + {" · "}
156 + www.food-ka.com
157 + {" · "}
158 + <NavLink to="/confidentialite">Confidentialité</NavLink>
159 + {" · "}
160 + <button
161 + className="flink"
162 + onClick={() => window.dispatchEvent(new Event("foodka:openConsent"))}
163 + >
164 + Gérer mes témoins
165 + </button>
166 + </div>
167 + </div>
168 + </footer>
169 + );
170 +}
171 +
172 +export default function App() {
173 + return (
174 + <>
175 + <Header />
176 + <main>
177 + <Routes>
178 + <Route path="/" element={<Home />} />
179 + <Route path="/aubaines" element={<Home aubaines />} />
180 + <Route path="/produit/:uid" element={<ProductPage />} />
181 + <Route path="/stats" element={<StatsPage />} />
182 + <Route path="/sources" element={<SourcesPage />} />
183 + <Route path="/confidentialite" element={<PrivacyPage />} />
184 + <Route
185 + path="*"
186 + element={
187 + <div className="notice container">
188 + <div className="big">🧭</div>
189 + <h2>Page introuvable</h2>
190 + <p>Le lien demandé n'existe pas.</p>
191 + </div>
192 + }
193 + />
194 + </Routes>
195 + </main>
196 + <Footer />
197 + <CookieConsent />
198 + </>
199 + );
200 +}
added frontend/src/api.ts +252 −0
@@ -0,0 +1,252 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts : types + client API robuste (timeout, erreurs typées)
5 +// -----------------------------------------------------------------------------
6 +
7 +export interface Product {
8 + uid: string;
9 + source: string; // id de la bannière (metro, iga, maxi…)
10 + external_id: string;
11 + url: string; // fiche produit chez la source
12 + name: string;
13 + brand: string;
14 + category: string; // catégorie canonique Food-Ka
15 + category_raw: string; // taxonomie originale de la bannière
16 + size_label: string; // format affiché, ex. "500 g", "2 L"
17 + price: number | null; // prix courant ($ CAD), rabais inclus
18 + regular_price: number | null;// prix régulier si le produit est en solde
19 + price_label: string; // texte original (ex. "2 / 5,00 $")
20 + on_sale: boolean;
21 + unit_price: number | null; // prix par unité comparable
22 + unit_price_label: string; // ex. "0,70 $ / 100 g"
23 + in_stock: boolean | null; // null = inconnu
24 + description: string;
25 + keywords: string[]; // tags source (bio, sans gluten…)
26 + details: Record<string, unknown>;
27 + images: string[];
28 + first_seen?: number;
29 + last_seen: number;
30 + updated_at: number;
31 + active: number;
32 +}
33 +
34 +/** Fiche produit : produit + historique de prix + comparaison inter-bannières */
35 +export interface ProductDetail extends Product {
36 + price_history: { ts: number; price: number | null }[];
37 + compare: Product[];
38 +}
39 +
40 +export interface Facets {
41 + categories: { category: string; n: number }[];
42 + brands: { brand: string; n: number }[];
43 + sources: { source: string; n: number }[];
44 + on_sale: number;
45 +}
46 +
47 +export interface Source {
48 + id: string;
49 + name: string;
50 + url: string;
51 + catalog_url: string;
52 + connector: string | null;
53 + status: string;
54 + region: string;
55 + tech: string;
56 + active_products: number;
57 + last_sync: number | null;
58 + notes?: string;
59 +}
60 +
61 +export interface SyncEntry {
62 + id: number;
63 + source: string;
64 + ts: number;
65 + found: number;
66 + added: number;
67 + updated: number;
68 + removed: number;
69 + ok: number;
70 + message: string | null;
71 +}
72 +
73 +export interface Stats {
74 + total: number;
75 + on_sale: number;
76 + sources: number;
77 + categories: number;
78 + avg_price: number | null;
79 + by_source: { source: string; n: number; sales: number; avg_price: number | null }[];
80 + by_category: { category: string; n: number; avg_price: number | null }[];
81 + deals: Product[];
82 + recent_syncs: SyncEntry[];
83 +}
84 +
85 +// --- Agrégats détaillés du marché (GET /api/stats/detailed) -------------------
86 +export interface SourceMarketStats {
87 + source: string;
88 + n: number;
89 + sales: number;
90 + sale_share: number; // 0..1
91 + avg_price: number | null;
92 + median_price: number | null;
93 + avg_discount_pct: number | null; // % moyen des rabais en cours
94 + max_discount_pct: number | null;
95 + with_unit_price: number;
96 + no_price: number;
97 +}
98 +
99 +export interface MatrixCell { median_price: number | null; n: number; }
100 +
101 +export interface BasketItem {
102 + item: string; // ex. « Lait », « Œufs »
103 + by_source: Record<string, MatrixCell>; // prix médian par bannière
104 +}
105 +
106 +export interface BasketTotal { source: string; items: number; total: number; }
107 +
108 +export interface PriceDrop {
109 + uid: string;
110 + name: string;
111 + source: string;
112 + old_price: number;
113 + new_price: number;
114 + drop_pct: number;
115 +}
116 +
117 +export interface DetailedStats {
118 + global: {
119 + total: number;
120 + on_sale: number;
121 + sale_share: number; // 0..1
122 + sources: number;
123 + categories: number;
124 + brands: number;
125 + avg_price: number | null;
126 + median_price: number | null;
127 + price_changes_7d: number;
128 + };
129 + by_source: SourceMarketStats[];
130 + category_matrix: Record<string, Record<string, MatrixCell>>;
131 + basket: BasketItem[];
132 + basket_totals: BasketTotal[]; // triés du panier le moins cher au plus cher
133 + price_drops: PriceDrop[];
134 + price_distribution: { range: string; n: number }[];
135 +}
136 +
137 +// --- Noms d'affichage des bannières ------------------------------------------
138 +const SOURCE_NAMES: Record<string, string> = {
139 + metro: "Metro",
140 + superc: "Super C",
141 + iga: "IGA",
142 + provigo: "Provigo",
143 + maxi: "Maxi",
144 + walmart: "Walmart",
145 + adonis: "Marché Adonis",
146 + avril: "Avril",
147 + rachelle_bery: "Rachelle-Béry",
148 + pa: "PA Supermarché",
149 + mayrand: "Mayrand",
150 + tau: "Marché Tau",
151 + giant_tiger: "Giant Tiger",
152 + club_entrepot: "Club Entrepôt",
153 + loco: "LOCO",
154 + frenco: "Frenco",
155 + akhavan: "Akhavan",
156 + tt: "T&T Supermarket",
157 +};
158 +
159 +export function registerSourceNames(sources: Source[]) {
160 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
161 +}
162 +
163 +/** Nom d'affichage d'une bannière — id inconnu : joliment reformaté. */
164 +export function sourceName(id: string): string {
165 + if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];
166 + return id
167 + .split(/[_-]/)
168 + .filter(Boolean)
169 + .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
170 + .join(" ");
171 +}
172 +
173 +// Noms courts — à côté des logos (cartes, tableaux serrés)
174 +const SOURCE_SHORT: Record<string, string> = {
175 + adonis: "Adonis",
176 + pa: "PA",
177 + giant_tiger: "G. Tiger",
178 + boite_a_grains: "B. à grains",
179 + aliments_merci: "Merci",
180 + bocoboco: "BocoBoco",
181 + epipresto: "ÉpiPresto",
182 + rachelle_bery: "Rachelle-Béry",
183 + tau: "Tau",
184 + maturin: "Maturin",
185 + club_entrepot: "Club Ent.",
186 + tt: "T&T",
187 +};
188 +
189 +/** Nom court d'une bannière (logo + libellé compact). */
190 +export const sourceShort = (id: string): string =>
191 + SOURCE_SHORT[id] ?? sourceName(id);
192 +
193 +async function get<T>(path: string): Promise<T> {
194 + const ctrl = new AbortController();
195 + const timer = setTimeout(() => ctrl.abort(), 20000);
196 + try {
197 + const res = await fetch(path, { signal: ctrl.signal });
198 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
199 + return (await res.json()) as T;
200 + } finally {
201 + clearTimeout(timer);
202 + }
203 +}
204 +
205 +export type SortKey =
206 + | "price_asc" | "price_desc" | "unit_price" | "discount" | "name" | "recent";
207 +
208 +export interface ProductFilters {
209 + category?: string;
210 + source?: string;
211 + brand?: string;
212 + price_min?: string;
213 + price_max?: string;
214 + on_sale?: string; // "1" = en solde seulement
215 + q?: string;
216 + sort?: string; // SortKey
217 + limit?: string;
218 + offset?: string;
219 +}
220 +
221 +export function fetchProducts(f: ProductFilters) {
222 + const params = new URLSearchParams();
223 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
224 + return get<{ total: number; count: number; products: Product[] }>(
225 + `/api/products?${params}`
226 + );
227 +}
228 +
229 +export const fetchProduct = (uid: string) =>
230 + get<ProductDetail>(`/api/products/${encodeURIComponent(uid)}`);
231 +export const fetchFacets = (category?: string) =>
232 + get<Facets>(`/api/facets${category ? `?category=${encodeURIComponent(category)}` : ""}`);
233 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
234 +export const fetchStats = () => get<Stats>("/api/stats");
235 +export const statsDetailed = () => get<DetailedStats>("/api/stats/detailed");
236 +
237 +// --- Formats ------------------------------------------------------------------
238 +/** 4.99 -> « 4,99 $ » (les prix d'épicerie gardent les cents) */
239 +export const fmtPrice = (p: number | null | undefined, label?: string) =>
240 + p != null
241 + ? p.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"
242 + : label || "Prix non affiché";
243 +
244 +/** Rabais relatif en % (positif), ou null si non applicable. */
245 +export function discountPct(p: Product): number | null {
246 + if (p.price == null || p.regular_price == null || p.regular_price <= p.price) return null;
247 + return Math.round(((p.regular_price - p.price) / p.regular_price) * 100);
248 +}
249 +
250 +/** Timestamp Unix -> « 12 août 2026, 13 h 05 » */
251 +export const fmtTs = (ts: number | null | undefined) =>
252 + ts ? new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" }) : "—";
added frontend/src/components/CookieConsent.tsx +103 −0
@@ -0,0 +1,103 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/CookieConsent.tsx : bandeau de consentement (témoins/stockage)
5 +// Honnête par design : Food-Ka n'utilise AUCUN témoin tiers ni traceur.
6 +// Le sélecteur couvre le stockage local (préférences) et une éventuelle
7 +// mesure d'audience anonyme future. Choix mémorisé en localStorage,
8 +// ré-ouvrable via l'événement « foodka:openConsent » (lien du pied de page).
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useState } from "react";
11 +import { Link } from "react-router-dom";
12 +
13 +const KEY = "foodka-consent-v1";
14 +
15 +export interface Consent {
16 + essential: true; // toujours actif (fonctionnement du site)
17 + preferences: boolean; // mémoriser filtres et tri
18 + statistics: boolean; // mesure d'audience anonyme (si activée un jour)
19 + ts: number;
20 +}
21 +
22 +export function getConsent(): Consent | null {
23 + try {
24 + const raw = localStorage.getItem(KEY);
25 + return raw ? (JSON.parse(raw) as Consent) : null;
26 + } catch {
27 + return null;
28 + }
29 +}
30 +
31 +function save(preferences: boolean, statistics: boolean): Consent {
32 + const c: Consent = { essential: true, preferences, statistics, ts: Date.now() };
33 + try { localStorage.setItem(KEY, JSON.stringify(c)); } catch { /* stockage bloqué */ }
34 + return c;
35 +}
36 +
37 +export default function CookieConsent() {
38 + const [visible, setVisible] = useState(false);
39 + const [custom, setCustom] = useState(false);
40 + const [prefs, setPrefs] = useState(true);
41 + const [stats, setStats] = useState(false);
42 +
43 + useEffect(() => {
44 + if (getConsent() === null) setVisible(true);
45 + const open = () => { setVisible(true); setCustom(true); };
46 + window.addEventListener("foodka:openConsent", open);
47 + return () => window.removeEventListener("foodka:openConsent", open);
48 + }, []);
49 +
50 + if (!visible) return null;
51 +
52 + const close = (p: boolean, s: boolean) => { save(p, s); setVisible(false); setCustom(false); };
53 +
54 + return (
55 + <div className="cookie-banner" role="dialog" aria-modal="false" aria-label="Gestion des témoins">
56 + <div className="cookie-inner">
57 + <div className="cookie-text">
58 + <b>🍪 Vos témoins, vos choix.</b>{" "}
59 + Food·Ka n'utilise aucun traceur publicitaire ni témoin tiers. Le site
60 + mémorise localement vos préférences (filtres, tri) et votre choix
61 + de consentement. <Link to="/confidentialite">Politique de confidentialité</Link>
62 + </div>
63 +
64 + {custom && (
65 + <div className="cookie-options">
66 + <label className="cookie-opt">
67 + <input type="checkbox" checked disabled />
68 + <span><b>Essentiels</b> — fonctionnement du site (toujours actifs)</span>
69 + </label>
70 + <label className="cookie-opt">
71 + <input type="checkbox" checked={prefs} onChange={(e) => setPrefs(e.target.checked)} />
72 + <span><b>Préférences</b> — retenir vos filtres et votre tri</span>
73 + </label>
74 + <label className="cookie-opt">
75 + <input type="checkbox" checked={stats} onChange={(e) => setStats(e.target.checked)} />
76 + <span><b>Statistiques</b> — mesure d'audience anonyme, sans profilage</span>
77 + </label>
78 + </div>
79 + )}
80 +
81 + <div className="cookie-actions">
82 + {!custom && (
83 + <button className="btn btn-ghost" onClick={() => setCustom(true)}>
84 + Personnaliser
85 + </button>
86 + )}
87 + <button className="btn btn-ghost" onClick={() => close(false, false)}>
88 + Essentiels seulement
89 + </button>
90 + {custom ? (
91 + <button className="btn btn-primary" onClick={() => close(prefs, stats)}>
92 + Enregistrer mes choix
93 + </button>
94 + ) : (
95 + <button className="btn btn-primary" onClick={() => close(true, true)}>
96 + Tout accepter
97 + </button>
98 + )}
99 + </div>
100 + </div>
101 + </div>
102 + );
103 +}
added frontend/src/components/ProductCard.tsx +51 −0
@@ -0,0 +1,51 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/ProductCard.tsx : carte produit (grille de résultats)
5 +// Prix courant + prix régulier barré et badge « -XX % » quand en solde,
6 +// prix unitaire comparable, bannière d'origine.
7 +// -----------------------------------------------------------------------------
8 +import { Link } from "react-router-dom";
9 +import { Product, discountPct, fmtPrice } from "../api";
10 +import SourceLogo from "./SourceLogo";
11 +
12 +export default function ProductCard({ p }: { p: Product }) {
13 + const img = p.images && p.images.length > 0 ? p.images[0] : null;
14 + const pct = discountPct(p);
15 + return (
16 + <Link to={`/produit/${encodeURIComponent(p.uid)}`} className="card">
17 + <div className="card-img packshot">
18 + {img ? (
19 + <img src={img} alt={p.name} loading="lazy" />
20 + ) : (
21 + <div className="noimg">🛒</div>
22 + )}
23 + {p.on_sale && (
24 + <span className="badge sale">{pct != null ? `−${pct} %` : "En solde"}</span>
25 + )}
26 + {p.in_stock === false && <span className="badge right">Rupture</span>}
27 + </div>
28 + <div className="card-body">
29 + <div className="card-price">
30 + {fmtPrice(p.price, p.price_label)}
31 + {p.on_sale && p.regular_price != null && (
32 + <s className="price-old">{fmtPrice(p.regular_price)}</s>
33 + )}
34 + </div>
35 + {p.unit_price_label && (
36 + <div className="card-unit">{p.unit_price_label}</div>
37 + )}
38 + <div className="card-title">{p.name}</div>
39 + <div className="card-meta">
40 + {p.brand && <span>{p.brand}</span>}
41 + {p.brand && p.size_label && <span className="sep" />}
42 + {p.size_label && <span>{p.size_label}</span>}
43 + </div>
44 + <div className="card-foot">
45 + <SourceLogo source={p.source} size={20} name="short" />
46 + {p.category && <span className="avail">{p.category}</span>}
47 + </div>
48 + </div>
49 + </Link>
50 + );
51 +}
added frontend/src/components/SourceLogo.tsx +52 −0
@@ -0,0 +1,52 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/SourceLogo.tsx : logo de bannière (frontend/public/logos/<id>.png)
5 +// Vignette arrondie sur fond blanc + nom optionnel (court ou complet).
6 +// Logo introuvable : repli sur la pastille texte historique (ou rien).
7 +// -----------------------------------------------------------------------------
8 +import { useState } from "react";
9 +import { sourceName, sourceShort } from "../api";
10 +
11 +interface SourceLogoProps {
12 + source: string;
13 + /** Côté du logo en px (défaut 22). */
14 + size?: number;
15 + /** Libellé affiché à côté du logo. */
16 + name?: "none" | "short" | "full";
17 + /** Comportement quand le PNG n'existe pas : pastille texte ou rien. */
18 + fallback?: "chip" | "hide";
19 + className?: string;
20 +}
21 +
22 +export default function SourceLogo({
23 + source,
24 + size = 22,
25 + name = "none",
26 + fallback = "chip",
27 + className = "",
28 +}: SourceLogoProps) {
29 + // mémorise l'id dont le logo a échoué (résiste au changement de `source`)
30 + const [failedFor, setFailedFor] = useState<string | null>(null);
31 + const failed = failedFor === source;
32 +
33 + if (failed) {
34 + if (fallback === "hide") return null;
35 + return <span className={`source-tag ${className}`}>{sourceName(source)}</span>;
36 + }
37 +
38 + const label = name === "full" ? sourceName(source) : sourceShort(source);
39 + return (
40 + <span className={`src-logo ${className}`} title={sourceName(source)}>
41 + <img
42 + src={`/logos/${source}.png`}
43 + alt={name === "none" ? sourceName(source) : ""}
44 + width={size}
45 + height={size}
46 + loading="lazy"
47 + onError={() => setFailedFor(source)}
48 + />
49 + {name !== "none" && <span className="src-logo-name">{label}</span>}
50 + </span>
51 + );
52 +}
added frontend/src/main.tsx +18 −0
@@ -0,0 +1,18 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// main.tsx : point d'entrée React
5 +// -----------------------------------------------------------------------------
6 +import React from "react";
7 +import ReactDOM from "react-dom/client";
8 +import { BrowserRouter } from "react-router-dom";
9 +import App from "./App";
10 +import "./styles.css";
11 +
12 +ReactDOM.createRoot(document.getElementById("root")!).render(
13 + <React.StrictMode>
14 + <BrowserRouter>
15 + <App />
16 + </BrowserRouter>
17 + </React.StrictMode>
18 +);
added frontend/src/pages/Home.tsx +405 −0
@@ -0,0 +1,405 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Home.tsx : accueil — héro, statistiques, filtres, grille de produits
5 +// Filtres : recherche, catégorie, bannière, marque, prix min/max, en solde,
6 +// tri — avec pastilles de filtres actifs et pagination « Charger plus ».
7 +// La même page sert la vue « Aubaines » (/aubaines), pré-filtrée en solde.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useMemo, useState } from "react";
10 +import { useSearchParams } from "react-router-dom";
11 +import {
12 + Facets, Product, ProductFilters, Stats,
13 + fetchFacets, fetchProducts, fetchSources, fetchStats, fmtPrice,
14 + registerSourceNames, sourceName,
15 +} from "../api";
16 +import ProductCard from "../components/ProductCard";
17 +import SourceLogo from "../components/SourceLogo";
18 +
19 +const PAGE_SIZE = 60;
20 +const PRICE_STEPS = [1, 2, 3, 5, 8, 10, 15, 20, 30, 50];
21 +
22 +const SORT_CHOICES: { key: string; label: string }[] = [
23 + { key: "price_asc", label: "Prix croissant" },
24 + { key: "price_desc", label: "Prix décroissant" },
25 + { key: "unit_price", label: "Prix unitaire" },
26 + { key: "discount", label: "Meilleur rabais" },
27 + { key: "name", label: "Nom A→Z" },
28 + { key: "recent", label: "Nouveautés" },
29 +];
30 +
31 +export default function Home({ aubaines = false }: { aubaines?: boolean }) {
32 + const [products, setProducts] = useState<Product[] | null>(null);
33 + const [total, setTotal] = useState(0);
34 + const [offset, setOffset] = useState(0);
35 + const [loadingMore, setLoadingMore] = useState(false);
36 + const [facets, setFacets] = useState<Facets | null>(null);
37 + const [brands, setBrands] = useState<{ brand: string; n: number }[]>([]);
38 + const [stats, setStats] = useState<Stats | null>(null);
39 + const [error, setError] = useState<string | null>(null);
40 +
41 + // filtres (pré-remplis depuis l'URL, ex. /?category=… — liens de la page Stats)
42 + const [params] = useSearchParams();
43 + const [q, setQ] = useState(params.get("q") ?? "");
44 + const [category, setCategory] = useState(params.get("category") ?? "");
45 + const [source, setSource] = useState(params.get("source") ?? "");
46 + const [brand, setBrand] = useState(params.get("brand") ?? "");
47 + const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
48 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
49 + const [onSale, setOnSale] = useState(aubaines || params.get("on_sale") === "1");
50 + const [sort, setSort] = useState(params.get("sort") ?? (aubaines ? "discount" : "price_asc"));
51 + // feuille de filtres mobile (bottom sheet) + panneau avancé desktop
52 + const [sheetOpen, setSheetOpen] = useState(false);
53 + const [advOpen, setAdvOpen] = useState(false);
54 + const saleForced = aubaines; // la page Aubaines impose « en solde »
55 + const activeFilters = [q, category, source, brand, priceMin, priceMax,
56 + !saleForced && onSale ? "1" : ""].filter(Boolean).length;
57 + const advCount = [brand, source].filter(Boolean).length;
58 +
59 + useEffect(() => {
60 + // /aubaines : force le filtre solde (et un tri utile par défaut)
61 + setOnSale(aubaines || params.get("on_sale") === "1");
62 + }, [aubaines, params]);
63 +
64 + const filters: ProductFilters = useMemo(() => ({
65 + q, category, source, brand,
66 + price_min: priceMin, price_max: priceMax,
67 + on_sale: onSale ? "1" : "",
68 + sort, limit: String(PAGE_SIZE),
69 + }), [q, category, source, brand, priceMin, priceMax, onSale, sort]);
70 +
71 + useEffect(() => {
72 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
73 + fetchFacets().then(setFacets).catch(() => {});
74 + fetchStats().then(setStats).catch(() => {});
75 + }, []);
76 +
77 + // marques dépendantes de la catégorie choisie
78 + useEffect(() => {
79 + fetchFacets(category || undefined)
80 + .then((f) => setBrands(f.brands))
81 + .catch(() => setBrands([]));
82 + }, [category]);
83 +
84 + useEffect(() => {
85 + let cancelled = false;
86 + setProducts(null);
87 + setOffset(0);
88 + setError(null);
89 + fetchProducts(filters)
90 + .then((r) => {
91 + if (!cancelled) {
92 + setProducts(r.products);
93 + setTotal(r.total);
94 + }
95 + })
96 + .catch((e) => !cancelled && setError(String(e)));
97 + return () => {
98 + cancelled = true;
99 + };
100 + }, [filters]);
101 +
102 + const loadMore = () => {
103 + const next = offset + PAGE_SIZE;
104 + setLoadingMore(true);
105 + fetchProducts({ ...filters, offset: String(next) })
106 + .then((r) => {
107 + setProducts((prev) => [...(prev ?? []), ...r.products]);
108 + setTotal(r.total);
109 + setOffset(next);
110 + })
111 + .catch((e) => setError(String(e)))
112 + .finally(() => setLoadingMore(false));
113 + };
114 +
115 + const resetAll = () => {
116 + setQ(""); setCategory(""); setSource(""); setBrand("");
117 + setPriceMin(""); setPriceMax("");
118 + if (!saleForced) setOnSale(false);
119 + };
120 +
121 + // pastilles « filtres actifs » — libellé + action de retrait (+ logo bannière)
122 + const pills: { label: string; clear: () => void; logo?: string }[] = [];
123 + if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
124 + if (category) pills.push({ label: category, clear: () => { setCategory(""); setBrand(""); } });
125 + if (brand) pills.push({ label: brand, clear: () => setBrand("") });
126 + if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") });
127 + if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });
128 + if (onSale && !saleForced) pills.push({ label: "En solde", clear: () => setOnSale(false) });
129 + if (source) pills.push({ label: sourceName(source), clear: () => setSource(""), logo: source });
130 +
131 + const shown = products?.length ?? 0;
132 +
133 + return (
134 + <div className="container">
135 + <section className="hero">
136 + <span className={`kicker ${aubaines ? "kicker-sale" : ""}`}>
137 + {aubaines ? "Aubaines — les meilleurs rabais du moment" : "Agrégateur — épicerie du Québec"}
138 + </span>
139 + {aubaines ? (
140 + <h1>
141 + Les <span className="hl hl-tomato">aubaines</span> de toutes<br />
142 + les <span className="outline">circulaires</span>.
143 + </h1>
144 + ) : (
145 + <h1>
146 + Tous les prix <span className="outline">d'épicerie</span><br />
147 + du Québec. <span className="hl">Un seul</span> endroit.
148 + </h1>
149 + )}
150 + <p className="lede">
151 + {aubaines
152 + ? "Food·Ka repère les produits en solde chez toutes les bannières d'épicerie du Québec et les classe par rabais — comparez avant de remplir le panier."
153 + : "Food·Ka rassemble les produits affichés par les bannières d'épicerie du Québec — Metro, IGA, Maxi, Super C, Provigo et plus — mis à jour automatiquement, avec prix unitaires et lien direct vers la fiche originale."}
154 + </p>
155 + <div className="stat-row">
156 + <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
157 + {stats && (
158 + <>
159 + <span className="stat-chip"><b>{stats.total}</b> produits suivis</span>
160 + <span className="stat-chip"><b>{stats.sources}</b> bannières</span>
161 + <span className="stat-chip"><b>{stats.on_sale}</b> soldes actifs</span>
162 + {stats.avg_price != null && (
163 + <span className="stat-chip">
164 + prix moyen <b>{fmtPrice(stats.avg_price)}</b>
165 + </span>
166 + )}
167 + </>
168 + )}
169 + </div>
170 + </section>
171 +
172 + {sheetOpen && (
173 + <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
174 + )}
175 + <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
176 + <div className="sheet-head">
177 + <span>Filtres</span>
178 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
179 +
180 + </button>
181 + </div>
182 +
183 + {/* — rangée principale : recherche, catégorie, prix, tri, + filtres — */}
184 + <div className="f-primary">
185 + <div className="f-search">
186 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
187 + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
188 + </svg>
189 + <input
190 + id="f-q" placeholder="Produit, marque, rayon…" value={q}
191 + onChange={(e) => setQ(e.target.value)}
192 + aria-label="Recherche"
193 + />
194 + {q && (
195 + <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
196 + )}
197 + </div>
198 + <label className="f-ctl">
199 + <span>Catégorie</span>
200 + <select value={category} onChange={(e) => { setCategory(e.target.value); setBrand(""); }}>
201 + <option value="">Toutes</option>
202 + {(facets?.categories ?? []).map((c) => (
203 + <option key={c.category} value={c.category}>{c.category}</option>
204 + ))}
205 + </select>
206 + </label>
207 + <div className="f-ctl">
208 + <span>Prix</span>
209 + <div className="range-pair">
210 + <select aria-label="Prix minimum" value={priceMin}
211 + onChange={(e) => setPriceMin(e.target.value)}>
212 + <option value="">Min</option>
213 + {PRICE_STEPS.map((p) => (
214 + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>
215 + {p} $
216 + </option>
217 + ))}
218 + </select>
219 + <span className="range-sep">—</span>
220 + <select aria-label="Prix maximum" value={priceMax}
221 + onChange={(e) => setPriceMax(e.target.value)}>
222 + <option value="">Max</option>
223 + {PRICE_STEPS.map((p) => (
224 + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>
225 + {p} $
226 + </option>
227 + ))}
228 + </select>
229 + </div>
230 + </div>
231 + <label className="f-ctl">
232 + <span>Trier par</span>
233 + <select value={sort} onChange={(e) => setSort(e.target.value)}>
234 + {SORT_CHOICES.map((s) => (
235 + <option key={s.key} value={s.key}>{s.label}</option>
236 + ))}
237 + </select>
238 + </label>
239 + <button
240 + className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}
241 + onClick={() => setAdvOpen(!advOpen)}
242 + aria-expanded={advOpen}
243 + >
244 + Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
245 + </button>
246 + </div>
247 +
248 + {/* — panneau avancé : bannière, marque, solde — */}
249 + {(advOpen || sheetOpen) && (
250 + <div className="f-adv">
251 + <div className="f-group">
252 + <label>Bannière</label>
253 + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
254 + <option value="">Toutes</option>
255 + {(facets?.sources ?? []).map((s) => (
256 + <option key={s.source} value={s.source}>
257 + {sourceName(s.source)} ({s.n})
258 + </option>
259 + ))}
260 + </select>
261 + </div>
262 + <div className="f-group">
263 + <label>Marque</label>
264 + <select className="f-native" value={brand} onChange={(e) => setBrand(e.target.value)}>
265 + <option value="">Toutes</option>
266 + {brands.map((b) => (
267 + <option key={b.brand} value={b.brand}>
268 + {b.brand} ({b.n})
269 + </option>
270 + ))}
271 + </select>
272 + </div>
273 + {!saleForced && (
274 + <div className="f-group">
275 + <label>Soldes</label>
276 + <div className="seg" role="group">
277 + <button className={!onSale ? "on" : ""} onClick={() => setOnSale(false)}>
278 + Tous les produits
279 + </button>
280 + <button className={onSale ? "on" : ""} onClick={() => setOnSale(true)}>
281 + 🔥 En solde{facets ? ` (${facets.on_sale})` : ""}
282 + </button>
283 + </div>
284 + </div>
285 + )}
286 + <div className="f-group f-group-end">
287 + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
288 + Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
289 + </button>
290 + </div>
291 + </div>
292 + )}
293 +
294 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
295 + Voir les résultats {products ? `(${total})` : ""}
296 + </button>
297 + </section>
298 +
299 + <div className="chips" role="group" aria-label="Filtres rapides">
300 + {(facets?.categories ?? []).slice(0, 8).map((c) => (
301 + <button
302 + key={c.category}
303 + className={`chip ${category === c.category ? "on" : ""}`}
304 + onClick={() => { setCategory(category === c.category ? "" : c.category); setBrand(""); }}
305 + >
306 + {c.category}
307 + </button>
308 + ))}
309 + {!saleForced && (
310 + <>
311 + <span className="chip-sep" aria-hidden="true" />
312 + <button
313 + className={`chip ${onSale ? "on" : ""}`}
314 + onClick={() => setOnSale(!onSale)}
315 + >
316 + 🔥 En solde
317 + </button>
318 + </>
319 + )}
320 + </div>
321 +
322 + {pills.length > 0 && (
323 + <div className="pills" aria-label="Filtres actifs">
324 + {pills.map((p) => (
325 + <button key={p.label} className="pill" onClick={p.clear}
326 + aria-label={`Retirer le filtre ${p.label}`}>
327 + {p.logo && <SourceLogo source={p.logo} size={18} fallback="hide" />}
328 + {p.label} <span className="pill-x">✕</span>
329 + </button>
330 + ))}
331 + <button className="pill pill-clear" onClick={resetAll}>
332 + Tout effacer
333 + </button>
334 + </div>
335 + )}
336 +
337 + <div className="results-head">
338 + <h2>{aubaines ? "Aubaines en cours" : "Produits"}</h2>
339 + {products && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
340 + </div>
341 +
342 + {error && (
343 + <div className="notice">
344 + <div className="big">⚠️</div>
345 + <h2>Impossible de charger les produits</h2>
346 + <p>{error}</p>
347 + <button className="btn btn-primary" onClick={() => window.location.reload()}>
348 + Réessayer
349 + </button>
350 + </div>
351 + )}
352 +
353 + {!error && products === null && (
354 + <div className="grid" aria-busy="true">
355 + {Array.from({ length: 8 }).map((_, i) => (
356 + <div className="skel" key={i}>
357 + <div className="sk-img" />
358 + <div className="sk-line" />
359 + <div className="sk-line short" />
360 + </div>
361 + ))}
362 + </div>
363 + )}
364 +
365 + {!error && products !== null && products.length === 0 && (
366 + <div className="notice">
367 + <div className="big">🔍</div>
368 + <h2>Aucun produit ne correspond</h2>
369 + <p>
370 + Essayez d'élargir vos critères
371 + {activeFilters > 0 && (
372 + <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>
373 + )}.
374 + </p>
375 + </div>
376 + )}
377 +
378 + {!error && products !== null && products.length > 0 && (
379 + <>
380 + <div className="grid">
381 + {products.map((p) => (
382 + <ProductCard key={p.uid} p={p} />
383 + ))}
384 + </div>
385 + {shown < total && (
386 + <div className="load-more">
387 + <button className="btn btn-primary" onClick={loadMore} disabled={loadingMore}>
388 + {loadingMore ? "Chargement…" : `Charger plus (${shown} / ${total})`}
389 + </button>
390 + </div>
391 + )}
392 + </>
393 + )}
394 +
395 + {/* Bouton flottant mobile — ouvre la feuille de filtres */}
396 + <button
397 + className="fab"
398 + onClick={() => setSheetOpen(true)}
399 + aria-label="Ouvrir les filtres"
400 + >
401 + ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
402 + </button>
403 + </div>
404 + );
405 +}
added frontend/src/pages/Privacy.tsx +66 −0
@@ -0,0 +1,66 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Privacy.tsx : politique de confidentialité et de témoins (cookies)
5 +// -----------------------------------------------------------------------------
6 +
7 +export default function PrivacyPage() {
8 + const reopen = () => window.dispatchEvent(new Event("foodka:openConsent"));
9 +
10 + return (
11 + <div className="container legal">
12 + <span className="kicker">Confidentialité &amp; témoins</span>
13 + <h1>Votre vie privée, simplement.</h1>
14 +
15 + <section>
16 + <h3>Ce que Food·Ka collecte</h3>
17 + <p>
18 + Rien qui vous identifie. Food·Ka n'a pas de compte utilisateur, pas de
19 + formulaire d'inscription, pas de pixel publicitaire et ne vend aucune
20 + donnée. Le site consulte des fiches produits publiques des bannières
21 + d'épicerie et vous les présente — c'est tout.
22 + </p>
23 + </section>
24 +
25 + <section>
26 + <h3>Témoins et stockage local</h3>
27 + <p>
28 + Food·Ka n'utilise <b>aucun témoin (cookie) tiers</b>. Le seul stockage
29 + se fait dans votre navigateur (« localStorage ») :
30 + </p>
31 + <ul>
32 + <li><b>Essentiels</b> — votre choix de consentement lui-même.</li>
33 + <li><b>Préférences</b> (si acceptées) — vos filtres de recherche et votre tri.</li>
34 + <li><b>Statistiques</b> (si acceptées) — une éventuelle mesure d'audience anonyme,
35 + sans identifiant ni profilage. Aucune n'est active à ce jour.</li>
36 + </ul>
37 + <p>
38 + <button className="btn btn-ghost" onClick={reopen}>Modifier mes choix de témoins</button>
39 + </p>
40 + </section>
41 +
42 + <section>
43 + <h3>Services externes</h3>
44 + <ul>
45 + <li><b>Images des produits</b> — chargées depuis les serveurs des bannières
46 + d'épicerie ; leur politique s'applique à ces requêtes.</li>
47 + <li><b>Polices de caractères</b> — servies par Google Fonts au chargement
48 + de la page.</li>
49 + </ul>
50 + </section>
51 +
52 + <section>
53 + <h3>Vos prix, vos sources</h3>
54 + <p>
55 + Chaque fiche renvoie vers la page produit originale de la bannière. Les
56 + prix affichés sont ceux relevés lors de la dernière synchronisation et
57 + peuvent différer en magasin. Pour faire retirer un contenu ou pour toute
58 + question :{" "}
59 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>.
60 + </p>
61 + </section>
62 +
63 + <div className="fine">Dernière mise à jour : août 2026.</div>
64 + </div>
65 + );
66 +}
added frontend/src/pages/Product.tsx +315 −0
@@ -0,0 +1,315 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Product.tsx : fiche produit — galerie packshot, bloc prix (solde,
5 +// prix unitaire), description, étiquettes, historique de prix et
6 +// « Comparer les prix » (même produit chez les autres bannières).
7 +// Mobile-first : galerie → prix → comparaison ; CTA source sticky en bas.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from "react";
10 +import { Link, useParams } from "react-router-dom";
11 +import {
12 + Product, ProductDetail,
13 + discountPct, fetchProduct, fetchSources, fmtPrice, fmtTs,
14 + registerSourceNames, sourceName,
15 +} from "../api";
16 +import SourceLogo from "../components/SourceLogo";
17 +
18 +const NBSP = " ";
19 +
20 +// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran -----
21 +function Galerie({ images, titre }: { images: string[]; titre: string }) {
22 + const [idx, setIdx] = useState(0);
23 + const [zoom, setZoom] = useState(false);
24 + const track = useRef<HTMLDivElement>(null);
25 +
26 + const onScroll = () => {
27 + const el = track.current;
28 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
29 + };
30 + const goto = (i: number) =>
31 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
32 +
33 + if (images.length === 0)
34 + return <div className="carousel"><div className="noimg carousel-empty">🛒</div></div>;
35 +
36 + return (
37 + <>
38 + <div className="carousel packshot">
39 + <div className="carousel-track" ref={track} onScroll={onScroll}>
40 + {images.map((u, i) => (
41 + <img
42 + key={u} src={u} loading={i === 0 ? "eager" : "lazy"}
43 + alt={`${titre} — image ${i + 1} de ${images.length}`}
44 + onClick={() => setZoom(true)}
45 + />
46 + ))}
47 + </div>
48 + {images.length > 1 && (
49 + <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
50 + )}
51 + {idx > 0 && (
52 + <button className="carousel-nav prev" aria-label="Image précédente" onClick={() => goto(idx - 1)}>‹</button>
53 + )}
54 + {idx < images.length - 1 && (
55 + <button className="carousel-nav next" aria-label="Image suivante" onClick={() => goto(idx + 1)}>›</button>
56 + )}
57 + </div>
58 + {images.length > 1 && (
59 + <div className="thumbs">
60 + {images.map((u, i) => (
61 + <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
62 + aria-label={`Image ${i + 1}`}>
63 + <img src={u} alt="" loading="lazy" />
64 + </button>
65 + ))}
66 + </div>
67 + )}
68 + {zoom && (
69 + <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Image agrandie">
70 + <img src={images[idx]} alt="" />
71 + </div>
72 + )}
73 + </>
74 + );
75 +}
76 +
77 +// --- Rangée de comparaison inter-bannières -----------------------------------
78 +function CompareRow({ p, best }: { p: Product; best: boolean }) {
79 + const pct = discountPct(p);
80 + const img = p.images && p.images.length > 0 ? p.images[0] : null;
81 + return (
82 + <li className={best ? "best" : ""}>
83 + <Link to={`/produit/${encodeURIComponent(p.uid)}`} className="cmp-link">
84 + <span className="cmp-img packshot">
85 + {img ? <img src={img} alt="" loading="lazy" /> : <span className="noimg">🛒</span>}
86 + </span>
87 + <span className="cmp-main">
88 + <span className="cmp-name">{p.name}</span>
89 + <span className="cmp-meta">
90 + {[p.brand, p.size_label].filter(Boolean).join(" · ")}
91 + </span>
92 + <SourceLogo source={p.source} size={18} name="short" />
93 + </span>
94 + <span className="cmp-price">
95 + <b>{fmtPrice(p.price, p.price_label)}</b>
96 + {p.on_sale && p.regular_price != null && (
97 + <s className="price-old">{fmtPrice(p.regular_price)}</s>
98 + )}
99 + {pct != null && <em className="baisse-pct">−{pct} %</em>}
100 + {best && <span className="cmp-best">meilleur prix</span>}
101 + {p.unit_price_label && <span className="cmp-unit">{p.unit_price_label}</span>}
102 + </span>
103 + </Link>
104 + </li>
105 + );
106 +}
107 +
108 +export default function ProductPage() {
109 + const { uid } = useParams<{ uid: string }>();
110 + const [p, setP] = useState<ProductDetail | null>(null);
111 + const [error, setError] = useState<string | null>(null);
112 +
113 + useEffect(() => {
114 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
115 + if (!uid) return;
116 + setP(null);
117 + setError(null);
118 + fetchProduct(uid).then(setP).catch((e) => setError(String(e)));
119 + window.scrollTo(0, 0);
120 + }, [uid]);
121 +
122 + if (error)
123 + return (
124 + <div className="notice container">
125 + <div className="big">⚠️</div>
126 + <h2>Produit introuvable</h2>
127 + <p>{error}</p>
128 + <Link className="btn btn-primary" to="/">Retour aux produits</Link>
129 + </div>
130 + );
131 +
132 + if (!p)
133 + return (
134 + <div className="container detail">
135 + <div className="fiche" aria-busy="true">
136 + <div className="skel"><div className="sk-img" /></div>
137 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
138 + </div>
139 + </div>
140 + );
141 +
142 + const pct = discountPct(p);
143 + const updated = p.updated_at
144 + ? new Date(p.updated_at * 1000).toLocaleDateString("fr-CA", {
145 + day: "numeric", month: "long", year: "numeric" }) : null;
146 +
147 + // chips clés
148 + const chips: string[] = [];
149 + if (p.size_label) chips.push(p.size_label);
150 + if (p.category) chips.push(p.category);
151 + if (p.on_sale) chips.push(pct != null ? `Solde −${pct}${NBSP}%` : "En solde");
152 + if (p.in_stock === true) chips.push("En stock");
153 + if (p.in_stock === false) chips.push("Rupture de stock");
154 +
155 + // comparaison triée du moins cher au plus cher (prix inconnus à la fin)
156 + const compare = [...(p.compare ?? [])].sort((a, b) => {
157 + if (a.price == null) return 1;
158 + if (b.price == null) return -1;
159 + return a.price - b.price;
160 + });
161 + const bestPrice = compare.find((c) => c.price != null)?.price ?? null;
162 +
163 + const hist = (p.price_history ?? []).filter((h) => h.price != null);
164 + const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price
165 + ? { de: hist[1].price!, a: hist[0].price! } : null;
166 +
167 + return (
168 + <div className="container detail">
169 + <nav className="crumbs" aria-label="Fil d'Ariane">
170 + <Link to="/">Produits</Link> ›
171 + {p.category && (
172 + <Link to={`/?category=${encodeURIComponent(p.category)}`}>{p.category}</Link>
173 + )} ›
174 + <span>{p.name}</span>
175 + </nav>
176 +
177 + <div className="fiche">
178 + {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}
179 + <div className="f-col">
180 + <section className="f-bloc f-galerie" aria-label="Images du produit">
181 + <Galerie images={p.images ?? []} titre={p.name} />
182 + </section>
183 +
184 + <section className="f-bloc f-desc" id="description">
185 + <h2>Description</h2>
186 + {p.description
187 + ? <p style={{ color: "var(--ink-2)" }}>{p.description}</p>
188 + : <p className="fine">La bannière ne fournit pas de description pour ce produit.</p>}
189 + {p.keywords.length > 0 && (
190 + <div className="amenity-row" style={{ marginTop: 12 }}>
191 + {p.keywords.map((k) => (
192 + <span className="amenity confirmed" key={k}>{k}</span>
193 + ))}
194 + </div>
195 + )}
196 + </section>
197 +
198 + <section className="f-bloc f-pratique">
199 + <h2>Détails pratiques</h2>
200 + <div className="kv">
201 + <div className="cell"><div className="k">Bannière</div><div className="v">{sourceName(p.source)}</div></div>
202 + {p.brand && (
203 + <div className="cell"><div className="k">Marque</div><div className="v">{p.brand}</div></div>
204 + )}
205 + {p.size_label && (
206 + <div className="cell"><div className="k">Format</div><div className="v">{p.size_label}</div></div>
207 + )}
208 + {p.category_raw && (
209 + <div className="cell"><div className="k">Rayon (source)</div><div className="v">{p.category_raw}</div></div>
210 + )}
211 + {p.price_label && (
212 + <div className="cell"><div className="k">Prix affiché</div><div className="v">{p.price_label}</div></div>
213 + )}
214 + {updated && (
215 + <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>
216 + )}
217 + </div>
218 + {baissePrix && (
219 + <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>
220 + {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}
221 + {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>
222 + </div>
223 + )}
224 + {hist.length > 1 && (
225 + <details className="texte-original">
226 + <summary>Historique de prix ({hist.length} relevés)</summary>
227 + <ul className="histo-prix-liste">
228 + {hist.map((h) => (
229 + <li key={h.ts}>
230 + <span className="hp-date">{fmtTs(h.ts)}</span>
231 + <b className="hp-prix">{fmtPrice(h.price)}</b>
232 + </li>
233 + ))}
234 + </ul>
235 + </details>
236 + )}
237 + </section>
238 + </div>
239 +
240 + {/* ------- colonne droite (desktop) : prix, comparaison --------------- */}
241 + <div className="f-col">
242 + <section className="f-bloc f-hero">
243 + <div className="price">
244 + {fmtPrice(p.price, p.price_label)}
245 + {p.on_sale && p.regular_price != null && (
246 + <s className="price-old big">{fmtPrice(p.regular_price)}</s>
247 + )}
248 + </div>
249 + {p.on_sale && (
250 + <div className="deal-badge deal-good">
251 + 🔥 En solde{pct != null && <> — épargnez {pct}{NBSP}%</>}
252 + </div>
253 + )}
254 + {p.unit_price_label && <div className="unit-price">{p.unit_price_label}</div>}
255 + <h1>{p.name}</h1>
256 + <div className="loc">
257 + {[p.brand, p.size_label].filter(Boolean).join(" · ")}
258 + </div>
259 + <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">
260 + {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}
261 + </div>
262 + <a className="cta cta-desktop" href={p.url}
263 + target="_blank" rel="noopener noreferrer">
264 + <SourceLogo source={p.source} size={24} fallback="hide" />
265 + Voir chez {sourceName(p.source)} ↗
266 + </a>
267 + <p className="fine">
268 + Prix affiché par la bannière lors de la dernière synchronisation —
269 + vérifiez en magasin ou sur la fiche originale.
270 + </p>
271 + </section>
272 +
273 + <section className="f-bloc f-compare" id="comparer">
274 + <h2>Comparer les prix</h2>
275 + {compare.length > 0 ? (
276 + <>
277 + <p className="q-sub">
278 + Produits similaires chez les autres bannières, du moins cher au plus cher.
279 + </p>
280 + <ul className="cmp-list">
281 + {compare.map((c) => (
282 + <CompareRow key={c.uid} p={c}
283 + best={bestPrice != null && c.price === bestPrice} />
284 + ))}
285 + </ul>
286 + </>
287 + ) : (
288 + <p className="fine">
289 + Aucun produit comparable trouvé chez les autres bannières pour l'instant.
290 + </p>
291 + )}
292 + </section>
293 + </div>
294 + </div>
295 +
296 + <div className="fine f-foot">
297 + {updated && <>Dernière synchronisation : {updated}. </>}
298 + Les prix et disponibilités sont ceux affichés par la source — chaque fiche
299 + renvoie à la page produit originale.
300 + </div>
301 +
302 + {/* CTA sticky mobile — toujours visible */}
303 + <div className="cta-sticky">
304 + <span className="cta-sticky-prix">
305 + {fmtPrice(p.price, p.price_label)}
306 + {p.unit_price_label && <small> {p.unit_price_label}</small>}
307 + </span>
308 + <a className="cta" href={p.url} target="_blank" rel="noopener noreferrer">
309 + <SourceLogo source={p.source} size={20} fallback="hide" />
310 + Voir chez {sourceName(p.source)} ↗
311 + </a>
312 + </div>
313 + </div>
314 + );
315 +}
added frontend/src/pages/Sources.tsx +83 −0
@@ -0,0 +1,83 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Sources.tsx : registre des bannières d'épicerie agrégées
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Source, fetchSources } from "../api";
8 +import SourceLogo from "../components/SourceLogo";
9 +
10 +export default function SourcesPage() {
11 + const [sources, setSources] = useState<Source[] | null>(null);
12 + const [error, setError] = useState<string | null>(null);
13 +
14 + useEffect(() => {
15 + fetchSources()
16 + .then((r) => setSources(r.sources))
17 + .catch((e) => setError(String(e)));
18 + }, []);
19 +
20 + return (
21 + <div className="container sources">
22 + <span className="kicker">Registre — bannières d'épicerie</span>
23 + <h1>Sources agrégées</h1>
24 + <p className="sub">
25 + Bannières d'épicerie du Québec recensées par Food·Ka. Chaque source «
26 + active » est synchronisée périodiquement par un connecteur dédié; les
27 + autres sont en attente de connecteur.
28 + </p>
29 +
30 + {error && <div className="notice">⚠️ {error}</div>}
31 + {!sources && !error && <div className="notice">Chargement…</div>}
32 +
33 + {sources && (
34 + <div className="src-wrap">
35 + <table className="src-table">
36 + <thead>
37 + <tr>
38 + <th>Bannière</th>
39 + <th>Région</th>
40 + <th>Statut</th>
41 + <th style={{ textAlign: "right" }}>Produits actifs</th>
42 + <th>Dernière synchro</th>
43 + </tr>
44 + </thead>
45 + <tbody>
46 + {sources.map((s) => (
47 + <tr key={s.id}>
48 + <td>
49 + <div className="src-cell">
50 + <SourceLogo source={s.id} size={32} fallback="hide" />
51 + <div>
52 + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
53 + {s.notes && (
54 + <div style={{ color: "var(--ink-3)", fontSize: 12, marginTop: 2 }}>{s.notes}</div>
55 + )}
56 + </div>
57 + </div>
58 + </td>
59 + <td style={{ color: "var(--ink-2)", maxWidth: 380 }}>{s.region}</td>
60 + <td>
61 + {s.connector ? (
62 + <span className="pill ok">connecté</span>
63 + ) : (
64 + <span className="pill todo">{s.status}</span>
65 + )}
66 + </td>
67 + <td style={{ textAlign: "right" }}>
68 + <span className="count-pill">{s.active_products || "—"}</span>
69 + </td>
70 + <td style={{ color: "var(--ink-3)" }}>
71 + {s.last_sync
72 + ? new Date(s.last_sync * 1000).toLocaleString("fr-CA")
73 + : "—"}
74 + </td>
75 + </tr>
76 + ))}
77 + </tbody>
78 + </table>
79 + </div>
80 + )}
81 + </div>
82 + );
83 +}
added frontend/src/pages/Stats.tsx +424 −0
@@ -0,0 +1,424 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Stats.tsx : observatoire du marché — consomme /api/stats/detailed
5 +// · Tuiles héro (produits, soldes, bannières, marques, prix médian, activité 7 j)
6 +// · 🧺 Panier comparatif : articles courants × bannières (prix médians)
7 +// · Bannières en chiffres (tableau triable, mini-barres de prix médian)
8 +// · Matrice catégorie × bannière (teinte chaleur, moins cher en vert)
9 +// · Distribution des prix, baisses de prix (7 j)
10 +// · Meilleures aubaines + journal des synchronisations (/api/stats)
11 +// -----------------------------------------------------------------------------
12 +import { useEffect, useState } from "react";
13 +import { Link } from "react-router-dom";
14 +import {
15 + BasketTotal, DetailedStats, SourceMarketStats, Stats,
16 + fetchSources, fetchStats, fmtTs, registerSourceNames,
17 + sourceName, sourceShort, statsDetailed,
18 +} from "../api";
19 +import ProductCard from "../components/ProductCard";
20 +import SourceLogo from "../components/SourceLogo";
21 +
22 +const fmt = (n: number | null | undefined) =>
23 + n == null ? "—" : n.toLocaleString("fr-CA");
24 +const fmt$ = (n: number | null | undefined) =>
25 + n == null
26 + ? "—"
27 + : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`;
28 +const fmtPct = (n: number | null | undefined, digits = 0) =>
29 + n == null
30 + ? "—"
31 + : `${n.toLocaleString("fr-CA", { maximumFractionDigits: digits })} %`;
32 +
33 +function Tile({ v, k, hero }: { v: string; k: string; hero?: boolean }) {
34 + return (
35 + <div className={`tile ${hero ? "hero-tile" : ""}`}>
36 + <div className="tile-v">{v}</div>
37 + <div className="tile-k">{k}</div>
38 + </div>
39 + );
40 +}
41 +
42 +// clés numériques triables du tableau « bannières en chiffres »
43 +type BannerSortKey = "n" | "median_price" | "sale_share" | "avg_discount_pct" | "max_discount_pct";
44 +
45 +export default function StatsPage() {
46 + const [d, setD] = useState<Stats | null>(null); // /api/stats (aubaines + journal)
47 + const [m, setM] = useState<DetailedStats | null>(null); // /api/stats/detailed
48 + const [error, setError] = useState<string | null>(null);
49 + const [sortKey, setSortKey] = useState<BannerSortKey>("n");
50 + const [sortDir, setSortDir] = useState<-1 | 1>(-1);
51 +
52 + useEffect(() => {
53 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
54 + fetchStats().then(setD).catch((e) => setError(String(e)));
55 + statsDetailed().then(setM).catch((e) => setError(String(e)));
56 + }, []);
57 +
58 + if (error)
59 + return (
60 + <div className="notice container">
61 + <div className="big">⚠️</div>
62 + <h2>Statistiques indisponibles</h2>
63 + <p>{error}</p>
64 + </div>
65 + );
66 +
67 + if (!m || !d)
68 + return (
69 + <div className="container stats-page" aria-busy="true">
70 + <div className="skel" style={{ height: 110, marginTop: 40 }} />
71 + <div className="tiles" style={{ marginTop: 20 }}>
72 + {Array.from({ length: 6 }).map((_, i) => (
73 + <div className="skel" key={i} style={{ height: 92 }} />
74 + ))}
75 + </div>
76 + <div className="skel" style={{ height: 320, marginTop: 8 }} />
77 + <div className="skel" style={{ height: 260, marginTop: 22 }} />
78 + </div>
79 + );
80 +
81 + const g = m.global;
82 +
83 + // ---- panier comparatif -----------------------------------------------------
84 + // colonnes = bannières couvrant assez d'articles, déjà triées du moins cher
85 + const basketCols = m.basket_totals.map((t) => t.source);
86 + const bestBasket: BasketTotal | null = m.basket_totals[0] ?? null;
87 + const totalBySrc: Record<string, BasketTotal> = {};
88 + for (const t of m.basket_totals) totalBySrc[t.source] = t;
89 +
90 + // ---- tableau bannières (triable) --------------------------------------------
91 + const numOf = (r: SourceMarketStats, k: BannerSortKey): number =>
92 + (r[k] ?? -Infinity) as number;
93 + const bannerRows = [...m.by_source].sort(
94 + (a, b) => (numOf(a, sortKey) - numOf(b, sortKey)) * sortDir || b.n - a.n
95 + );
96 + const maxMedian = Math.max(...m.by_source.map((r) => r.median_price ?? 0), 0.01);
97 + const sortBy = (k: BannerSortKey) => {
98 + if (sortKey === k) setSortDir((dir) => (dir === -1 ? 1 : -1));
99 + else { setSortKey(k); setSortDir(-1); }
100 + };
101 + const Th = ({ k, label }: { k: BannerSortKey; label: string }) => (
102 + <th
103 + className={`sortable ${sortKey === k ? "sorted" : ""}`}
104 + onClick={() => sortBy(k)}
105 + title="Trier"
106 + >
107 + {label}{sortKey === k ? (sortDir === -1 ? " ▾" : " ▴") : ""}
108 + </th>
109 + );
110 +
111 + // ---- matrice catégorie × bannière --------------------------------------------
112 + const matrixSources = m.by_source.slice(0, 9).map((s) => s.source);
113 + const matrixRows = Object.entries(m.category_matrix)
114 + .map(([cat, per]) => ({
115 + cat, per,
116 + total: Object.values(per).reduce((s, c) => s + c.n, 0),
117 + }))
118 + .sort((a, b) => b.total - a.total);
119 +
120 + // ---- distribution des prix ---------------------------------------------------
121 + const distMax = Math.max(...m.price_distribution.map((b) => b.n), 1);
122 +
123 + return (
124 + <div className="container stats-page">
125 + <span className="kicker">Observatoire — prix d'épicerie au Québec</span>
126 + <div className="stats-head">
127 + <h1 className="stats-title">L'épicerie, en chiffres</h1>
128 + <a className="btn btn-primary btn-pdf" href="/api/stats/rapport.pdf" download>
129 + ↓ Télécharger le rapport PDF
130 + </a>
131 + </div>
132 + <p className="sub">
133 + Calculé en direct sur les {fmt(g.total)} produits actifs de {fmt(g.sources)} bannières,
134 + répartis dans {fmt(g.categories)} catégories et {fmt(g.brands)} marques.
135 + </p>
136 +
137 + {/* ---- 1 · tuiles héro ---- */}
138 + <div className="tiles">
139 + <Tile hero v={fmt(g.total)} k="produits suivis" />
140 + <Tile v={fmtPct(g.sale_share * 100)} k={`en solde (${fmt(g.on_sale)} produits)`} />
141 + <Tile v={fmt(g.sources)} k="bannières connectées" />
142 + <Tile v={fmt(g.brands)} k="marques" />
143 + <Tile v={fmt$(g.median_price)} k="prix médian global" />
144 + <Tile v={fmt(g.price_changes_7d)} k="changements de prix (7 j)" />
145 + </div>
146 +
147 + {/* ---- 2 · panier comparatif ---- */}
148 + <section className="viz-card">
149 + <h2>🧺 Panier comparatif</h2>
150 + <p className="viz-sub">
151 + articles courants × bannières — prix médian des produits correspondants
152 + </p>
153 + {basketCols.length > 0 ? (
154 + <>
155 + <div className="basket-wrap">
156 + <table className="basket-table">
157 + <thead>
158 + <tr>
159 + <th>Article</th>
160 + {basketCols.map((src) => (
161 + <th key={src}
162 + className={src === bestBasket?.source ? "best-col" : ""}>
163 + <span className="basket-th">
164 + <SourceLogo source={src} size={28} fallback="hide" />
165 + {sourceShort(src)}
166 + </span>
167 + </th>
168 + ))}
169 + </tr>
170 + </thead>
171 + <tbody>
172 + {m.basket.map((row) => {
173 + const prices = basketCols
174 + .map((src) => row.by_source[src]?.median_price)
175 + .filter((v): v is number => v != null);
176 + const min = prices.length ? Math.min(...prices) : null;
177 + return (
178 + <tr key={row.item}>
179 + <th>{row.item}</th>
180 + {basketCols.map((src) => {
181 + const cell = row.by_source[src];
182 + const v = cell?.median_price ?? null;
183 + const cls = [
184 + v != null && v === min ? "cell-best" : "",
185 + src === bestBasket?.source ? "best-col" : "",
186 + ].join(" ").trim();
187 + return (
188 + <td key={src} className={cls}
189 + title={cell ? `${fmt(cell.n)} produits correspondants` : undefined}>
190 + {fmt$(v)}
191 + </td>
192 + );
193 + })}
194 + </tr>
195 + );
196 + })}
197 + </tbody>
198 + <tfoot>
199 + <tr>
200 + <th>Total du panier</th>
201 + {basketCols.map((src) => {
202 + const t = totalBySrc[src];
203 + return (
204 + <td key={src}
205 + className={src === bestBasket?.source ? "best-col cell-best" : ""}>
206 + {fmt$(t?.total)}
207 + <small>{t ? `${t.items}/${m.basket.length} articles` : ""}</small>
208 + </td>
209 + );
210 + })}
211 + </tr>
212 + </tfoot>
213 + </table>
214 + </div>
215 + {bestBasket && (
216 + <div className="basket-callout">
217 + 🏆 Panier le moins cher : <b>{sourceName(bestBasket.source)}</b> —{" "}
218 + {fmt$(bestBasket.total)} pour {bestBasket.items} articles
219 + </div>
220 + )}
221 + <p className="stats-foot">
222 + Prix médian des produits correspondant à chaque article chez la bannière ;
223 + seules les bannières couvrant la majorité du panier sont comparées.
224 + </p>
225 + </>
226 + ) : (
227 + <p className="fine">
228 + Pas encore assez de données pour composer le panier — il se remplit à
229 + mesure que les bannières sont synchronisées.
230 + </p>
231 + )}
232 + </section>
233 +
234 + {/* ---- 3 · bannières en chiffres ---- */}
235 + <section className="viz-card">
236 + <h2>Bannières en chiffres</h2>
237 + <p className="viz-sub">cliquez un en-tête pour trier · cliquez une bannière pour filtrer</p>
238 + <div className="stat-table-wrap">
239 + <table className="stat-table">
240 + <thead>
241 + <tr>
242 + <th>Bannière</th>
243 + <Th k="n" label="Produits" />
244 + <Th k="median_price" label="Prix médian" />
245 + <Th k="sale_share" label="En solde" />
246 + <Th k="avg_discount_pct" label="Rabais moyen" />
247 + <Th k="max_discount_pct" label="Rabais max" />
248 + </tr>
249 + </thead>
250 + <tbody>
251 + {bannerRows.map((r) => (
252 + <tr key={r.source}>
253 + <td>
254 + <Link to={`/?source=${encodeURIComponent(r.source)}`}>
255 + <SourceLogo source={r.source} size={22} name="short" />
256 + </Link>
257 + </td>
258 + <td>{fmt(r.n)}</td>
259 + <td>
260 + {fmt$(r.median_price)}
261 + <span className="mini-track" aria-hidden="true">
262 + <span className="mini-fill"
263 + style={{ width: `${((r.median_price ?? 0) / maxMedian) * 100}%` }} />
264 + </span>
265 + </td>
266 + <td className={r.sale_share > 0 ? "pct-sale" : ""}>
267 + {fmtPct(r.sale_share * 100)}
268 + </td>
269 + <td>{fmtPct(r.avg_discount_pct, 1)}</td>
270 + <td>{fmtPct(r.max_discount_pct, 1)}</td>
271 + </tr>
272 + ))}
273 + </tbody>
274 + </table>
275 + </div>
276 + </section>
277 +
278 + {/* ---- 4 · matrice catégorie × bannière ---- */}
279 + <section className="viz-card">
280 + <h2>Prix médian par catégorie et bannière</h2>
281 + <p className="viz-sub">
282 + {matrixSources.length} plus grandes bannières — le moins cher de chaque rangée en vert
283 + </p>
284 + <div className="stat-table-wrap">
285 + <table className="stat-table matrix-table">
286 + <thead>
287 + <tr>
288 + <th>Catégorie</th>
289 + {matrixSources.map((src) => (
290 + <th key={src} title={sourceName(src)}>
291 + <span className="basket-th">
292 + <SourceLogo source={src} size={24} fallback="hide" />
293 + {sourceShort(src)}
294 + </span>
295 + </th>
296 + ))}
297 + </tr>
298 + </thead>
299 + <tbody>
300 + {matrixRows.map(({ cat, per }) => {
301 + const vals = matrixSources
302 + .map((src) => per[src]?.median_price)
303 + .filter((v): v is number => v != null);
304 + const min = vals.length ? Math.min(...vals) : null;
305 + const max = vals.length ? Math.max(...vals) : null;
306 + return (
307 + <tr key={cat}>
308 + <td>
309 + <Link to={`/?category=${encodeURIComponent(cat)}`}>{cat}</Link>
310 + </td>
311 + {matrixSources.map((src) => {
312 + const cell = per[src];
313 + const v = cell?.median_price ?? null;
314 + let style: React.CSSProperties | undefined;
315 + if (v != null && min != null && max != null) {
316 + if (v === min) style = { background: "rgba(46, 158, 99, 0.16)" };
317 + else if (max > min) {
318 + const t = (v - min) / (max - min);
319 + style = { background: `rgba(232, 84, 47, ${(0.05 + 0.2 * t).toFixed(3)})` };
320 + }
321 + }
322 + return (
323 + <td key={src} style={style}
324 + className={v != null && v === min ? "cell-best" : ""}
325 + title={cell ? `${fmt(cell.n)} produits` : undefined}>
326 + {fmt$(v)}
327 + </td>
328 + );
329 + })}
330 + </tr>
331 + );
332 + })}
333 + </tbody>
334 + </table>
335 + </div>
336 + <div className="matrix-legend">
337 + <span><span className="sw" style={{ background: "rgba(46, 158, 99, 0.3)" }} />moins cher</span>
338 + <span><span className="sw" style={{ background: "rgba(232, 84, 47, 0.25)" }} />plus cher</span>
339 + </div>
340 + </section>
341 +
342 + {/* ---- 5 · distribution des prix ---- */}
343 + <section className="viz-card">
344 + <h2>Distribution des prix</h2>
345 + <p className="viz-sub">produits actifs par palier de prix</p>
346 + <div className="hbars">
347 + {m.price_distribution.map((b) => (
348 + <div className="hbar-row" key={b.range}>
349 + <span className="hbar-label">{b.range}</span>
350 + <span className="hbar-track">
351 + <span className="hbar-fill" style={{ width: `${(b.n / distMax) * 100}%` }} />
352 + </span>
353 + <span className="hbar-value">{fmt(b.n)}</span>
354 + </div>
355 + ))}
356 + </div>
357 + </section>
358 +
359 + {/* ---- 6 · baisses de prix (7 j) ---- */}
360 + <section className="viz-card">
361 + <h2>📉 Baisses de prix (7 jours)</h2>
362 + <p className="viz-sub">produits dont le prix relevé a diminué depuis la dernière synchronisation</p>
363 + {m.price_drops.length > 0 ? (
364 + <ul className="drops-list">
365 + {m.price_drops.slice(0, 20).map((dr, i) => (
366 + <li key={`${dr.uid}-${i}`}>
367 + <Link to={`/produit/${encodeURIComponent(dr.uid)}`}>
368 + <SourceLogo source={dr.source} size={22} fallback="hide" />
369 + <span className="drop-name">{dr.name}</span>
370 + <span className="drop-prices">
371 + <s>{fmt$(dr.old_price)}</s> → <b>{fmt$(dr.new_price)}</b>
372 + </span>
373 + <span className="drop-pct">−{dr.drop_pct.toLocaleString("fr-CA")} %</span>
374 + </Link>
375 + </li>
376 + ))}
377 + </ul>
378 + ) : (
379 + <p className="fine">
380 + Aucune baisse détectée encore — l'historique se construit à chaque synchronisation.
381 + </p>
382 + )}
383 + </section>
384 +
385 + {/* ---- aubaines & journal (depuis /api/stats) ---- */}
386 + {d.deals.length > 0 && (
387 + <section className="viz-card">
388 + <h2>Meilleures aubaines du moment 🔥</h2>
389 + <p className="viz-sub">rabais relatif le plus fort, toutes bannières confondues</p>
390 + <div className="grid deals-grid">
391 + {d.deals.slice(0, 12).map((p) => (
392 + <ProductCard key={p.uid} p={p} />
393 + ))}
394 + </div>
395 + <p className="stats-foot">
396 + <Link to="/aubaines">Voir toutes les aubaines →</Link>
397 + </p>
398 + </section>
399 + )}
400 +
401 + {d.recent_syncs.length > 0 && (
402 + <section className="viz-card">
403 + <h2>Dernières synchronisations</h2>
404 + <ul className="alertes">
405 + {d.recent_syncs.map((s) => (
406 + <li key={s.id}>
407 + {s.ok ? "✅" : "⚠️"} <b>{sourceName(s.source)}</b> — {fmtTs(s.ts)} ·{" "}
408 + {fmt(s.found)} produits trouvés, {fmt(s.added)} ajoutés,{" "}
409 + {fmt(s.updated)} mis à jour, {fmt(s.removed)} retirés
410 + {s.message ? ` — ${s.message}` : ""}
411 + </li>
412 + ))}
413 + </ul>
414 + </section>
415 + )}
416 +
417 + <p className="stats-foot">
418 + Données recalculées à chaque synchronisation. Les catégories et bannières
419 + renvoient vers les produits filtrés correspondants.{" "}
420 + <Link to="/sources">Voir le registre complet des sources →</Link>
421 + </p>
422 + </div>
423 + );
424 +}
added frontend/src/styles.css +990 −0
@@ -0,0 +1,990 @@
1 +/* -----------------------------------------------------------------------------
2 + Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : système de design « éditorial sharp » — thème clair « marché frais »
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Palette épicerie : papier crème, encre vert-noir, vert marché #1f7a4d,
8 + lime #d9f26b en surlignage ponctuel, tomate #e8542f réservée aux soldes
9 + · Packshots produits : object-fit contain sur fond blanc
10 + · 100 % adaptatif mobile (PWA installable, safe-areas iOS)
11 +----------------------------------------------------------------------------- */
12 +:root {
13 + --paper: #faf6ee;
14 + --surface: #ffffff;
15 + --surface-2: #f7f2e7;
16 + --ink: #14231a;
17 + --ink-2: #47564c;
18 + --ink-3: #839187;
19 + --line: rgba(20, 35, 26, 0.14);
20 + --line-strong: rgba(20, 35, 26, 0.85);
21 + --green: #1f7a4d;
22 + --green-bright: #2e9e63;
23 + --green-deep: #14523a;
24 + --lime: #d9f26b;
25 + --lime-soft: #eef6d4;
26 + --tomato: #e8542f;
27 + --tomato-deep: #b83c1c;
28 + --tomato-soft: #fcebe3;
29 + --amber: #e8a33d;
30 + --amber-soft: #fdf3e2;
31 + --danger: #b3423a;
32 + --r-card: 10px;
33 + --r-ctl: 6px;
34 + --shadow-flat: 0 1px 2px rgba(20, 35, 26, 0.05);
35 + --shadow-off: 6px 6px 0 var(--ink);
36 + --shadow-off-soft: 8px 8px 0 rgba(20, 35, 26, 0.08);
37 + --font-display: "Space Grotesk", system-ui, sans-serif;
38 + --font-body: "Inter", system-ui, sans-serif;
39 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
40 +}
41 +
42 +* { box-sizing: border-box; }
43 +html { scroll-behavior: smooth; }
44 +body {
45 + margin: 0;
46 + background: var(--paper);
47 + color: var(--ink);
48 + font-family: var(--font-body);
49 + font-size: 15px;
50 + line-height: 1.55;
51 + -webkit-font-smoothing: antialiased;
52 + padding-bottom: env(safe-area-inset-bottom);
53 +}
54 +/* grain subtil — texture signature */
55 +body::before {
56 + content: "";
57 + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
58 + 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");
59 +}
60 +#root { position: relative; z-index: 1; }
61 +
62 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
63 +a { color: inherit; text-decoration: none; }
64 +button { font-family: inherit; }
65 +img { display: block; }
66 +::selection { background: var(--lime); color: var(--ink); }
67 +
68 +.mono { font-family: var(--font-mono); }
69 +.kicker {
70 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
71 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
72 + display: inline-flex; align-items: center; gap: 8px;
73 +}
74 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
75 +
76 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
77 +@media (max-width: 640px) { .container { padding: 0 16px; } }
78 +
79 +/* ================= Header ================= */
80 +.header {
81 + position: sticky; top: 0; z-index: 50;
82 + background: rgba(250, 246, 238, 0.88);
83 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
84 + border-bottom: 2px solid var(--ink);
85 +}
86 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
87 +.brand {
88 + font-family: var(--font-display); font-weight: 700; font-size: 26px;
89 + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;
90 +}
91 +.brand .ka {
92 + background: var(--green-deep); color: var(--lime); padding: 2px 7px 4px;
93 + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
94 + transition: transform 0.2s ease;
95 +}
96 +.brand:hover .ka { transform: rotate(0deg); }
97 +/* marque panier — pictogramme épicerie à côté du nom */
98 +.brand-mark {
99 + width: 30px; height: 30px; margin-right: 10px; flex: none;
100 + display: inline-flex; align-items: center; justify-content: center;
101 + background: var(--lime); color: var(--ink); border: 1.5px solid var(--ink);
102 + border-radius: 8px; transform: rotate(-5deg); transition: transform 0.2s ease;
103 + box-shadow: 2px 2px 0 rgba(20, 35, 26, 0.2);
104 +}
105 +.brand:hover .brand-mark { transform: rotate(0deg); }
106 +.brand-mark svg { width: 17px; height: 17px; }
107 +@media (max-width: 640px) { .brand-mark { width: 26px; height: 26px; margin-right: 8px; } .brand-mark svg { width: 15px; height: 15px; } }
108 +.brand-tag {
109 + font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
110 + letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px;
111 +}
112 +@media (max-width: 760px) { .brand-tag { display: none; } }
113 +.nav { margin-left: auto; display: flex; gap: 4px; }
114 +.nav a {
115 + padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px;
116 + color: var(--ink-2); border: 1.5px solid transparent;
117 + transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center;
118 +}
119 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
120 +.nav a.active { background: var(--green-deep); color: var(--lime); }
121 +
122 +/* ================= Ticker — bande « ruban de caisse » ================= */
123 +.ticker {
124 + background: var(--surface); color: var(--ink-2); overflow: hidden;
125 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em;
126 + text-transform: uppercase; padding: 7px 0; white-space: nowrap;
127 + border-bottom: 2px dashed var(--line-strong);
128 + box-shadow: inset 0 -6px 12px -10px rgba(20, 35, 26, 0.35);
129 +}
130 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }
131 +.ticker span { padding: 0 26px; position: relative; font-variant-numeric: tabular-nums; }
132 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.6; font-size: 8px; top: 3px; color: var(--green); }
133 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
134 +@media (prefers-reduced-motion: reduce) {
135 + .ticker-track { animation: none; }
136 + * { transition-duration: 0.01ms !important; }
137 +}
138 +
139 +/* ================= Hero ================= */
140 +.hero { padding: 58px 0 22px; }
141 +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
142 +.hero h1 {
143 + font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98;
144 + text-transform: uppercase; letter-spacing: -0.035em; max-width: 900px; margin-top: 14px;
145 +}
146 +.hero h1 .outline {
147 + color: transparent; -webkit-text-stroke: 2px var(--ink);
148 +}
149 +.hero h1 .hl {
150 + background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block;
151 + transform: rotate(-1deg);
152 +}
153 +/* variante « aubaines » : surlignage tomate (rabais) */
154 +.hero h1 .hl-tomato { background: var(--tomato); color: #fff; }
155 +.kicker-sale { color: var(--tomato-deep); }
156 +.kicker-sale::before { background: var(--tomato); }
157 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 640px; margin: 20px 0 0; }
158 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
159 +.stat-chip {
160 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px;
161 + padding: 8px 16px; font-family: var(--font-mono); font-size: 12px;
162 + color: var(--ink-2); display: flex; gap: 8px; align-items: center;
163 + box-shadow: 3px 3px 0 rgba(20, 35, 26, 0.12);
164 +}
165 +.stat-chip b { color: var(--ink); font-weight: 700; }
166 +.stat-chip .pulse {
167 + width: 8px; height: 8px; border-radius: 50%; background: var(--green);
168 + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite;
169 +}
170 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } }
171 +
172 +/* ================= Filter bar ================= */
173 +.filterbar {
174 + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card);
175 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px;
176 + display: flex; flex-direction: column; gap: 0; min-width: 0;
177 +}
178 +
179 +/* — rangée principale — */
180 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
181 +.f-search {
182 + flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px;
183 + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2);
184 + padding: 0 13px; min-height: 52px; color: var(--ink-2);
185 + transition: box-shadow 0.15s ease;
186 +}
187 +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
188 +.f-search input {
189 + border: none; background: none; outline: none; flex: 1; min-width: 0;
190 + font-size: 15px; color: var(--ink); font-family: inherit;
191 +}
192 +.f-clear {
193 + border: none; background: var(--line); color: var(--ink-2); border-radius: 50%;
194 + width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none;
195 + display: grid; place-items: center;
196 +}
197 +.f-ctl {
198 + flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;
199 + gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
200 + padding: 7px 12px 6px; min-height: 52px; cursor: pointer;
201 + transition: box-shadow 0.15s ease;
202 +}
203 +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); }
204 +.f-ctl > span {
205 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
206 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
207 +}
208 +.f-ctl select {
209 + border: none; background: transparent; outline: none; font-family: var(--font-display);
210 + font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;
211 + appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;
212 + text-overflow: ellipsis;
213 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E");
214 + background-repeat: no-repeat; background-position: right center;
215 +}
216 +.range-pair { display: flex; align-items: center; gap: 4px; }
217 +.range-pair select { max-width: 86px; }
218 +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
219 +.f-more {
220 + flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px;
221 + background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer;
222 + font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px;
223 + transition: all 0.13s ease;
224 +}
225 +.f-more:hover { background: var(--lime-soft); }
226 +.f-more.on { background: var(--green-deep); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,35,26,0.22); }
227 +
228 +/* — panneau avancé — */
229 +.f-adv {
230 + display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px;
231 + border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0;
232 + animation: adv-in 0.18s ease;
233 +}
234 +@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
235 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
236 +.f-group > label {
237 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
238 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
239 +}
240 +.f-group-end { justify-content: flex-end; }
241 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
242 +.f-native {
243 + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
244 + padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none;
245 + font-family: inherit; min-height: 42px; width: 100%; min-width: 0;
246 + appearance: none; -webkit-appearance: none;
247 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
248 + background-repeat: no-repeat; background-position: right 12px center;
249 +}
250 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
251 +
252 +/* segments (pilules soudées) */
253 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
254 +.seg button {
255 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2);
256 + padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px;
257 + cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px;
258 + transition: all 0.12s ease;
259 +}
260 +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
261 +.seg button:last-child { border-radius: 0 8px 8px 0; }
262 +.seg button:hover { background: var(--lime-soft); color: var(--ink); }
263 +.seg button.on { background: var(--green-deep); color: var(--lime); position: relative; z-index: 1; }
264 +
265 +/* pastilles de filtres actifs */
266 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
267 +.pill {
268 + display: inline-flex; align-items: center; gap: 7px;
269 + border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink);
270 + border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600;
271 + font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease;
272 +}
273 +.pill:hover { background: var(--lime); }
274 +.pill-x { font-size: 10px; opacity: 0.65; }
275 +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
276 +.pill-clear:hover { background: var(--danger); color: #fff; }
277 +.chip-sep { width: 1.5px; align-self: stretch; background: var(--line); margin: 4px 4px; flex: none; }
278 +.link-btn {
279 + background: none; border: none; padding: 0; color: var(--green); font: inherit;
280 + text-decoration: underline; cursor: pointer;
281 +}
282 +.field { display: flex; flex-direction: column; gap: 5px; }
283 +.field label {
284 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
285 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding-left: 2px;
286 +}
287 +.field input, .field select {
288 + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
289 + padding: 11px 12px; font-size: 15px; color: var(--ink); outline: none; font-family: inherit;
290 + transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px;
291 + appearance: none; -webkit-appearance: none;
292 +}
293 +.field select {
294 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
295 + background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px;
296 +}
297 +.field input:focus, .field select:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
298 +.btn {
299 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px;
300 + font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px;
301 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease;
302 + font-family: var(--font-display); letter-spacing: 0.01em;
303 +}
304 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
305 +.btn-primary { background: var(--green-deep); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,35,26,0.25); }
306 +.btn-primary:hover { background: var(--ink); }
307 +.btn-ghost { background: transparent; color: var(--ink); }
308 +.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,35,26,0.2); }
309 +
310 +/* ================= Chips ================= */
311 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
312 +.chips::-webkit-scrollbar { display: none; }
313 +.chip {
314 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);
315 + border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer;
316 + font-family: var(--font-display); white-space: nowrap; min-height: 40px;
317 + transition: all 0.13s ease;
318 +}
319 +.chip:hover { background: var(--lime-soft); transform: translateY(-1px); }
320 +.chip.on { background: var(--green-deep); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,35,26,0.2); }
321 +
322 +/* ================= Results ================= */
323 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }
324 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
325 +.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
326 +.grid {
327 + display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
328 + gap: 22px; padding-bottom: 26px;
329 +}
330 +@media (max-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); gap: 12px; padding-bottom: 24px; } }
331 +
332 +.card {
333 + background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card);
334 + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat);
335 + transition: transform 0.16s ease, box-shadow 0.16s ease;
336 +}
337 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
338 +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
339 +.card-img { position: relative; aspect-ratio: 1/1; background: #fff; overflow: hidden; }
340 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
341 +/* packshot produit : image entière sur fond blanc, jamais recadrée */
342 +.card-img.packshot { border-bottom: 1.5px dashed var(--line); }
343 +.card-img.packshot img { object-fit: contain; padding: 14px; }
344 +.card:hover .card-img img { transform: scale(1.05); }
345 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
346 +.badge {
347 + position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px;
348 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em;
349 + background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink);
350 +}
351 +.badge.sale { background: var(--tomato); color: #fff; border-color: var(--tomato-deep); }
352 +.badge.right { left: auto; right: 12px; background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
353 +.card-body { padding: 14px 16px 14px; display: flex; flex-direction: column; gap: 5px; flex: 1; }
354 +.card-price {
355 + font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em;
356 + display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
357 +}
358 +.price-old {
359 + font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 12.5px;
360 + letter-spacing: 0.02em;
361 + text-decoration: line-through; text-decoration-color: var(--tomato);
362 +}
363 +.price-old.big { font-size: 16px; margin-left: 10px; }
364 +.card-unit { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; }
365 +.card-title { font-weight: 600; font-size: 14px; color: var(--ink); }
366 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
367 +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
368 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
369 +.source-tag {
370 + font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase;
371 + letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft);
372 + border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px;
373 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
374 +}
375 +.avail { font-size: 11.5px; color: var(--ink-2); font-weight: 500; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
376 +
377 +/* pagination « Charger plus » */
378 +.load-more { display: flex; justify-content: center; padding: 4px 0 70px; }
379 +@media (max-width: 640px) { .load-more { padding-bottom: 90px; } }
380 +
381 +/* ================= Skeletons ================= */
382 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
383 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
384 +.skel .sk-img, .skel .sk-line {
385 + background: linear-gradient(90deg, #efe9db 25%, #f9f5eb 50%, #efe9db 75%);
386 + background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
387 +}
388 +.skel .sk-img { aspect-ratio: 1/1; }
389 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
390 +.skel .sk-line.short { width: 45%; }
391 +
392 +/* ================= Empty / error ================= */
393 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
394 +.notice .big { font-size: 44px; margin-bottom: 10px; }
395 +.notice h2 { text-transform: uppercase; }
396 +
397 +/* ================= Fiche produit ================= */
398 +.detail { padding: 30px 0 90px; }
399 +.crumbs {
400 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase;
401 + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
402 +}
403 +.crumbs a { border-bottom: 1.5px solid transparent; }
404 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
405 +
406 +/* mobile : flux unique ordonné ; desktop : 2 colonnes (produit | synthèse) */
407 +.fiche { display: flex; flex-direction: column; gap: 22px; }
408 +.f-col { display: contents; }
409 +.f-galerie { order: 1; } .f-hero { order: 2; } .f-compare { order: 3; }
410 +.f-desc { order: 4; } .f-pratique { order: 5; }
411 +@media (min-width: 900px) {
412 + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
413 + .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
414 +}
415 +.f-bloc { min-width: 0; }
416 +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 10px; }
417 +.f-bloc:empty { display: none; }
418 +
419 +/* galerie à balayage natif — packshots entiers, fond blanc */
420 +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden;
421 + border: 1.5px solid var(--line-strong); background: #fff; }
422 +.carousel-track {
423 + display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
424 + -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 1/1;
425 +}
426 +.carousel-track::-webkit-scrollbar { display: none; }
427 +.carousel-track img {
428 + flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center;
429 + cursor: zoom-in;
430 +}
431 +.carousel.packshot .carousel-track { aspect-ratio: 4/3; }
432 +.carousel.packshot .carousel-track img { object-fit: contain; padding: 24px; }
433 +.carousel-empty { display: flex; align-items: center; justify-content: center;
434 + aspect-ratio: 4/3; font-size: 48px; }
435 +.carousel-count {
436 + position: absolute; right: 12px; bottom: 12px; z-index: 2;
437 + background: rgba(20, 35, 26, 0.82); color: var(--lime);
438 + font-family: var(--font-mono); font-size: 12px; font-weight: 700;
439 + padding: 4px 10px; border-radius: 999px;
440 +}
441 +.carousel-nav {
442 + position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
443 + width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink);
444 + background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer;
445 + display: flex; align-items: center; justify-content: center; line-height: 1;
446 +}
447 +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
448 +@media (max-width: 640px) { .carousel-nav { display: none; } } /* balayage natif */
449 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; }
450 +.thumbs button {
451 + border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 6px; cursor: pointer;
452 + aspect-ratio: 1/1; background: #fff; transition: border-color 0.12s ease, transform 0.12s ease;
453 +}
454 +.thumbs button:hover { transform: translateY(-2px); }
455 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
456 +.thumbs img { width: 100%; height: 100%; object-fit: contain; }
457 +
458 +/* bandeau prix */
459 +.f-hero .price {
460 + font-family: var(--font-display); font-size: clamp(34px, 8vw, 44px); font-weight: 700;
461 + letter-spacing: -0.03em; display: flex; align-items: baseline; flex-wrap: wrap;
462 +}
463 +.unit-price {
464 + font-family: var(--font-mono); font-size: 13px; color: var(--ink-2);
465 + letter-spacing: 0.04em; margin-top: 4px;
466 +}
467 +.deal-badge {
468 + display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px;
469 + border-radius: 999px; padding: 7px 14px; font-size: 13px; font-weight: 600;
470 + border: 1.5px solid var(--line-strong);
471 +}
472 +.deal-good { background: var(--tomato-soft); border-color: var(--tomato); color: var(--tomato-deep); }
473 +.deal-ok { background: var(--surface); color: var(--ink-2); }
474 +.deal-high { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
475 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 26px); margin-top: 8px; }
476 +.f-hero .loc { color: var(--ink-2); font-size: 14px; }
477 +
478 +/* chips clés à défilement horizontal */
479 +.chips-scroll {
480 + display: flex; gap: 8px; overflow-x: auto; padding: 12px 0 6px;
481 + scrollbar-width: none; -webkit-overflow-scrolling: touch;
482 +}
483 +.chips-scroll::-webkit-scrollbar { display: none; }
484 +.chip-key {
485 + flex: 0 0 auto; background: var(--green-deep); color: var(--lime);
486 + border-radius: 999px; padding: 8px 14px; font-family: var(--font-mono);
487 + font-size: 12px; font-weight: 700; white-space: nowrap; min-height: 34px;
488 + display: inline-flex; align-items: center;
489 +}
490 +
491 +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
492 +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }
493 +.kv .cell { background: var(--surface-2); border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 10px 13px; }
494 +.kv .cell .k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; }
495 +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); }
496 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }
497 +.amenity {
498 + background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600;
499 + border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px;
500 +}
501 +.amenity.confirmed { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); font-weight: 600; }
502 +.cta {
503 + display: flex; align-items: center; justify-content: center; gap: 10px;
504 + text-align: center; background: var(--green-deep); color: var(--lime);
505 + font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl);
506 + padding: 12px 15px; border: 1.5px solid var(--ink); min-height: 48px;
507 + box-shadow: 4px 4px 0 rgba(20,35,26,0.25); transition: all 0.14s ease;
508 + margin-top: 14px;
509 +}
510 +.cta:hover { background: var(--lime); color: var(--ink); }
511 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
512 +.fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; font-family: var(--font-mono); letter-spacing: 0.02em; }
513 +
514 +/* historique de prix */
515 +.prix-histo {
516 + margin-top: 12px; padding: 10px 14px; border-radius: var(--r-card);
517 + border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface);
518 +}
519 +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
520 +.texte-original { margin-top: 12px; }
521 +.texte-original summary {
522 + cursor: pointer; font-family: var(--font-mono); font-size: 12px;
523 + color: var(--ink-3); min-height: 44px; display: flex; align-items: center;
524 +}
525 +.histo-prix-liste { list-style: none; margin: 0; padding: 0; }
526 +.histo-prix-liste li {
527 + display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
528 + padding: 7px 2px; border-bottom: 1px dashed var(--line); font-size: 13px;
529 +}
530 +.histo-prix-liste li:last-child { border-bottom: 0; }
531 +.hp-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); }
532 +.hp-prix { font-family: var(--font-display); }
533 +
534 +/* ================= Comparer les prix ================= */
535 +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
536 +.cmp-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
537 +.cmp-list li {
538 + background: var(--surface); border: 1.5px solid var(--line-strong);
539 + border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-flat);
540 + transition: transform 0.14s ease, box-shadow 0.14s ease;
541 +}
542 +.cmp-list li:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--ink); }
543 +.cmp-list li.best { border-color: var(--green); background: var(--lime-soft); }
544 +.cmp-link { display: flex; align-items: center; gap: 12px; padding: 10px 14px 10px 10px; }
545 +.cmp-img {
546 + flex: 0 0 56px; width: 56px; height: 56px; border: 1px solid var(--line);
547 + border-radius: 8px; background: #fff; overflow: hidden;
548 + display: flex; align-items: center; justify-content: center;
549 +}
550 +.cmp-img img { width: 100%; height: 100%; object-fit: contain; padding: 4px; }
551 +.cmp-img .noimg { font-size: 22px; color: var(--ink-3); }
552 +.cmp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
553 +.cmp-name { font-weight: 600; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis;
554 + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
555 +.cmp-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); }
556 +.cmp-main .source-tag { align-self: flex-start; max-width: 100%; }
557 +.cmp-price { flex: 0 0 auto; text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
558 +.cmp-price b { font-family: var(--font-display); font-size: 17px; letter-spacing: -0.02em; }
559 +.cmp-unit { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
560 +.cmp-best {
561 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase;
562 + letter-spacing: 0.08em; background: var(--green); color: var(--lime);
563 + border-radius: 4px; padding: 2px 7px;
564 +}
565 +.baisse-pct {
566 + font-style: normal; font-family: var(--font-mono); font-weight: 700;
567 + background: var(--tomato-soft); border: 1px solid var(--tomato);
568 + color: var(--tomato-deep); border-radius: 999px; padding: 2px 8px;
569 + font-size: 11px;
570 +}
571 +
572 +/* ================= Lightbox ================= */
573 +.lightbox {
574 + position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100;
575 + display: flex; align-items: center; justify-content: center; cursor: zoom-out;
576 + padding: max(16px, env(safe-area-inset-top)) 16px;
577 +}
578 +.lightbox img { max-width: 94vw; max-height: 90vh; border-radius: 6px; border: 2px solid var(--lime); background: #fff; }
579 +
580 +/* ================= Sources page ================= */
581 +.sources { padding: 44px 0 90px; }
582 +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
583 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }
584 +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }
585 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }
586 +.src-table th {
587 + text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
588 + letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px;
589 + border-bottom: 1.5px solid var(--ink); background: var(--surface-2);
590 +}
591 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
592 +.src-table tr:last-child td { border-bottom: none; }
593 +.src-table tr:hover td { background: var(--surface-2); }
594 +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
595 +.pill.ok { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; }
596 +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; }
597 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; }
598 +
599 +/* ================= Page Statistiques ================= */
600 +.stats-page { padding: 44px 0 90px; }
601 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
602 +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }
603 +
604 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
605 +.tile {
606 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
607 + padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 35, 26, 0.1);
608 +}
609 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; }
610 +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
611 +.hero-tile { background: var(--green-deep); color: var(--lime); border-color: var(--green-deep); }
612 +.hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); }
613 +
614 +.viz-card {
615 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
616 + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;
617 +}
618 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
619 +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
620 +.viz-card .deals-grid { padding-bottom: 8px; }
621 +
622 +/* Barres horizontales (mono-série) */
623 +.hbars { display: flex; flex-direction: column; gap: 7px; }
624 +.hbar-row {
625 + display: grid; grid-template-columns: minmax(96px, 170px) 1fr auto;
626 + gap: 12px; align-items: center; min-height: 26px; cursor: default;
627 +}
628 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
629 +.hbar-label a { border-bottom: 1.5px solid var(--lime); }
630 +.hbar-label a:hover { color: var(--green-deep); }
631 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
632 +.hbar-fill {
633 + display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0;
634 + min-width: 2px; transition: background 0.12s ease;
635 +}
636 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
637 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
638 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
639 +@media (max-width: 640px) {
640 + .hbar-row { grid-template-columns: minmax(80px, 110px) 1fr auto; gap: 8px; }
641 + .hbar-value em { display: none; }
642 + .viz-card { padding: 20px 16px 14px; }
643 +}
644 +
645 +/* Infobulle */
646 +.viz-tip {
647 + position: fixed; z-index: 120; pointer-events: none; max-width: 180px;
648 + background: var(--ink); color: var(--paper); border-radius: 8px;
649 + border: 1px solid var(--lime); padding: 9px 12px; font-size: 12px; line-height: 1.5;
650 + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35);
651 +}
652 +.viz-tip-title { font-family: var(--font-display); font-weight: 700; color: var(--lime); margin-bottom: 2px; }
653 +
654 +/* Vue tableau repliable (accessibilité / relief) */
655 +.viz-table { margin-top: 16px; border-top: 1.5px dashed var(--line); padding-top: 10px; }
656 +.viz-table summary {
657 + cursor: pointer; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
658 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep);
659 +}
660 +.viz-table table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
661 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
662 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
663 +
664 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
665 +.alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); }
666 +
667 +/* ================= Mobile : feuille de filtres + FAB ================= */
668 +.sheet-head { display: none; }
669 +.sheet-apply { display: none; }
670 +.sheet-backdrop { display: none; }
671 +.fab { display: none; }
672 +
673 +@media (max-width: 640px) {
674 + /* En-tête compact */
675 + .header-inner { height: 56px; }
676 + .brand { font-size: 21px; }
677 + .nav a { padding: 8px 13px; font-size: 13.5px; }
678 + .ticker { font-size: 10.5px; padding: 6px 0; }
679 +
680 + /* Héro resserré + stats en rangée défilante */
681 + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
682 + .hero p.lede { font-size: 15px; }
683 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
684 + .stat-row::-webkit-scrollbar { display: none; }
685 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
686 +
687 + /* La barre de filtres devient une feuille coulissante (bottom sheet) */
688 + .filterbar { display: none; }
689 + .filterbar.open .f-primary { flex-direction: column; }
690 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
691 + .filterbar.open .f-more { display: none; }
692 + .filterbar.open .f-adv { margin-top: 4px; }
693 + .filterbar.open {
694 + display: flex; flex-direction: column; gap: 12px;
695 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
696 + margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
697 + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
698 + padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
699 + box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35);
700 + animation: sheet-up 0.22s ease;
701 + }
702 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
703 + .filterbar.open .sheet-head {
704 + display: flex; justify-content: space-between; align-items: center;
705 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
706 + text-transform: uppercase; letter-spacing: -0.01em;
707 + position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;
708 + border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1;
709 + }
710 + .sheet-close {
711 + border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%;
712 + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1;
713 + }
714 + .filterbar.open .sheet-apply { display: block; width: 100%; }
715 + .sheet-backdrop {
716 + display: block; position: fixed; inset: 0; z-index: 90;
717 + background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px);
718 + }
719 +
720 + /* Bouton flottant */
721 + .fab {
722 + display: flex; align-items: center; gap: 6px;
723 + position: fixed; left: 50%; transform: translateX(-50%);
724 + bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80;
725 + background: var(--green-deep); color: var(--lime); border: 1.5px solid var(--ink);
726 + border-radius: 999px; padding: 13px 24px; font-family: var(--font-display);
727 + font-weight: 700; font-size: 15px; cursor: pointer;
728 + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 35, 26, 0.25);
729 + }
730 + .fab:active { transform: translateX(-50%) scale(0.97); }
731 +
732 + /* Fiche produit : vignettes en bande défilante */
733 + .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; }
734 + .thumbs::-webkit-scrollbar { display: none; }
735 + .thumbs button { flex: 0 0 96px; }
736 + .detail { padding-top: 20px; }
737 + .results-head { margin-top: 20px; }
738 + .chips { margin-right: -16px; padding-right: 16px; }
739 + .notice { padding: 48px 16px; }
740 + .cmp-link { padding: 8px 10px; gap: 10px; }
741 + .cmp-img { flex-basis: 48px; width: 48px; height: 48px; }
742 +}
743 +
744 +/* Anti-zoom iOS : les champs doivent faire >= 16px */
745 +@media (max-width: 900px) {
746 + .field input, .field select { font-size: 16px; }
747 +}
748 +
749 +/* ================= Footer ================= */
750 +.footer { background: var(--ink); color: rgba(250, 246, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; }
751 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
752 +.footer .fbrand .ka { color: var(--lime); }
753 +.footer b { color: var(--paper); }
754 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
755 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(250,246,238,0.45); margin-top: 18px; }
756 +.footer .fmono a { text-decoration: underline; text-underline-offset: 2px; }
757 +
758 +/* --- Menu déroulant mobile ------------------------------------------------- */
759 +.menu-btn {
760 + display: none; position: relative; z-index: 60;
761 + width: 44px; height: 44px; margin-left: auto;
762 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
763 + background: var(--surface); cursor: pointer; padding: 0;
764 + flex-direction: column; align-items: center; justify-content: center; gap: 5px;
765 + box-shadow: 3px 3px 0 rgba(20, 35, 26, 0.15);
766 + transition: box-shadow 0.15s ease, transform 0.15s ease;
767 +}
768 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,35,26,0.15); }
769 +.menu-btn span {
770 + display: block; width: 18px; height: 2px; background: var(--ink);
771 + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease;
772 +}
773 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
774 +.menu-btn.open span:nth-child(2) { opacity: 0; }
775 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
776 +
777 +.mobile-menu {
778 + display: none; position: absolute; left: 0; right: 0; top: 100%;
779 + background: var(--paper); border-bottom: 2px solid var(--ink);
780 + box-shadow: 0 18px 30px rgba(20, 35, 26, 0.18);
781 + padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
782 + max-height: 0; overflow: hidden; opacity: 0;
783 + transition: max-height 0.3s ease, opacity 0.22s ease;
784 +}
785 +.mobile-menu.open { max-height: 480px; opacity: 1; }
786 +.mm-link {
787 + display: flex; align-items: center; gap: 12px;
788 + padding: 15px 10px; min-height: 52px;
789 + font-family: var(--font-display); font-weight: 700; font-size: 19px;
790 + letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line);
791 + opacity: 0; transform: translateY(-8px);
792 + transition: opacity 0.25s ease, transform 0.25s ease;
793 +}
794 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
795 +.mm-link.active { color: var(--green); }
796 +.mm-link.active .mm-arrow { opacity: 1; }
797 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
798 +.mm-arrow { margin-left: auto; opacity: 0.25; transition: opacity 0.15s ease; }
799 +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
800 +.mm-foot {
801 + padding: 12px 10px 4px; font-family: var(--font-mono);
802 + font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em;
803 +}
804 +.mm-backdrop {
805 + position: fixed; inset: 0; z-index: 40;
806 + background: rgba(20, 35, 26, 0.35); backdrop-filter: blur(2px);
807 +}
808 +@media (max-width: 760px) {
809 + .menu-btn { display: flex; }
810 + .nav { display: none; }
811 + .mobile-menu { display: block; }
812 +}
813 +
814 +/* --- Bandeau de consentement (témoins) ------------------------------------- */
815 +.cookie-banner {
816 + position: fixed; left: 12px; right: 12px;
817 + bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 80;
818 + animation: cookie-in 0.35s ease;
819 +}
820 +@keyframes cookie-in { from { transform: translateY(24px); opacity: 0; } }
821 +.cookie-inner {
822 + max-width: 720px; margin: 0 auto; background: var(--surface);
823 + border: 2px solid var(--ink); border-radius: var(--r-card);
824 + box-shadow: var(--shadow-off); padding: 16px 18px;
825 +}
826 +.cookie-text { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
827 +.cookie-text b { color: var(--ink); }
828 +.cookie-text a { text-decoration: underline; text-underline-offset: 2px; }
829 +.cookie-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
830 +.cookie-opt {
831 + display: flex; align-items: flex-start; gap: 10px; font-size: 13px;
832 + color: var(--ink-2); cursor: pointer;
833 +}
834 +.cookie-opt input { margin-top: 2px; accent-color: var(--green); width: 16px; height: 16px; }
835 +.cookie-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; justify-content: flex-end; }
836 +@media (max-width: 640px) {
837 + .cookie-actions { justify-content: stretch; }
838 + .cookie-actions .btn { flex: 1; text-align: center; justify-content: center; }
839 +}
840 +
841 +/* --- Page confidentialité + lien pied de page ------------------------------- */
842 +.legal { padding: 48px 0 70px; max-width: 760px; }
843 +.legal h1 { font-size: clamp(30px, 5vw, 46px); margin: 12px 0 8px; letter-spacing: -0.03em; }
844 +.legal section { margin-top: 26px; }
845 +.legal h3 { font-size: 18px; margin-bottom: 8px; }
846 +.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; }
847 +.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
848 +.flink {
849 + border: 0; background: none; padding: 0; cursor: pointer;
850 + color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px;
851 +}
852 +
853 +/* --- CTA sticky mobile (fiche produit) -------------------------------------- */
854 +.cta-sticky {
855 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 45;
856 + display: flex; align-items: center; gap: 12px;
857 + background: rgba(250, 246, 238, 0.94); backdrop-filter: blur(12px);
858 + border-top: 2px solid var(--ink);
859 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
860 +}
861 +.cta-sticky .cta { flex: 1; margin: 0; text-align: center; }
862 +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
863 +.cta-sticky-prix small { font-family: var(--font-mono); font-weight: 500; font-size: 10px; color: var(--ink-3); }
864 +@media (min-width: 900px) { .cta-sticky { display: none; } }
865 +@media (max-width: 899px) {
866 + .cta-desktop { display: none; }
867 + .detail { padding-bottom: 84px; } /* place pour le CTA sticky */
868 +}
869 +.f-foot { margin-top: 26px; }
870 +
871 +/* ================= Logos de bannières ================= */
872 +.src-logo { display: inline-flex; align-items: center; gap: 8px; min-width: 0; vertical-align: middle; }
873 +.src-logo img {
874 + border-radius: 6px; background: #fff; border: 1px solid var(--line);
875 + object-fit: contain; padding: 2px; flex: none;
876 +}
877 +.src-logo-name {
878 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
879 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--green-deep);
880 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
881 +}
882 +.card-foot .src-logo { max-width: 60%; }
883 +.cta .src-logo img { border-color: rgba(255, 255, 255, 0.6); }
884 +.cta:hover .src-logo img { border-color: var(--ink); }
885 +.src-cell { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
886 +.src-cell .src-logo img { padding: 3px; }
887 +
888 +/* ================= Stats — panier comparatif ================= */
889 +.basket-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
890 +.basket-table { width: 100%; border-collapse: collapse; min-width: 680px; font-size: 13px; }
891 +.basket-table thead th {
892 + padding: 10px 8px; border-bottom: 2px solid var(--ink); vertical-align: bottom;
893 + font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
894 + letter-spacing: 0.06em; color: var(--ink-3); text-align: center;
895 +}
896 +.basket-table thead th:first-child { text-align: left; }
897 +.basket-th { display: flex; flex-direction: column; align-items: center; gap: 6px; }
898 +.basket-th .src-logo img { padding: 3px; }
899 +.basket-table tbody th {
900 + text-align: left; padding: 8px 10px; font-weight: 600; font-size: 13.5px;
901 + border-bottom: 1px solid var(--line); white-space: nowrap;
902 +}
903 +.basket-table td {
904 + padding: 8px; border-bottom: 1px solid var(--line); text-align: center;
905 + font-family: var(--font-mono); font-size: 12.5px; font-variant-numeric: tabular-nums;
906 + white-space: nowrap;
907 +}
908 +.basket-table td.cell-best { color: var(--green-deep); font-weight: 700; }
909 +.basket-table .best-col { background: rgba(46, 158, 99, 0.13); }
910 +.basket-table tfoot th, .basket-table tfoot td {
911 + border-top: 2px solid var(--ink); border-bottom: none;
912 + font-family: var(--font-display); font-weight: 700; font-size: 14.5px; padding: 10px 8px;
913 +}
914 +.basket-table tfoot th { text-align: left; font-size: 12px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; }
915 +.basket-table tfoot small { display: block; font-family: var(--font-mono); font-weight: 500; font-size: 9.5px; color: var(--ink-3); margin-top: 2px; }
916 +.basket-callout {
917 + display: inline-flex; align-items: center; gap: 9px; margin: 16px 0 2px;
918 + border: 1.5px solid var(--green); background: var(--lime-soft); color: var(--green-deep);
919 + border-radius: 999px; padding: 9px 18px; font-weight: 600; font-size: 13.5px;
920 + box-shadow: 3px 3px 0 rgba(20, 35, 26, 0.12);
921 +}
922 +
923 +/* ================= Stats — tableau bannières & matrice ================= */
924 +.stat-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
925 +.stat-table { width: 100%; border-collapse: collapse; min-width: 640px; font-size: 13px; }
926 +.stat-table th {
927 + padding: 9px 10px; border-bottom: 2px solid var(--ink); text-align: right;
928 + font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
929 + letter-spacing: 0.06em; color: var(--ink-3); white-space: nowrap;
930 +}
931 +.stat-table th:first-child { text-align: left; }
932 +.stat-table th.sortable { cursor: pointer; user-select: none; }
933 +.stat-table th.sortable:hover { color: var(--ink); }
934 +.stat-table th.sorted { color: var(--green-deep); }
935 +.stat-table td {
936 + padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: right;
937 + font-family: var(--font-mono); font-size: 12.5px; font-variant-numeric: tabular-nums;
938 + white-space: nowrap;
939 +}
940 +.stat-table td:first-child { text-align: left; }
941 +.stat-table tr:hover td { background: var(--surface-2); }
942 +.stat-table a { display: inline-flex; }
943 +.stat-table .pct-sale { color: var(--tomato-deep); font-weight: 700; }
944 +.mini-track {
945 + display: inline-block; width: 74px; height: 8px; margin-left: 10px;
946 + background: var(--surface-2); border-radius: 4px; overflow: hidden; vertical-align: middle;
947 +}
948 +.mini-fill { display: block; height: 100%; background: var(--green); border-radius: 4px; min-width: 2px; }
949 +
950 +.matrix-table td { min-width: 74px; text-align: center; }
951 +.matrix-table td:first-child { text-align: left; font-family: var(--font-body); font-weight: 600; font-size: 13px; white-space: nowrap; }
952 +.matrix-table th { text-align: center; }
953 +.matrix-table th:first-child { text-align: left; }
954 +.matrix-table td.cell-best { color: var(--green-deep); font-weight: 700; }
955 +.matrix-table tr:hover td { background: inherit; }
956 +.matrix-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.05em; }
957 +.matrix-legend .sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; border: 1px solid var(--line); vertical-align: -2px; margin-right: 6px; }
958 +
959 +/* ================= Stats — baisses de prix ================= */
960 +.drops-list { list-style: none; margin: 0; padding: 0; }
961 +.drops-list li { border-bottom: 1px dashed var(--line); }
962 +.drops-list li:last-child { border-bottom: none; }
963 +.drops-list a {
964 + display: flex; align-items: center; gap: 12px; padding: 10px 6px;
965 + border-radius: var(--r-ctl); transition: background 0.12s ease;
966 +}
967 +.drops-list a:hover { background: var(--surface-2); }
968 +.drop-name {
969 + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
970 + font-weight: 600; font-size: 13.5px;
971 +}
972 +.drop-prices { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); white-space: nowrap; }
973 +.drop-prices s { text-decoration-color: var(--tomato); color: var(--ink-3); }
974 +.drop-prices b { color: var(--ink); }
975 +.drop-pct {
976 + font-family: var(--font-mono); font-weight: 700; font-size: 11px; white-space: nowrap;
977 + background: var(--tomato-soft); border: 1px solid var(--tomato); color: var(--tomato-deep);
978 + border-radius: 999px; padding: 2px 8px; flex: none;
979 +}
980 +@media (max-width: 640px) {
981 + .drop-prices s { display: none; }
982 + .drops-list a { gap: 9px; }
983 +}
984 +
985 +/* ancres de navigation rapide */
986 +html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
987 +
988 +/* ---- bouton rapport PDF (page Stats) ---- */
989 +.stats-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
990 +.btn-pdf { text-decoration: none; white-space: nowrap; }
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
added frontend/tsconfig.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2020",
4 + "useDefineForClassFields": true,
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 + "module": "ESNext",
7 + "skipLibCheck": true,
8 + "moduleResolution": "bundler",
9 + "allowImportingTsExtensions": true,
10 + "resolveJsonModule": true,
11 + "isolatedModules": true,
12 + "noEmit": true,
13 + "jsx": "react-jsx",
14 + "strict": true,
15 + "noUnusedLocals": false,
16 + "noUnusedParameters": false,
17 + "noFallthroughCasesInSwitch": true
18 + },
19 + "include": ["src"]
20 +}
added frontend/vite.config.ts +15 −0
@@ -0,0 +1,15 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5 +// -----------------------------------------------------------------------------
6 +import { defineConfig } from "vite";
7 +import react from "@vitejs/plugin-react";
8 +
9 +export default defineConfig({
10 + plugins: [react()],
11 + server: {
12 + proxy: { "/api": "http://localhost:8080" },
13 + },
14 + build: { outDir: "dist" },
15 +});
added requirements.txt +8 −0
@@ -0,0 +1,8 @@
1 +# Food-Ka — dépendances backend
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +fastapi>=0.110
4 +uvicorn>=0.29
5 +requests>=2.31
6 +beautifulsoup4>=4.12
7 +reportlab>=4.0
8 +pillow>=10.0
added run.py +47 −0
@@ -0,0 +1,47 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# run.py : point d'entrée — `sync`, `watch`, `serve`
6 +# -----------------------------------------------------------------------------
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise les produits
9 + python run.py watch [minutes] # synchronise en boucle (défaut 360 min)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 +"""
12 +from __future__ import annotations
13 +
14 +import os
15 +import sys
16 +from pathlib import Path
17 +
18 +# Charger .env (SCRAPFLY_API_KEY, FIRECRAWL_API_KEY…) sans dépendance externe
19 +_env = Path(__file__).parent / ".env"
20 +if _env.exists():
21 + for line in _env.read_text().splitlines():
22 + line = line.strip()
23 + if line and not line.startswith("#") and "=" in line:
24 + k, _, v = line.partition("=")
25 + os.environ.setdefault(k.strip(), v.strip())
26 +
27 +
28 +def main() -> None:
29 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
30 + if cmd == "sync":
31 + from foodka import ingest
32 + ingest.run(sys.argv[2:] or None)
33 + elif cmd == "watch":
34 + from foodka import ingest
35 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 360
36 + ingest.watch(minutes * 60)
37 + elif cmd == "serve":
38 + import uvicorn
39 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
40 + uvicorn.run("foodka.web:app", host="0.0.0.0", port=port)
41 + else:
42 + print(__doc__)
43 + sys.exit(1)
44 +
45 +
46 +if __name__ == "__main__":
47 + main()
added tests/test_normalize.py +66 −0
@@ -0,0 +1,66 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — tests de la couche de normalisation
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# -----------------------------------------------------------------------------
5 +from foodka.normalize import (
6 + normalize_category,
7 + parse_price,
8 + parse_size,
9 + unit_price,
10 +)
11 +from foodka.schema import Product
12 +
13 +
14 +def test_parse_price():
15 + assert parse_price("3,49 $") == 3.49
16 + assert parse_price("$3.49") == 3.49
17 + assert parse_price("2 / 5,00 $") == 2.5
18 + assert parse_price("3 pour 12,00 $") == 4.0
19 + assert parse_price("99 ¢") == 0.99
20 + assert parse_price("1 249,99 $") == 1249.99
21 + assert parse_price(4.29) == 4.29
22 + assert parse_price("prix en magasin") is None
23 + assert parse_price(None) is None
24 +
25 +
26 +def test_parse_size():
27 + assert parse_size("450 g") == (450.0, "g")
28 + assert parse_size("2 L") == (2000.0, "ml")
29 + assert parse_size("1,5 L") == (1500.0, "ml")
30 + assert parse_size("12 x 355 ml") == (4260.0, "ml")
31 + assert parse_size("6 un") == (6.0, "un")
32 + assert parse_size("1 lb") == (453.592, "g")
33 + assert parse_size("") is None
34 +
35 +
36 +def test_unit_price():
37 + assert unit_price(3.5, "500 g") == (0.7, "100 g")
38 + assert unit_price(6.0, "2 L") == (0.3, "100 ml")
39 + assert unit_price(12.0, "6 un") == (2.0, "un")
40 + assert unit_price(None, "500 g") is None
41 +
42 +
43 +def test_normalize_category():
44 + assert normalize_category("Fruits et légumes frais") == "Fruits et légumes"
45 + assert normalize_category("Dairy & Eggs") == "Produits laitiers et œufs"
46 + assert normalize_category("Boissons gazeuses") == "Boissons"
47 + assert normalize_category("Surgelés") == "Surgelés"
48 + assert normalize_category("Mystère") == "Autres"
49 +
50 +
51 +def test_product_finalize_sale():
52 + p = Product(source="t", external_id="1", url="u", name=" Beurre ",
53 + price_label="4,99 $", size_label="454 g",
54 + regular_price=6.49).finalize()
55 + assert p.price == 4.99
56 + assert p.on_sale is True
57 + assert p.unit_price is not None
58 + assert p.category == "Autres"
59 +
60 +
61 +def test_product_finalize_bogus_regular():
62 + # prix régulier <= prix courant : pas un solde, on le laisse tomber
63 + p = Product(source="t", external_id="1", url="u", name="x",
64 + price=5.0, regular_price=5.0).finalize()
65 + assert p.regular_price is None
66 + assert p.on_sale is False
67