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