Resto·Ka v1 — agrégateur des restaurants du Québec (menus & prix réels)
- Pipeline complet calqué sur Lou·Ka : connecteurs auto-découverts, schéma Restaurant + menu imbriqué (sections → items → prix → options), validation stricte (price_context/price_source obligatoires), menus conservés PAR contexte, historique des prix par item, alertes de dérive, délai de grâce. - Connecteur templatisé UEAT (API GraphQL, prix takeout réels) : 19 marques, 542 restos, 61 330 plats (98 % avec photo, 69 % avec options), 14/17 régions. - Géocodage Nominatim + Adresses Québec (96 %), régions ville→région, déduplication inter-sources (succursales jamais fusionnées). - Frontend Vite/React 18/TS — design « éditorial sharp » Groupe KA, recherche par resto ET par plat (groupée par marque), pagination numérotée, images, options en pastilles avec suppléments, badges de contexte de prix. - 41 tests (fixtures UEAT réelles hors ligne). Déployé : www.resto-ka.com (M3U96b:8115, PM2 + ngrok + sync hebdo). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
44 changed files +7,899 −0
added
.gitignore
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: .gitignore | |
| 4 | +# Desc: Resto·Ka — fichiers exclus du dépôt (secrets, base, builds). | |
| 5 | +# ============================================================================== | |
| 6 | +.env | |
| 7 | +__pycache__/ | |
| 8 | +*.pyc | |
| 9 | +.venv/ | |
| 10 | +.pytest_cache/ | |
| 11 | +data/*.db | |
| 12 | +data/*.db-journal | |
| 13 | +frontend/node_modules/ | |
| 14 | +frontend/dist/ | |
| 15 | +frontend/tsconfig.tsbuildinfo | |
| 16 | +.DS_Store | |
added
CLAUDE.md
+572 −0
@@ -0,0 +1,572 @@ | ||
| 1 | +<!-- | |
| 2 | +================================================================================ | |
| 3 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | + File: CLAUDE.md | |
| 5 | + Project: Resto·Ka — Agrégateur québécois de tous les restaurants (menus + prix) | |
| 6 | +================================================================================ | |
| 7 | +--> | |
| 8 | + | |
| 9 | +# CLAUDE.md — Resto·Ka | |
| 10 | + | |
| 11 | +> **Agrégateur exhaustif de tous les restaurants du Québec, avec leurs menus | |
| 12 | +> complets et leurs prix.** | |
| 13 | +> Chaque resto, chaque plat, chaque prix — dans les 17 régions, au même endroit, | |
| 14 | +> comparable et cherchable. | |
| 15 | + | |
| 16 | +Ce fichier est la source de vérité pour tout agent Claude (ou humain) qui travaille | |
| 17 | +sur ce dépôt. Lis-le **en entier** avant d'écrire une seule ligne de code. | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## 0. À LIRE EN PREMIER — Règles non négociables | |
| 22 | + | |
| 23 | +1. **En-tête d'auteur obligatoire.** **Chaque fichier** du dépôt (code, config, | |
| 24 | + doc, script) doit commencer par un en-tête mentionnant l'auteur : | |
| 25 | + `Simon-Pierre Boucher <contact@spboucher.ai>`. Voir la **section 17** pour le | |
| 26 | + format exact selon le type de fichier. Aucun fichier ne doit être commité sans | |
| 27 | + cet en-tête. | |
| 28 | + | |
| 29 | +2. **Inspire-toi du dépôt Lou·Ka.** Avant de concevoir ou d'écrire **le moindre | |
| 30 | + connecteur**, va lire le **dépôt de Lou·Ka** (le site jumeau, agrégateur de | |
| 31 | + logements locatifs). Ses connecteurs sont le patron de référence : structure | |
| 32 | + des modules, extraction (Firecrawl / Scrapfly), normalisation, déduplication, | |
| 33 | + gestion des mises à jour, planification des tâches. **Ne réinvente rien** : | |
| 34 | + reprends les mêmes conventions, la même architecture de connecteur, les mêmes | |
| 35 | + utilitaires partagés. Resto·Ka doit être cohérent avec l'écosystème `·Ka` | |
| 36 | + (Lou·Ka, Immo·Ka, Auto·Ka, Food·Ka, Fabri·Ka, Sorti·Ka). Voir **section 3**. | |
| 37 | + | |
| 38 | +3. **Travail de moine.** L'agrégation de menus est un travail minutieux, source par | |
| 39 | + source, plat par plat, sans rien précipiter. Un menu mal parsé (mauvais prix, | |
| 40 | + plat manquant, option ignorée) détruit la confiance dans toute la base. Mieux | |
| 41 | + vaut un connecteur robuste de plus que dix fragiles. | |
| 42 | + | |
| 43 | +4. **Vérifie l'existant avant d'ajouter.** Avant d'intégrer un resto ou une source, | |
| 44 | + confirme qu'il/elle n'est **pas déjà couvert(e)** (voir le registre des sources | |
| 45 | + et l'index des restaurants, sections 9 et 12). Ne duplique jamais un connecteur | |
| 46 | + ni une fiche resto. Un restaurant = une fiche canonique, quelles que soient les | |
| 47 | + sources qui l'alimentent. | |
| 48 | + | |
| 49 | +5. **Chaque source a son connecteur sur mesure**, mais les sources qui partagent | |
| 50 | + une **même plateforme** (livraison, commande en ligne, POS, réservation) | |
| 51 | + doivent être couvertes par un **connecteur générique templatisé** réutilisable. | |
| 52 | + Voir **section 8**. | |
| 53 | + | |
| 54 | +6. **La provenance du prix est sacrée.** Un prix de livraison (Uber Eats, DoorDash, | |
| 55 | + Skip) est **majoré de 25-30 %** par rapport à la salle à manger. Ne **jamais** | |
| 56 | + présenter un prix sans son contexte (`price_context`). Voir **sections 5 et 11**. | |
| 57 | + | |
| 58 | +--- | |
| 59 | + | |
| 60 | +## 1. Mission | |
| 61 | + | |
| 62 | +Rendre visible, en un seul endroit, **tous les restaurants du Québec avec leurs | |
| 63 | +menus et leurs prix réels**, dans les 17 régions — pour que quiconque puisse | |
| 64 | +répondre à *« qu'est-ce que je mange, où, et combien ça coûte ? »* sans ouvrir | |
| 65 | +quinze applis. | |
| 66 | + | |
| 67 | +### Ce qui nous distingue | |
| 68 | + | |
| 69 | +Les plateformes existantes sont soit des **apps de livraison** (Uber Eats, Skip, | |
| 70 | +DoorDash — menus complets mais prix majorés, périmètre limité aux restos inscrits | |
| 71 | +et payants), soit des **annuaires** (Google, Yelp, TripAdvisor — avis et coordonnées, | |
| 72 | +mais **pas de menus structurés ni de prix comparables**), soit des **médias food** | |
| 73 | +(Tastet, RestoMontréal — éditorial, pas exhaustif). | |
| 74 | + | |
| 75 | +Personne n'offre **le menu complet + les prix de tous les restos, comparables entre | |
| 76 | +eux, partout au Québec.** C'est le créneau de Resto·Ka. | |
| 77 | + | |
| 78 | +--- | |
| 79 | + | |
| 80 | +## 2. Périmètre — TOUS les établissements où l'on mange | |
| 81 | + | |
| 82 | +Périmètre maximaliste. Si on peut y commander à manger, ça doit finir dans Resto·Ka : | |
| 83 | + | |
| 84 | +- **Restaurants** (tous services : gastronomique, décontracté, familial). | |
| 85 | +- **Fast-food & chaînes** (menus standardisés, multi-succursales). | |
| 86 | +- **Cafés & salons de thé** (avec offre alimentaire). | |
| 87 | +- **Bars & pubs** avec cuisine. | |
| 88 | +- **Comptoirs pour emporter, cantines, casse-croûtes, food trucks.** | |
| 89 | +- **Pizzérias, sushis, poulet, burgers, shawarma**, etc. | |
| 90 | +- **Boulangeries-pâtisseries et traiteurs** avec menu et prix. | |
| 91 | +- **Microbrasseries et établissements** avec menu de bouffe. | |
| 92 | +- **Restaurants d'hôtel**, cuisines fantômes (ghost kitchens). | |
| 93 | + | |
| 94 | +> Règle d'or : en cas de doute sur l'inclusion, **on inclut** et on catégorise par | |
| 95 | +> type d'établissement. Le périmètre est large par conception. | |
| 96 | + | |
| 97 | +--- | |
| 98 | + | |
| 99 | +## 3. Dépôts de référence — étudier Lou·Ka AVANT de coder | |
| 100 | + | |
| 101 | +Resto·Ka fait partie de la famille `·Ka`. **Le dépôt de Lou·Ka est la référence | |
| 102 | +canonique pour l'architecture des connecteurs.** Avant tout développement, l'agent | |
| 103 | +Claude doit : | |
| 104 | + | |
| 105 | +1. **Ouvrir le dépôt de Lou·Ka** et localiser le dossier des connecteurs | |
| 106 | + (typiquement `connectors/`, `scrapers/` ou `sources/`). | |
| 107 | +2. **Lire 2 ou 3 connecteurs complets** de bout en bout pour comprendre : | |
| 108 | + - la structure d'un module connecteur (interface commune, méthodes attendues) ; | |
| 109 | + - l'**extraction** (Firecrawl / Scrapfly, endpoints JSON, sitemaps) ; | |
| 110 | + - la **normalisation** vers le schéma commun ; | |
| 111 | + - la **déduplication** (annonces publiées sur plusieurs plateformes) ; | |
| 112 | + - la gestion des **mises à jour** (nouveautés, changements, retraits) ; | |
| 113 | + - la **planification** (cadence, files, retries, backoff), les **erreurs** et le | |
| 114 | + **logging**. | |
| 115 | +3. **Réutiliser** les utilitaires partagés de Lou·Ka (client Firecrawl/Scrapfly, | |
| 116 | + helpers de normalisation, déduplication, géocodage) plutôt que d'en réécrire. | |
| 117 | +4. **Calquer** les conventions de nommage, la structure des dossiers et le style | |
| 118 | + de code de Lou·Ka. Un dev qui connaît Lou·Ka doit se retrouver immédiatement. | |
| 119 | + | |
| 120 | +> Adaptation propre à Resto·Ka : là où Lou·Ka normalise une **annonce**, Resto·Ka | |
| 121 | +> normalise un **restaurant + un menu imbriqué (catégories → plats → prix → | |
| 122 | +> options)**. Le schéma est plus profond ; le reste du patron s'applique tel quel. | |
| 123 | + | |
| 124 | +> Si tu ne trouves pas le dépôt Lou·Ka ou l'accès aux connecteurs, **arrête-toi et | |
| 125 | +> demande** plutôt que d'inventer une architecture divergente. | |
| 126 | + | |
| 127 | +Écosystème complet, pour contexte : | |
| 128 | + | |
| 129 | +| Site | Domaine | Agrège | | |
| 130 | +|-------------|------------------|-------------------------------------------| | |
| 131 | +| Lou·Ka | lou-ka.com | Logements à louer | | |
| 132 | +| Immo·Ka | immo-ka.com | Propriétés à vendre | | |
| 133 | +| Auto·Ka | auto-ka.com | Véhicules usagés | | |
| 134 | +| Food·Ka | food-ka.com | Prix d'épicerie & circulaires | | |
| 135 | +| Fabri·Ka | fabri-ka.com | Produits québécois | | |
| 136 | +| Sorti·Ka | sorti-ka.com | Sorties & événements | | |
| 137 | +| **Resto·Ka**| **resto-ka.com** | **Restaurants, menus & prix (ce dépôt)** | | |
| 138 | + | |
| 139 | +--- | |
| 140 | + | |
| 141 | +## 4. Architecture | |
| 142 | + | |
| 143 | +Pipeline standard de la famille `·Ka`, adapté aux restaurants et menus : | |
| 144 | + | |
| 145 | +``` | |
| 146 | + ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ ┌──────────┐ | |
| 147 | + │ CONNECTEUR │──▶│ EXTRACTION │──▶│ NORMALISATION │──▶│ DÉDUPLICATION │──▶│ STOCKAGE │ | |
| 148 | + │ (par source)│ │ FC / Scrapfly│ │ resto + menu │ │ identité resto│ │ (DB) │ | |
| 149 | + │ │ │ + parse PDF │ │ (schéma commun)│ │ + items │ │ │ | |
| 150 | + └─────────────┘ └──────────────┘ └────────────────┘ └──────────────┘ └────┬─────┘ | |
| 151 | + │ | |
| 152 | + ┌──────────▼──────────┐ | |
| 153 | + │ API / RECHERCHE / │ | |
| 154 | + │ UI (menus & prix) │ | |
| 155 | + └─────────────────────┘ | |
| 156 | +``` | |
| 157 | + | |
| 158 | +Composants : | |
| 159 | + | |
| 160 | +- **Connecteurs** : un module par source (ou par plateforme), interface commune. | |
| 161 | +- **Orchestrateur** : planifie et exécute (cadence, retries, backoff). | |
| 162 | +- **Extraction** : Firecrawl & Scrapfly + clients d'API + **parsing de menus PDF / | |
| 163 | + images** (OCR) pour les menus non structurés. | |
| 164 | +- **Normalisation** : mappe le brut vers le **schéma Restaurant + Menu** (section 5). | |
| 165 | +- **Déduplication** : fusionne les restos et les items en double inter-sources. | |
| 166 | +- **Stockage** : base restos + menus + **historique des prix** (suivi des hausses). | |
| 167 | +- **API / UI** : recherche géolocalisée, filtres (cuisine, prix, diète), comparaison. | |
| 168 | + | |
| 169 | +--- | |
| 170 | + | |
| 171 | +## 5. Modèle de données — schéma Restaurant + Menu | |
| 172 | + | |
| 173 | +### 5.1 Restaurant | |
| 174 | + | |
| 175 | +```jsonc | |
| 176 | +{ | |
| 177 | + "id": "string", // * id interne canonique (post-déduplication) | |
| 178 | + "source_ids": ["ubereats:abc"], // * id(s) d'origine par source (préfixés) | |
| 179 | + "name": "string", // * nom normalisé | |
| 180 | + "chain": "string|null", // marque/chaîne si applicable | |
| 181 | + "cuisines": ["italien", "pizza"], // * taxonomie cuisine (section 6) | |
| 182 | + "establishment_type": "restaurant",// restaurant|fast-food|cafe|bar|food-truck|traiteur... | |
| 183 | + "price_range": "$$", // $ à $$$$ (estimé à partir des prix) | |
| 184 | + "address": "string", // * | |
| 185 | + "city": "string", // * | |
| 186 | + "region": "string", // * une des 17 régions (section 7) | |
| 187 | + "postal_code": "string", | |
| 188 | + "lat": 0.0, // * géolocalisation | |
| 189 | + "lng": 0.0, // * | |
| 190 | + "phone": "string", | |
| 191 | + "website": "string", | |
| 192 | + "hours": { /* horaires par jour */ }, | |
| 193 | + "services": ["salle", "emporter", "livraison"], // dine-in|takeout|delivery | |
| 194 | + "dietary_options": ["vegan", "sans-gluten", "halal"], | |
| 195 | + "languages": ["fr", "en"], | |
| 196 | + "menu": { /* voir 5.2 */ }, | |
| 197 | + "status": "open", // open|temporarily_closed|closed | |
| 198 | + "first_seen": "ISO-8601", // * | |
| 199 | + "last_seen": "ISO-8601", // * | |
| 200 | + "updated_at": "ISO-8601" // * | |
| 201 | +} | |
| 202 | +``` | |
| 203 | + | |
| 204 | +### 5.2 Menu (imbriqué) | |
| 205 | + | |
| 206 | +```jsonc | |
| 207 | +"menu": { | |
| 208 | + "currency": "CAD", | |
| 209 | + "price_context": "delivery", // * dine-in | takeout | delivery (voir §6) | |
| 210 | + "price_source": "ubereats", // * source du prix (traçabilité) | |
| 211 | + "captured_at": "ISO-8601", // * quand ce menu/prix a été capté | |
| 212 | + "sections": [ | |
| 213 | + { | |
| 214 | + "name": "Pizzas", | |
| 215 | + "items": [ | |
| 216 | + { | |
| 217 | + "id": "string", | |
| 218 | + "name": "Margherita", // * | |
| 219 | + "description": "string", | |
| 220 | + "price": 16.50, // * prix de l'item (dans price_context) | |
| 221 | + "currency": "CAD", | |
| 222 | + "tags": ["vegetarien"], | |
| 223 | + "options": [ // modificateurs / variantes | |
| 224 | + { | |
| 225 | + "group": "Format", | |
| 226 | + "required": true, | |
| 227 | + "choices": [ | |
| 228 | + { "name": "10 pouces", "price_delta": 0.00 }, | |
| 229 | + { "name": "14 pouces", "price_delta": 6.00 } | |
| 230 | + ] | |
| 231 | + } | |
| 232 | + ] | |
| 233 | + } | |
| 234 | + ] | |
| 235 | + } | |
| 236 | + ] | |
| 237 | +} | |
| 238 | +``` | |
| 239 | + | |
| 240 | +Règles : | |
| 241 | +- `price_context` et `price_source` sont **obligatoires** : un prix sans contexte | |
| 242 | + est inutilisable (livraison ≠ salle). | |
| 243 | +- `lat`/`lng` obligatoires → **géocoder** l'adresse si absente. | |
| 244 | +- Capturer les **options/modificateurs** (formats, suppléments, choix) : ils | |
| 245 | + changent le prix réel. | |
| 246 | +- Conserver la **devise** partout (CAD). Au QC, TPS/TVQ s'ajoutent ; ne pas les | |
| 247 | + inclure dans `price` sauf si la source le fait, et alors le **documenter**. | |
| 248 | + | |
| 249 | +--- | |
| 250 | + | |
| 251 | +## 6. Taxonomies | |
| 252 | + | |
| 253 | +### 6.1 Cuisines (multi-valué) | |
| 254 | +`quebecois` · `francais` · `italien` · `pizza` · `burgers` · `poulet` · | |
| 255 | +`bbq-grillades` · `fruits-de-mer` · `sushi-japonais` · `chinois` · `thai` · | |
| 256 | +`vietnamien` · `coreen` · `indien` · `libanais-moyen-orient` · `mexicain` · | |
| 257 | +`grec` · `mediterraneen` · `dejeuner-brunch` · `cafe-dessert` · `vegetarien-vegan` · | |
| 258 | +`fast-food` · `autre` | |
| 259 | + | |
| 260 | +### 6.2 Contexte de prix (`price_context`) — CRITIQUE | |
| 261 | +- `dine-in` — prix en salle (référence idéale). | |
| 262 | +- `takeout` — prix pour emporter (souvent = salle). | |
| 263 | +- `delivery` — prix sur plateforme de livraison, **majoré de 25-30 %**. | |
| 264 | + | |
| 265 | +> Toujours privilégier `dine-in`/`takeout` (menu du resto lui-même ou sa plateforme | |
| 266 | +> de commande en ligne) plutôt que `delivery` quand les deux existent. Si on n'a que | |
| 267 | +> `delivery`, on l'affiche **clairement étiqueté**. | |
| 268 | + | |
| 269 | +### 6.3 Diètes / tags | |
| 270 | +`vegan` · `vegetarien` · `sans-gluten` · `halal` · `casher` · `sans-noix` · `epice` | |
| 271 | + | |
| 272 | +--- | |
| 273 | + | |
| 274 | +## 7. Régions du Québec (obligatoire pour chaque restaurant) | |
| 275 | + | |
| 276 | +Bas-Saint-Laurent · Saguenay–Lac-Saint-Jean · Capitale-Nationale · Mauricie · | |
| 277 | +Estrie · Montréal · Outaouais · Abitibi-Témiscamingue · Côte-Nord · | |
| 278 | +Nord-du-Québec · Gaspésie–Îles-de-la-Madeleine · Chaudière-Appalaches · Laval · | |
| 279 | +Lanaudière · Laurentides · Montérégie · Centre-du-Québec. | |
| 280 | + | |
| 281 | +Rattacher chaque resto à sa région via la ville/adresse (table de correspondance | |
| 282 | +ville → région, comme dans Lou·Ka). | |
| 283 | + | |
| 284 | +--- | |
| 285 | + | |
| 286 | +## 8. Philosophie des connecteurs | |
| 287 | + | |
| 288 | +- **Un connecteur par source**, taillé sur mesure. | |
| 289 | +- **Connecteurs templatisés par plateforme** : c'est le levier d'échelle nº 1. Une | |
| 290 | + plateforme de commande en ligne ou de livraison couvre des **milliers** de restos | |
| 291 | + avec la même structure → un seul connecteur paramétré les couvre tous (exactement | |
| 292 | + comme les connecteurs par plateforme d'Auto·Ka et Fabri·Ka). | |
| 293 | +- **Deux types de sources à combiner** : | |
| 294 | + 1. **Découverte** (liste des restos + métadonnées) : annuaires, cartes, tourisme. | |
| 295 | + 2. **Menus + prix** (le cœur) : commande en ligne, livraison, sites de restos, PDF. | |
| 296 | +- Interface commune (à calquer sur Lou·Ka) : `fetch()` → `normalize()` → `emit()`. | |
| 297 | + Idempotent et incrémental quand c'est possible. | |
| 298 | +- **Robustesse** : timeouts, retries + backoff, tolérance aux changements mineurs, | |
| 299 | + alerte si le nombre d'items d'un menu chute anormalement (connecteur cassé). | |
| 300 | + | |
| 301 | +--- | |
| 302 | + | |
| 303 | +## 9. Catalogue des sources à connecter (priorisé) | |
| 304 | + | |
| 305 | +> ⚠️ Vérifie le registre des sources et l'index des restos **avant** d'ajouter. | |
| 306 | + | |
| 307 | +### Palier 1 — Plateformes de commande en ligne des restos (MEILLEUR prix + volume) | |
| 308 | + | |
| 309 | +Ces plateformes propulsent les **sites/apps des restos eux-mêmes** → menus complets | |
| 310 | +à des **prix réels (dine-in/takeout, non majorés)**. À templatiser par plateforme : | |
| 311 | + | |
| 312 | +- **UEAT** (foodtech de Québec, marque blanche, **très répandue au QC**). | |
| 313 | +- **Square Online Ordering**, **Toast**, **Clover**, **GloriaFood (Oracle)**, | |
| 314 | + **Flipdish**, **Foodiv**, **ChowNow**, **Bite (DoorDash)**. | |
| 315 | + | |
| 316 | +### Palier 2 — Plateformes de livraison (menus complets, prix MAJORÉS) | |
| 317 | + | |
| 318 | +Menus riches et structurés, mais prix `delivery` (+25-30 %), anti-bot fort et CGU | |
| 319 | +restrictives (voir **section 15** — risque légal réel) : | |
| 320 | + | |
| 321 | +- **Uber Eats**, **SkipTheDishes**, **DoorDash**. | |
| 322 | +- Plateformes québécoises : **RestoLoco** et autres services locaux régionaux. | |
| 323 | + | |
| 324 | +> Politique : utiliser la livraison surtout pour la **découverte** et pour les | |
| 325 | +> restos qu'on ne trouve nulle part ailleurs. Toujours étiqueter `price_context: | |
| 326 | +> delivery`. Préférer une source `dine-in` dès qu'elle existe. | |
| 327 | + | |
| 328 | +### Palier 3 — Sites web des restaurants (menus « maison ») | |
| 329 | + | |
| 330 | +- Menus en **HTML** (parse direct) ou en **PDF / image** (→ parsing PDF + OCR, | |
| 331 | + voir section 10). Détecter la plateforme de commande embarquée (souvent Palier 1). | |
| 332 | + | |
| 333 | +### Palier 4 — Découverte & métadonnées (liste des restos) | |
| 334 | + | |
| 335 | +- **Google Places / Maps**, **Yelp**, **TripAdvisor** (coordonnées, horaires, type | |
| 336 | + de cuisine, fourchette de prix — **rarement les menus** ; respecter les CGU/API). | |
| 337 | +- **Associations touristiques régionales (ATR)**, Tourisme Québec. | |
| 338 | +- Médias food : **Tastet**, **RestoMontréal** et annuaires régionaux (découverte). | |
| 339 | + | |
| 340 | +### Palier 5 — Réservation (métadonnées + parfois menus) | |
| 341 | + | |
| 342 | +- **OpenTable**, **LibroReserve** (Québec), **Resy**, **Zenchef** — horaires, | |
| 343 | + services, parfois menus. | |
| 344 | + | |
| 345 | +### Palier 6 — Chaînes (menus standardisés) | |
| 346 | + | |
| 347 | +- Sites des grandes chaînes : menus et prix souvent uniformes par bannière et | |
| 348 | + parfois par région → un connecteur par chaîne couvre toutes les succursales. | |
| 349 | + | |
| 350 | +--- | |
| 351 | + | |
| 352 | +## 10. Extraction — Firecrawl, Scrapfly & parsing de menus | |
| 353 | + | |
| 354 | +Les connecteurs s'appuient **souvent sur Firecrawl et Scrapfly** (comme tout | |
| 355 | +l'écosystème `·Ka`). Règle de choix : | |
| 356 | + | |
| 357 | +- **API / endpoint JSON de la plateforme** → toujours privilégié (données | |
| 358 | + structurées, stables). Beaucoup de plateformes de commande chargent leur menu | |
| 359 | + via un appel JSON interne : **le trouver et le consommer directement.** | |
| 360 | +- **Firecrawl** → crawl et extraction en volume de sites relativement ouverts | |
| 361 | + (sites de restos, annuaires), extraction structurée (markdown/JSON). | |
| 362 | +- **Scrapfly** → sites protégés, JavaScript lourd, anti-bot, rendu navigateur, | |
| 363 | + rotation d'IP. **Requis** pour les plateformes de livraison (Uber Eats, Skip, | |
| 364 | + DoorDash) qui bloquent agressivement. | |
| 365 | +- **Parsing de menus PDF / images** : de nombreux restos publient un menu en PDF ou | |
| 366 | + en photo. Prévoir extraction de texte PDF + **OCR** + un post-traitement (LLM) qui | |
| 367 | + transforme le texte brut en structure `sections → items → price → options`. | |
| 368 | + Valider systématiquement (un prix mal OCR-isé est pire que pas de prix). | |
| 369 | + | |
| 370 | +Chaque connecteur doit **documenter** en tête de fichier son mode d'extraction et | |
| 371 | +pourquoi. Réutiliser les clients partagés de Lou·Ka. | |
| 372 | + | |
| 373 | +--- | |
| 374 | + | |
| 375 | +## 11. Normalisation | |
| 376 | + | |
| 377 | +- Nettoyer noms de restos, sections et plats (HTML résiduel, majuscules, doublons). | |
| 378 | +- Mapper vers le **schéma Restaurant + Menu** (section 5) intégralement. | |
| 379 | +- **Cuisines** et **type d'établissement** classés dès la normalisation (section 6). | |
| 380 | +- **Géocoder** (lat/lng obligatoires) et **rattacher la région** (section 7). | |
| 381 | +- **Prix** : renseigner **obligatoirement** `price_context` et `price_source` ; | |
| 382 | + capturer les **options/modificateurs**. Ne jamais mélanger des prix de contextes | |
| 383 | + différents dans un même menu. | |
| 384 | +- **Devise** CAD ; documenter le traitement des taxes (TPS/TVQ hors prix au QC). | |
| 385 | +- Conserver les **`source_ids`** préfixés (ex. `ueat:1234`, `ubereats:abcd`). | |
| 386 | + | |
| 387 | +--- | |
| 388 | + | |
| 389 | +## 12. Déduplication — restaurants ET items | |
| 390 | + | |
| 391 | +Un même resto apparaît sur Uber Eats + DoorDash + Skip + son propre site + Google. | |
| 392 | + | |
| 393 | +### 12.1 Identité du restaurant | |
| 394 | +1. **Clé** : nom normalisé + adresse (géoloc arrondie) + téléphone → empreinte. | |
| 395 | +2. **Approché** : noms proches + même adresse → fusion candidate (journaliser). | |
| 396 | +3. **Fusion** : un enregistrement canonique, agréger les `source_ids`, choisir la | |
| 397 | + meilleure source par champ. | |
| 398 | +4. **Multi-succursales / chaînes** : chaque **adresse** est une fiche resto | |
| 399 | + distincte, reliée par `chain`. Ne pas fusionner deux succursales. | |
| 400 | + | |
| 401 | +### 12.2 Menus & prix | |
| 402 | +- Ne pas écraser un menu `dine-in` par un menu `delivery` : **conserver les deux | |
| 403 | + contextes** et présenter le meilleur (dine-in prioritaire), en gardant delivery | |
| 404 | + étiqueté. | |
| 405 | +- Dédupliquer les **items** au sein d'un même contexte (même plat listé deux fois). | |
| 406 | +- **Historiser** les prix : à chaque capture, si le prix d'un item change, garder la | |
| 407 | + série temporelle (utile pour détecter les hausses et pour la crédibilité). | |
| 408 | + | |
| 409 | +S'inspirer de la déduplication de **Lou·Ka** (multi-plateformes) et d'Auto·Ka | |
| 410 | +(identité forte par clé). | |
| 411 | + | |
| 412 | +--- | |
| 413 | + | |
| 414 | +## 13. Fraîcheur des menus & états | |
| 415 | + | |
| 416 | +- **Menus changeants** : prix et plats évoluent (saisonnier, table d'hôte, | |
| 417 | + spéciaux). Rafraîchir régulièrement et **horodater** chaque capture (`captured_at`). | |
| 418 | +- **Fermetures** : resto fermé temporairement / définitivement → `status` mis à jour | |
| 419 | + plutôt que suppression brutale (politique de grâce via `last_seen`, comme Lou·Ka). | |
| 420 | +- **Items retirés** : disparition d'un plat → le marquer inactif, conserver | |
| 421 | + l'historique. | |
| 422 | +- **Spéciaux / table d'hôte** : capturer si structurés, avec leur période de validité. | |
| 423 | + | |
| 424 | +--- | |
| 425 | + | |
| 426 | +## 14. Cadence de rafraîchissement | |
| 427 | + | |
| 428 | +- Grandes plateformes (commande en ligne, livraison, chaînes) : **hebdomadaire** au | |
| 429 | + minimum ; **quotidien** pour les prix sur les gros volumes urbains. | |
| 430 | +- Sites de restos indépendants : **hebdomadaire à mensuel** (menus stables). | |
| 431 | +- Découverte (annuaires) : **mensuel** pour repérer nouveaux restos / fermetures. | |
| 432 | +- Toujours mettre à jour `last_seen` et `captured_at` à chaque passage réussi. | |
| 433 | + | |
| 434 | +--- | |
| 435 | + | |
| 436 | +## 15. Conformité & aspects légaux — À PRENDRE AU SÉRIEUX | |
| 437 | + | |
| 438 | +> C'est le point le plus sensible de ce projet. Les plateformes de livraison | |
| 439 | +> **protègent activement** leurs données (menus/prix) et l'interdisent dans leurs | |
| 440 | +> CGU. Ne pas traiter ça à la légère. | |
| 441 | + | |
| 442 | +- Respecter les **CGU** et `robots.txt` de chaque source. Les plateformes de | |
| 443 | + livraison sont explicitement restrictives : **minimiser**, privilégier d'autres | |
| 444 | + sources pour les mêmes restos, et **demander** en cas de doute avant d'industrialiser. | |
| 445 | +- Privilégier les **sources de première partie** : le **site du resto** et sa | |
| 446 | + **plateforme de commande** (UEAT, etc.) sont la voie la plus légitime pour le | |
| 447 | + menu et le prix réel. | |
| 448 | +- **Rate-limiting** raisonnable, identification honnête, pas de surcharge. | |
| 449 | +- Utiliser les **API officielles** quand elles existent (Google Places, Yelp) et | |
| 450 | + en respecter les quotas et conditions d'affichage/attribution. | |
| 451 | +- Ne republier que ce qui est nécessaire à la découverte (nom, plats, prix, infos | |
| 452 | + pratiques, lien vers la source). **Attribuer** les sources requises. | |
| 453 | +- Consigner par source la base légale/mode d'accès retenu (API vs scraping) en tête | |
| 454 | + de connecteur. | |
| 455 | + | |
| 456 | +--- | |
| 457 | + | |
| 458 | +## 16. Conventions de code | |
| 459 | + | |
| 460 | +- Suivre **les mêmes conventions que Lou·Ka** (langage, structure de dossiers, | |
| 461 | + nommage, linting, formatage). Cohérence > préférences personnelles. | |
| 462 | +- Un connecteur = un module isolé, testable indépendamment. | |
| 463 | +- Secrets (clés Firecrawl/Scrapfly, API Google/Yelp) via variables d'environnement / | |
| 464 | + gestionnaire de secrets. Jamais en clair. | |
| 465 | +- Logging structuré (source, nb de restos, nb d'items, erreurs, durée). | |
| 466 | +- Commits atomiques, messages clairs. | |
| 467 | + | |
| 468 | +--- | |
| 469 | + | |
| 470 | +## 17. En-tête d'auteur — OBLIGATOIRE dans chaque fichier | |
| 471 | + | |
| 472 | +**Tout fichier** du dépôt doit débuter par un en-tête d'auteur. L'auteur est | |
| 473 | +toujours **Simon-Pierre Boucher \<contact@spboucher.ai\>**. | |
| 474 | + | |
| 475 | +**Python / Shell / YAML / TOML / Dockerfile :** | |
| 476 | +```python | |
| 477 | +# ============================================================================== | |
| 478 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 479 | +# File: <nom_du_fichier> | |
| 480 | +# Desc: <brève description> | |
| 481 | +# ============================================================================== | |
| 482 | +``` | |
| 483 | + | |
| 484 | +**JavaScript / TypeScript / Go / Rust / Java / C :** | |
| 485 | +```javascript | |
| 486 | +// ============================================================================== | |
| 487 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 488 | +// File: <nom_du_fichier> | |
| 489 | +// Desc: <brève description> | |
| 490 | +// ============================================================================== | |
| 491 | +``` | |
| 492 | + | |
| 493 | +**Markdown / HTML / XML :** | |
| 494 | +```html | |
| 495 | +<!-- | |
| 496 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 497 | + File: <nom_du_fichier> | |
| 498 | + Desc: <brève description> | |
| 499 | +--> | |
| 500 | +``` | |
| 501 | + | |
| 502 | +**CSS / SCSS :** | |
| 503 | +```css | |
| 504 | +/* | |
| 505 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 506 | + File: <nom_du_fichier> | |
| 507 | +*/ | |
| 508 | +``` | |
| 509 | + | |
| 510 | +Règles : | |
| 511 | +- **Aucun** fichier commité sans cet en-tête. | |
| 512 | +- Pour un connecteur, indiquer dans `Desc:` la **source/plateforme couverte**, le | |
| 513 | + **mode d'extraction** (API / Firecrawl / Scrapfly / PDF-OCR) et le **contexte de | |
| 514 | + prix** produit (dine-in / takeout / delivery). | |
| 515 | +- Vérifier la présence de l'en-tête en revue de code (idéalement hook de pré-commit). | |
| 516 | + | |
| 517 | +--- | |
| 518 | + | |
| 519 | +## 18. Tests & qualité | |
| 520 | + | |
| 521 | +- Chaque connecteur : **tests unitaires** de normalisation sur des fixtures réelles | |
| 522 | + (réponses JSON, HTML, PDF de menus figés). | |
| 523 | +- Tests de **parsing de menus** : vérifier prix, options et sections sur des | |
| 524 | + échantillons connus (les prix sont l'actif le plus critique). | |
| 525 | +- Tests de **déduplication** resto + items multi-sources. | |
| 526 | +- **Validation de schéma** : tout resto/menu émis doit passer la validation avant | |
| 527 | + stockage ; rejeter un menu sans `price_context`/`price_source`. | |
| 528 | +- **Monitoring** : alerte si le nb d'items d'un resto ou le volume d'une source | |
| 529 | + chute anormalement (connecteur probablement cassé). | |
| 530 | + | |
| 531 | +--- | |
| 532 | + | |
| 533 | +## 19. Checklist — ajouter un nouveau connecteur / restaurant | |
| 534 | + | |
| 535 | +1. [ ] Lire les connecteurs de référence dans le **dépôt Lou·Ka** (section 3). | |
| 536 | +2. [ ] Vérifier que la source/le resto **n'est pas déjà couvert** (registres). | |
| 537 | +3. [ ] Identifier la **plateforme** : peut-on templatiser/réutiliser un connecteur ? | |
| 538 | +4. [ ] Choisir le **mode d'extraction** : API > site du resto > Firecrawl > Scrapfly ; | |
| 539 | + PDF/OCR si menu non structuré (section 10). | |
| 540 | +5. [ ] Déterminer le **`price_context`** produit et le documenter. | |
| 541 | +6. [ ] Implémenter `fetch()` → `normalize()` → `emit()` (interface commune). | |
| 542 | +7. [ ] Mapper **tout** le schéma resto + menu, géocoder, région, cuisines, options. | |
| 543 | +8. [ ] Brancher la **déduplication** resto + items (section 12). | |
| 544 | +9. [ ] Gérer **fraîcheur, fermetures, items retirés, historique des prix** (§13). | |
| 545 | +10. [ ] Vérifier la **conformité** CGU/robots et consigner la base d'accès (§15). | |
| 546 | +11. [ ] Ajouter l'**en-tête d'auteur** (section 17). | |
| 547 | +12. [ ] Écrire les **tests** + fixtures, dont le parsing de prix (section 18). | |
| 548 | +13. [ ] Enregistrer la source dans le **registre** avec sa cadence. | |
| 549 | +14. [ ] Valider volume et qualité sur un premier run, journaliser. | |
| 550 | + | |
| 551 | +--- | |
| 552 | + | |
| 553 | +## 20. Roadmap / MVP | |
| 554 | + | |
| 555 | +**MVP** : templatiser **un** connecteur pour la plateforme de commande en ligne la | |
| 556 | +plus répandue au QC (**UEAT**) → menus complets à prix réels (`dine-in`/`takeout`) | |
| 557 | +pour des milliers de restos d'un coup. Ajouter la découverte via Google Places/Yelp | |
| 558 | +pour la liste et les métadonnées, et le parsing des menus PDF des sites de restos. | |
| 559 | +UI de recherche géolocalisée avec filtres (cuisine, prix, diète, service) et | |
| 560 | +affichage clair du **contexte de prix**. | |
| 561 | + | |
| 562 | +**Ensuite** : plateformes de livraison (avec étiquetage strict), chaînes, | |
| 563 | +réservation, régions moins couvertes. Puis historique des prix, comparaison entre | |
| 564 | +restos, et cross-linking avec les autres sites `·Ka`. | |
| 565 | + | |
| 566 | +**Nord stratégique** : être **le seul** à offrir menus **et** prix **comparables** | |
| 567 | +de **tous** les restos du Québec — là où la livraison est chère et partielle, les | |
| 568 | +annuaires sans menus, et les médias food non exhaustifs. | |
| 569 | + | |
| 570 | +--- | |
| 571 | + | |
| 572 | +*Fin de CLAUDE.md — Resto·Ka. Auteur : Simon-Pierre Boucher \<contact@spboucher.ai\>.* | |
added
README.md
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +<!-- | |
| 2 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | + File: README.md | |
| 4 | + Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix). | |
| 5 | +--> | |
| 6 | + | |
| 7 | +# Resto·Ka — [resto-ka.com](https://www.resto-ka.com) | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | +> **Agrégateur exhaustif de tous les restaurants du Québec, avec leurs menus | |
| 23 | +> complets et leurs prix.** Chaque resto, chaque plat, chaque prix — dans les | |
| 24 | +> 17 régions, au même endroit, comparable et cherchable — **par resto ou par | |
| 25 | +> plat**. | |
| 26 | + | |
| 27 | +Plateforme de la famille **·Ka** (Lou·Ka, Immo·Ka, Auto·Ka, Food·Ka, Fabri·Ka, | |
| 28 | +Sorti·Ka). Architecture des connecteurs **calquée sur Lou·Ka**, identité | |
| 29 | +visuelle **« éditorial sharp » du Groupe KA** (Space Grotesk / Inter / | |
| 30 | +JetBrains Mono, papier-encre-lime, bordures encre + ombres décalées). | |
| 31 | +`CLAUDE.md` est la source de vérité du projet. | |
| 32 | + | |
| 33 | +## L'app | |
| 34 | + | |
| 35 | +| Accueil — recherche de restos | Recherche par plat | | |
| 36 | +|---|---| | |
| 37 | +|  |  | | |
| 38 | + | |
| 39 | +| Fiche resto — menu, photos, options & prix | Couverture en direct | | |
| 40 | +|---|---| | |
| 41 | +|  |  | | |
| 42 | + | |
| 43 | +## Ce qui rend Resto·Ka différent | |
| 44 | + | |
| 45 | +- **Prix réels** : captés sur la plateforme de commande des restos eux-mêmes | |
| 46 | + (Palier 1), pas sur les apps de livraison (+25-30 %). | |
| 47 | +- **Un prix n'est JAMAIS présenté sans son contexte** (`dine-in` / `takeout` / | |
| 48 | + `delivery`) — un menu sans `price_context`/`price_source` est rejeté à | |
| 49 | + l'ingestion, et un menu salle n'est jamais écrasé par un menu livraison. | |
| 50 | +- **Recherche par plat** dans les 61 330 items (groupée par marque pour ne pas | |
| 51 | + répéter 76 fois la même poutine de chaîne). | |
| 52 | +- **Options & formats capturés** avec leurs suppléments (« Mini +3,70 $ ») — | |
| 53 | + ils changent le prix réel. | |
| 54 | +- **Historique des prix par item** (`item_price_log`) à chaque changement. | |
| 55 | +- **Alerte de dérive** : chute anormale du volume d'une source ou du nombre | |
| 56 | + d'items d'un menu → retraits suspendus / ancien menu conservé. | |
| 57 | + | |
| 58 | +## Démarrage | |
| 59 | + | |
| 60 | +```bash | |
| 61 | +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt | |
| 62 | +cd frontend && npm install && npm run build && cd .. | |
| 63 | + | |
| 64 | +.venv/bin/python run.py sync # synchronise (+ géocode + dédup) | |
| 65 | +.venv/bin/python run.py serve 8115 # API + frontend sur :8115 | |
| 66 | +.venv/bin/python run.py watch 168 # boucle hebdomadaire | |
| 67 | +.venv/bin/python -m pytest tests/ # 41 tests (fixtures réelles hors ligne) | |
| 68 | +``` | |
| 69 | + | |
| 70 | +## Architecture (patron Lou·Ka) | |
| 71 | + | |
| 72 | +``` | |
| 73 | +restoka/ | |
| 74 | +├── connectors/ # un module par source/plateforme, auto-enregistrés | |
| 75 | +│ ├── base.py # requests direct / Firecrawl / Scrapfly + cache détail | |
| 76 | +│ └── ueat.py # UEAT (templatisé) — API GraphQL, prix TAKEOUT réels | |
| 77 | +├── schema.py # Restaurant + menu imbriqué (sections→items→options) + finalize() | |
| 78 | +├── normalize.py # prix, cuisines, type d'établissement, diètes, $-$$$$ | |
| 79 | +├── regions.py # ville → 17 régions (+ repli postal, + repli lat/lng) | |
| 80 | +├── db.py # SQLite : restos, menus PAR CONTEXTE, historique de prix | |
| 81 | +├── ingest.py # pipeline sync → geocode → dedup (délai de grâce, alertes) | |
| 82 | +├── dedup.py # identité resto inter-sources (succursales jamais fusionnées) | |
| 83 | +├── geocode.py # Nominatim + Adresses Québec, cache persistant | |
| 84 | +└── web.py # FastAPI + frontend React (frontend/dist) | |
| 85 | +frontend/ # Vite + React 18 + TS — design éditorial sharp Groupe KA | |
| 86 | +``` | |
| 87 | + | |
| 88 | +## Sources connectées | |
| 89 | + | |
| 90 | +Registre : `data/sources.json` (à consulter AVANT d'ajouter quoi que ce soit). | |
| 91 | + | |
| 92 | +| Source | Palier | Contexte de prix | Couverture | | |
| 93 | +|--------|--------|------------------|------------| | |
| 94 | +| **UEAT** (commande en ligne, API GraphQL anonyme) | 1 | `takeout` (prix réels) | 19 intégrations : Sushi Shop (141), Poulet Rouge (82), Valentine (76), Normandin (44), Chocolats Favoris (41), Allô mon Coco (34), Thaïzone (26), Scores (25), Chez Ashton (23), Pacini (20), Tutti Frutti, Mandy's, Panda Boba-T, Ryu Sushi, Impérial, Pizza Mia, Station W, Le Super Qualité, Château Frontenac | | |
| 95 | +| St-Hubert | 6 | `takeout` (visé) | à faire — anti-bot Akamai, voir `data/sources.json` | | |
| 96 | + | |
| 97 | +## Déploiement | |
| 98 | + | |
| 99 | +Node **M3U96b** (MacLustr), `~/apps/resto-ka`, PM2 : `resto-ka` (:8115) + | |
| 100 | +`resto-ka-ngrok` (→ https://www.resto-ka.com) + `resto-ka-sync` (hebdo). | |
| 101 | +Registre cross-session : `~/Desktop/cluster-skill/cluster-deployments.json`. | |
| 102 | + | |
| 103 | +--- | |
| 104 | + | |
| 105 | +*Resto·Ka — plateforme du [Groupe KA](https://www.groupe-ka.com). Auteur : | |
| 106 | +Simon-Pierre Boucher \<contact@spboucher.ai\>.* | |
added
data/sources.json
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Resto·Ka — Registre des sources (restaurants du Québec, menus & prix). Auteur : Simon-Pierre Boucher — contact@spboucher.ai. Vérifier ce registre AVANT d'ajouter une source ou un resto (CLAUDE.md §0.4).", | |
| 3 | + "sources": [ | |
| 4 | + { | |
| 5 | + "id": "ueat", | |
| 6 | + "name": "UEAT (commande en ligne)", | |
| 7 | + "url": "https://ueat.io", | |
| 8 | + "platform": "ueat", | |
| 9 | + "connector": "ueat", | |
| 10 | + "tier": 1, | |
| 11 | + "price_context": "takeout", | |
| 12 | + "extraction": "API GraphQL interne (api.ueat.io/graphql), auth anonyme par GUID d'intégration — la même API que le widget officiel de commande", | |
| 13 | + "acces_legal": "Source de première partie : plateforme de commande des restos eux-mêmes, prix réels non majorés (CLAUDE.md §9 Palier 1, §15). API anonyme publique, throttling poli 0,35 s, User-Agent identifiable RestoKaBot.", | |
| 14 | + "cadence": "hebdomadaire", | |
| 15 | + "status": "actif", | |
| 16 | + "integrations": [ | |
| 17 | + { "key": "4268100d-6470-40b9-a755-e11342c91d6d", "label": "Poulet Rouge (chaîne)" }, | |
| 18 | + { "key": "01d1c391-59cf-45a3-b051-7b2f9a623858", "label": "Fairmont Le Château Frontenac (Québec)" }, | |
| 19 | + { "key": "b7282ce3-95cc-43ff-80b5-0cca8a7e4778", "label": "Allô mon Coco (chaîne, déjeuner-brunch)" }, | |
| 20 | + { "key": "b1c2c004-bcff-4eb0-b3b9-d3bc5c8799a3", "label": "Restaurant Impérial (Brossard, Pointe-Claire)" }, | |
| 21 | + { "key": "2f51961a-f378-4812-a31a-a0b45da3f87e", "label": "Pizza Mia (Laval)" }, | |
| 22 | + { "key": "1b432c57-98d6-4681-a560-d1b7bfafdd6f", "label": "Station W (Montréal, café)" }, | |
| 23 | + { "key": "ff376cab-30e2-4cce-9999-ed755583c7f5", "label": "Chocolats Favoris (chaîne, dessert)" }, | |
| 24 | + { "key": "c5548a05-1776-49ac-9c9f-2ac8c998eed4", "label": "Le Super Qualité (Montréal, indien)" }, | |
| 25 | + { "key": "d84801b5-66f5-44b3-b0dc-52c0365f571c", "label": "Ryu Sushi (Montréal)" }, | |
| 26 | + { "key": "87c690ed-be3c-4a5d-b58b-676235faa9c1", "label": "Panda Boba-T (chaîne, bubble tea)" }, | |
| 27 | + { "key": "970d8e90-55ca-4655-9631-a8f9af08f4b7", "label": "Chez Ashton (23 succ., poutine)" }, | |
| 28 | + { "key": "e473d88d-b405-41ee-9ee8-2aa628eee604", "label": "Thaïzone (26 succ. QC)" }, | |
| 29 | + { "key": "f89d74e1-c2ab-4ace-8bce-39069900f5b4", "label": "Normandin (44 succ., familial)" }, | |
| 30 | + { "key": "e15b2545-2f2a-4e5f-baa6-00ab849cfa16", "label": "Valentine (76 succ. QC, casse-croûte)" }, | |
| 31 | + { "key": "657f8ce3-d288-4245-aecf-7ca017364b8e", "label": "Tutti Frutti (9 succ. QC, déjeuner)" }, | |
| 32 | + { "key": "00f8d52b-7aaf-4bcf-8722-6d538aad5a9a", "label": "Pacini (20 succ. QC, italien)" }, | |
| 33 | + { "key": "d679a49f-0a25-4694-82f2-56073a8b8790", "label": "Scores (25 succ., rôtisserie)" }, | |
| 34 | + { "key": "1696ecd3-d4ea-4224-9f98-9299ebcb339c", "label": "Mandy's (9 succ. QC, salades)" }, | |
| 35 | + { "key": "dce3e009-8038-47bc-8d5e-f336458e573f", "label": "Sushi Shop (141 succ. QC)" }, | |
| 36 | + { "key": "e8015de3-09ac-4cd2-a18d-0820dbe356ed", "label": "(intégration retirée chez UEAT)", "status": "inactif" }, | |
| 37 | + { "key": "dfde098b-ee0f-46a3-b1ad-09ec2acb54b9", "label": "(intégration retirée chez UEAT)", "status": "inactif" } | |
| 38 | + ] | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "id": "sthubert", | |
| 42 | + "name": "St-Hubert (commande en ligne)", | |
| 43 | + "url": "https://commande.st-hubert.com", | |
| 44 | + "platform": "st-hubert", | |
| 45 | + "connector": null, | |
| 46 | + "tier": 6, | |
| 47 | + "price_context": "takeout", | |
| 48 | + "extraction": "À déterminer — Akamai anti-bot sur st-hubert.com ; commande.st-hubert.com rend une page vide même via Scrapfly ASP (flux JS multi-étapes, sélection de succursale requise). Prochain essai : rejouer les XHR internes après sélection d'une succursale (js_scenario Scrapfly).", | |
| 49 | + "acces_legal": "Site de commande de la chaîne elle-même (prix réels). À industrialiser prudemment (CLAUDE.md §15).", | |
| 50 | + "cadence": "hebdomadaire (visé)", | |
| 51 | + "status": "à faire" | |
| 52 | + } | |
| 53 | + ] | |
| 54 | +} | |
added
docs/screenshots/accueil.png
+0 −0
Binary file not shown.
added
docs/screenshots/fiche.png
+0 −0
Binary file not shown.
added
docs/screenshots/plats.png
+0 −0
Binary file not shown.
added
docs/screenshots/stats.png
+0 −0
Binary file not shown.
added
frontend/index.html
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<!-- --------------------------------------------------------------------------- | |
| 3 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | + File: index.html | |
| 5 | + Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix). | |
| 6 | +---------------------------------------------------------------------------- --> | |
| 7 | +<html lang="fr"> | |
| 8 | + <head> | |
| 9 | + <meta charset="UTF-8" /> | |
| 10 | + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | |
| 11 | + <title>Resto-Ka — Tous les restaurants du Québec, menus et prix</title> | |
| 12 | + <meta name="description" content="Resto-Ka agrège les restaurants du Québec avec leurs menus complets et leurs prix réels — comparables, cherchables, dans les 17 régions. Chaque resto, chaque plat, chaque prix." /> | |
| 13 | + <meta name="theme-color" content="#ffffff" /> | |
| 14 | + <meta name="mobile-web-app-capable" content="yes" /> | |
| 15 | + <meta name="apple-mobile-web-app-capable" content="yes" /> | |
| 16 | + <meta name="apple-mobile-web-app-status-bar-style" content="default" /> | |
| 17 | + <meta name="apple-mobile-web-app-title" content="Resto-Ka" /> | |
| 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@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@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='%23141814'/%3E%3Ccircle cx='32' cy='32' r='21' fill='none' stroke='%23d9f26b' stroke-width='5'/%3E%3Ccircle cx='32' cy='32' r='9.5' fill='%23d9f26b'/%3E%3Cpath d='M13 20 v10 M9.5 20 v6 M16.5 20 v6' stroke='%23f5f3ee' stroke-width='2.6' stroke-linecap='round'/%3E%3Cpath d='M51.5 20 v24' stroke='%23f5f3ee' stroke-width='2.6' stroke-linecap='round'/%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": "resto-ka-frontend", | |
| 3 | + "version": "1.0.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "resto-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.15", | |
| 1274 | + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", | |
| 1275 | + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", | |
| 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.408", | |
| 1374 | + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz", | |
| 1375 | + "integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==", | |
| 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": "resto-ka-frontend", | |
| 3 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 4 | + "private": true, | |
| 5 | + "version": "1.0.0", | |
| 6 | + "type": "module", | |
| 7 | + "scripts": { | |
| 8 | + "dev": "vite", | |
| 9 | + "build": "tsc -b && vite build", | |
| 10 | + "preview": "vite preview" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "react": "^18.3.1", | |
| 14 | + "react-dom": "^18.3.1", | |
| 15 | + "react-router-dom": "^6.26.0" | |
| 16 | + }, | |
| 17 | + "devDependencies": { | |
| 18 | + "@types/react": "^18.3.3", | |
| 19 | + "@types/react-dom": "^18.3.0", | |
| 20 | + "@vitejs/plugin-react": "^4.3.1", | |
| 21 | + "typescript": "^5.5.4", | |
| 22 | + "vite": "^5.4.0" | |
| 23 | + } | |
| 24 | +} | |
added
frontend/src/App.tsx
+209 −0
@@ -0,0 +1,209 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: App.tsx | |
| 4 | +// Desc: Layout global (header + ticker en direct + footer encre) et routage — | |
| 5 | +// même patron que le App.tsx de Lou·Ka. | |
| 6 | +// ============================================================================== | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | |
| 9 | +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | |
| 10 | +import { | |
| 11 | + IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch, | |
| 12 | +} from "./components/Icons"; | |
| 13 | +import { LogoIcon } from "./components/Logo"; | |
| 14 | +import Home from "./pages/Home"; | |
| 15 | +import RestoPage from "./pages/Resto"; | |
| 16 | +import SourcesPage from "./pages/Sources"; | |
| 17 | +import StatsPage from "./pages/Stats"; | |
| 18 | + | |
| 19 | +function Ticker() { | |
| 20 | + const [items, setItems] = useState<string[]>([]); | |
| 21 | + | |
| 22 | + useEffect(() => { | |
| 23 | + Promise.all([fetchStats(), fetchFacets(), fetchSources()]) | |
| 24 | + .then(([stats, facets, src]) => { | |
| 25 | + registerSourceNames(src.sources); | |
| 26 | + const parts: string[] = [ | |
| 27 | + `${stats.restaurants} restaurants`, | |
| 28 | + `${stats.items} plats avec prix`, | |
| 29 | + `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`, | |
| 30 | + ]; | |
| 31 | + for (const r of stats.by_region.slice(0, 6)) | |
| 32 | + parts.push(`${r.region} · ${r.n}`); | |
| 33 | + for (const s of facets.sources.slice(0, 6)) | |
| 34 | + parts.push(`${sourceName(s.source)} · ${s.n}`); | |
| 35 | + parts.push("Prix réels étiquetés par contexte"); | |
| 36 | + parts.push("Mise à jour automatique"); | |
| 37 | + setItems(parts); | |
| 38 | + }) | |
| 39 | + .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"])); | |
| 40 | + }, []); | |
| 41 | + | |
| 42 | + if (items.length === 0) return null; | |
| 43 | + // contenu doublé pour une boucle de défilement continue | |
| 44 | + return ( | |
| 45 | + <div className="ticker" aria-hidden="true"> | |
| 46 | + <div className="ticker-track"> | |
| 47 | + {[...items, ...items].map((t, i) => ( | |
| 48 | + <span key={i}>{t}</span> | |
| 49 | + ))} | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + ); | |
| 53 | +} | |
| 54 | + | |
| 55 | +const NAV_LINKS = [ | |
| 56 | + { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true }, | |
| 57 | + { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false }, | |
| 58 | + { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false }, | |
| 59 | +]; | |
| 60 | + | |
| 61 | +function Header() { | |
| 62 | + const [open, setOpen] = useState(false); | |
| 63 | + const location = useLocation(); | |
| 64 | + | |
| 65 | + // fermer le menu à chaque navigation + verrouiller le défilement en dessous | |
| 66 | + useEffect(() => { setOpen(false); }, [location]); | |
| 67 | + useEffect(() => { | |
| 68 | + document.body.style.overflow = open ? "hidden" : ""; | |
| 69 | + return () => { document.body.style.overflow = ""; }; | |
| 70 | + }, [open]); | |
| 71 | + | |
| 72 | + return ( | |
| 73 | + <> | |
| 74 | + <header className="header"> | |
| 75 | + <div className="container header-inner"> | |
| 76 | + <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil"> | |
| 77 | + <LogoIcon size={30} /> | |
| 78 | + Resto-<span className="ka">Ka</span> | |
| 79 | + <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span> | |
| 80 | + </NavLink> | |
| 81 | + <nav className="nav" aria-label="Navigation principale"> | |
| 82 | + {NAV_LINKS.map((l) => ( | |
| 83 | + <NavLink key={l.to} to={l.to} end={l.end} | |
| 84 | + className={({ isActive }) => (isActive ? "active" : "")}> | |
| 85 | + {l.label} | |
| 86 | + </NavLink> | |
| 87 | + ))} | |
| 88 | + </nav> | |
| 89 | + <button | |
| 90 | + className={`menu-btn ${open ? "open" : ""}`} | |
| 91 | + aria-expanded={open} | |
| 92 | + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"} | |
| 93 | + onClick={() => setOpen(!open)} | |
| 94 | + > | |
| 95 | + <span /><span /><span /> | |
| 96 | + </button> | |
| 97 | + </div> | |
| 98 | + | |
| 99 | + {/* menu déroulant mobile */} | |
| 100 | + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile"> | |
| 101 | + {NAV_LINKS.map((l, i) => ( | |
| 102 | + <NavLink | |
| 103 | + key={l.to} | |
| 104 | + to={l.to} | |
| 105 | + end={l.end} | |
| 106 | + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }} | |
| 107 | + className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`} | |
| 108 | + onClick={() => setOpen(false)} | |
| 109 | + > | |
| 110 | + <span className="mm-ico" aria-hidden="true">{l.icon}</span> | |
| 111 | + {l.label} | |
| 112 | + <span className="mm-arrow" aria-hidden="true">→</span> | |
| 113 | + </NavLink> | |
| 114 | + ))} | |
| 115 | + <div className="mm-foot"> | |
| 116 | + Agrégateur indépendant — menus et prix captés aux sources, chaque | |
| 117 | + fiche renvoie au resto original et affiche le contexte du prix. | |
| 118 | + </div> | |
| 119 | + </div> | |
| 120 | + </header> | |
| 121 | + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />} | |
| 122 | + <Ticker /> | |
| 123 | + </> | |
| 124 | + ); | |
| 125 | +} | |
| 126 | + | |
| 127 | +function Footer() { | |
| 128 | + return ( | |
| 129 | + <footer className="footer"> | |
| 130 | + <div className="container"> | |
| 131 | + <div className="fbrand"> | |
| 132 | + <LogoIcon size={32} dark /> | |
| 133 | + Resto-<span className="ka">Ka</span> | |
| 134 | + </div> | |
| 135 | + <div className="fbaseline"> | |
| 136 | + Chaque resto, chaque plat, <span className="hl">chaque prix.</span> | |
| 137 | + </div> | |
| 138 | + <div className="frow"> | |
| 139 | + <div> | |
| 140 | + <b>Agrégateur indépendant</b> des restaurants du Québec — menus | |
| 141 | + complets et prix réels dans les 17 régions. Les menus proviennent | |
| 142 | + des plateformes de commande des restos eux-mêmes ; chaque prix est | |
| 143 | + étiqueté selon son contexte (salle, emporter, livraison) et chaque | |
| 144 | + fiche renvoie à la source originale. | |
| 145 | + </div> | |
| 146 | + <div> | |
| 147 | + Resto-Ka est une plateforme du{" "} | |
| 148 | + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"> | |
| 149 | + <b>Groupe KA</b> | |
| 150 | + </a>{" "} | |
| 151 | + — contact :{" "} | |
| 152 | + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> | |
| 153 | + </div> | |
| 154 | + </div> | |
| 155 | + <div className="fmono"> | |
| 156 | + © {new Date().getFullYear()} Groupe KA — Resto-Ka | |
| 157 | + </div> | |
| 158 | + </div> | |
| 159 | + </footer> | |
| 160 | + ); | |
| 161 | +} | |
| 162 | + | |
| 163 | +/** Barre de navigation inférieure (mobile) — icône active en orange. */ | |
| 164 | +function MobileTabBar() { | |
| 165 | + const location = useLocation(); | |
| 166 | + const tabs = [ | |
| 167 | + { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" }, | |
| 168 | + { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") }, | |
| 169 | + { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") }, | |
| 170 | + ]; | |
| 171 | + return ( | |
| 172 | + <nav className="tabbar tabbar-3" aria-label="Navigation mobile"> | |
| 173 | + {tabs.map((t) => ( | |
| 174 | + <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}> | |
| 175 | + {t.icon} | |
| 176 | + {t.label} | |
| 177 | + </NavLink> | |
| 178 | + ))} | |
| 179 | + </nav> | |
| 180 | + ); | |
| 181 | +} | |
| 182 | + | |
| 183 | +export default function App() { | |
| 184 | + return ( | |
| 185 | + <> | |
| 186 | + <Header /> | |
| 187 | + <main> | |
| 188 | + <Routes> | |
| 189 | + <Route path="/" element={<Home />} /> | |
| 190 | + <Route path="/resto/:uid" element={<RestoPage />} /> | |
| 191 | + <Route path="/stats" element={<StatsPage />} /> | |
| 192 | + <Route path="/sources" element={<SourcesPage />} /> | |
| 193 | + <Route | |
| 194 | + path="*" | |
| 195 | + element={ | |
| 196 | + <div className="notice container"> | |
| 197 | + <div className="big"><IcoCompass size={40} /></div> | |
| 198 | + <h2>Page introuvable</h2> | |
| 199 | + <p>Le lien demandé n'existe pas.</p> | |
| 200 | + </div> | |
| 201 | + } | |
| 202 | + /> | |
| 203 | + </Routes> | |
| 204 | + </main> | |
| 205 | + <Footer /> | |
| 206 | + <MobileTabBar /> | |
| 207 | + </> | |
| 208 | + ); | |
| 209 | +} | |
added
frontend/src/api.ts
+244 −0
@@ -0,0 +1,244 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: api.ts | |
| 4 | +// Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR | |
| 5 | +// (cuisines, contextes de prix, régions). Même patron que Lou·Ka. | |
| 6 | +// ============================================================================== | |
| 7 | + | |
| 8 | +export interface MenuOptionChoice { name: string; price_delta: number } | |
| 9 | +export interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] } | |
| 10 | + | |
| 11 | +export interface MenuItem { | |
| 12 | + id?: string; | |
| 13 | + name: string; | |
| 14 | + description?: string; | |
| 15 | + price: number | null; | |
| 16 | + currency?: string; | |
| 17 | + tags?: string[]; | |
| 18 | + options?: MenuOption[]; | |
| 19 | + image?: string; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface MenuSection { name: string; items: MenuItem[]; image?: string } | |
| 23 | + | |
| 24 | +export interface Menu { | |
| 25 | + price_context: "dine-in" | "takeout" | "delivery"; | |
| 26 | + price_source: string; | |
| 27 | + currency: string; | |
| 28 | + captured_at: string; | |
| 29 | + item_count: number; | |
| 30 | + sections: MenuSection[]; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface MenuSummary { | |
| 34 | + image?: string | null; | |
| 35 | + price_context: string; | |
| 36 | + price_source: string; | |
| 37 | + captured_at: string; | |
| 38 | + item_count: number; | |
| 39 | + price_min: number | null; | |
| 40 | + price_median: number | null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export interface Restaurant { | |
| 44 | + uid: string; | |
| 45 | + source: string; | |
| 46 | + external_id: string; | |
| 47 | + name: string; | |
| 48 | + chain: string | null; | |
| 49 | + cuisines: string[]; | |
| 50 | + establishment_type: string; | |
| 51 | + price_range: string; | |
| 52 | + address: string; | |
| 53 | + city: string; | |
| 54 | + region: string; | |
| 55 | + postal_code: string; | |
| 56 | + lat: number | null; | |
| 57 | + lng: number | null; | |
| 58 | + phone: string; | |
| 59 | + website: string; | |
| 60 | + url: string; | |
| 61 | + hours: Record<string, string>; | |
| 62 | + services: string[]; | |
| 63 | + dietary_options: string[]; | |
| 64 | + languages: string[]; | |
| 65 | + images: string[]; | |
| 66 | + status: string; | |
| 67 | + menu_summary?: MenuSummary | null; | |
| 68 | + menus?: Menu[]; | |
| 69 | + recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[]; | |
| 70 | + dup_sources?: string[]; | |
| 71 | + first_seen?: number; | |
| 72 | + last_seen: number; | |
| 73 | + updated_at: number; | |
| 74 | + active: number; | |
| 75 | +} | |
| 76 | + | |
| 77 | +export interface Facets { | |
| 78 | + regions: { region: string; n: number }[]; | |
| 79 | + all_regions: string[]; | |
| 80 | + cities: string[]; | |
| 81 | + cuisines: { cuisine: string; n: number }[]; | |
| 82 | + diets: { diet: string; n: number }[]; | |
| 83 | + establishment_types: { t: string; n: number }[]; | |
| 84 | + chains: string[]; | |
| 85 | + sources: { source: string; n: number }[]; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export interface Source { | |
| 89 | + id: string; | |
| 90 | + name: string; | |
| 91 | + url: string; | |
| 92 | + platform?: string; | |
| 93 | + tier?: number; | |
| 94 | + price_context?: string; | |
| 95 | + extraction?: string; | |
| 96 | + cadence?: string; | |
| 97 | + status: string; | |
| 98 | + active_restaurants: number; | |
| 99 | + menu_items: number; | |
| 100 | + last_sync: number | null; | |
| 101 | + integrations?: { label: string; status: string }[]; | |
| 102 | +} | |
| 103 | + | |
| 104 | +export interface Stats { | |
| 105 | + restaurants: number; | |
| 106 | + chains: number; | |
| 107 | + regions: number; | |
| 108 | + sources: number; | |
| 109 | + menus: number; | |
| 110 | + items: number; | |
| 111 | + by_region: { region: string; n: number }[]; | |
| 112 | + by_context: { price_context: string; n: number }[]; | |
| 113 | + recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[]; | |
| 114 | +} | |
| 115 | + | |
| 116 | +// --- libellés français -------------------------------------------------------- | |
| 117 | + | |
| 118 | +export const CUISINE_LABELS: Record<string, string> = { | |
| 119 | + "quebecois": "Québécois", "francais": "Français", "italien": "Italien", | |
| 120 | + "pizza": "Pizza", "burgers": "Burgers", "poulet": "Poulet", | |
| 121 | + "bbq-grillades": "BBQ & grillades", "fruits-de-mer": "Fruits de mer", | |
| 122 | + "sushi-japonais": "Sushi & japonais", "chinois": "Chinois", "thai": "Thaï", | |
| 123 | + "vietnamien": "Vietnamien", "coreen": "Coréen", "indien": "Indien", | |
| 124 | + "libanais-moyen-orient": "Libanais & Moyen-Orient", "mexicain": "Mexicain", | |
| 125 | + "grec": "Grec", "mediterraneen": "Méditerranéen", | |
| 126 | + "dejeuner-brunch": "Déjeuner & brunch", "cafe-dessert": "Café & dessert", | |
| 127 | + "vegetarien-vegan": "Végé & vegan", "fast-food": "Fast-food", "autre": "Autre", | |
| 128 | +}; | |
| 129 | + | |
| 130 | +export const CONTEXT_LABELS: Record<string, string> = { | |
| 131 | + "dine-in": "Prix en salle", | |
| 132 | + "takeout": "Prix pour emporter", | |
| 133 | + "delivery": "Prix livraison (majoré 25-30 %)", | |
| 134 | +}; | |
| 135 | + | |
| 136 | +export const CONTEXT_SHORT: Record<string, string> = { | |
| 137 | + "dine-in": "Salle", "takeout": "Emporter", "delivery": "Livraison", | |
| 138 | +}; | |
| 139 | + | |
| 140 | +export const TYPE_LABELS: Record<string, string> = { | |
| 141 | + "restaurant": "Restaurant", "fast-food": "Fast-food", "cafe": "Café", | |
| 142 | + "bar": "Bar & pub", "food-truck": "Food truck", "traiteur": "Traiteur", | |
| 143 | + "boulangerie-patisserie": "Boulangerie-pâtisserie", | |
| 144 | + "casse-croute": "Casse-croûte", "microbrasserie": "Microbrasserie", | |
| 145 | + "hotel": "Resto d'hôtel", "ghost-kitchen": "Cuisine fantôme", | |
| 146 | +}; | |
| 147 | + | |
| 148 | +export const DIET_LABELS: Record<string, string> = { | |
| 149 | + "vegan": "Vegan", "vegetarien": "Végétarien", "sans-gluten": "Sans gluten", | |
| 150 | + "halal": "Halal", "casher": "Casher", "sans-noix": "Sans noix", "epice": "Épicé", | |
| 151 | +}; | |
| 152 | + | |
| 153 | +export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c; | |
| 154 | +export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t; | |
| 155 | +export const dietLabel = (d: string) => DIET_LABELS[d] ?? d; | |
| 156 | + | |
| 157 | +const SOURCE_NAMES: Record<string, string> = { ueat: "UEAT (commande en ligne)" }; | |
| 158 | +export function registerSourceNames(sources: Source[]) { | |
| 159 | + for (const s of sources) SOURCE_NAMES[s.id] = s.name; | |
| 160 | +} | |
| 161 | +export const sourceName = (id: string) => SOURCE_NAMES[id] ?? id; | |
| 162 | + | |
| 163 | +/** 16.5 -> « 16,50 $ » */ | |
| 164 | +export const fmtPrice = (p: number | null | undefined): string => | |
| 165 | + p == null ? "—" : `${p.toFixed(2).replace(".", ",")} $`; | |
| 166 | + | |
| 167 | +export const fmtDate = (iso: string | null | undefined): string => { | |
| 168 | + if (!iso) return "—"; | |
| 169 | + const d = new Date(iso); | |
| 170 | + return isNaN(d.getTime()) ? iso : d.toLocaleDateString("fr-CA"); | |
| 171 | +}; | |
| 172 | + | |
| 173 | +export const fmtTs = (ts: number | null | undefined): string => | |
| 174 | + ts ? new Date(ts * 1000).toLocaleString("fr-CA") : "—"; | |
| 175 | + | |
| 176 | +// --- client ------------------------------------------------------------------- | |
| 177 | + | |
| 178 | +async function get<T>(path: string): Promise<T> { | |
| 179 | + const ctl = new AbortController(); | |
| 180 | + const timer = setTimeout(() => ctl.abort(), 20000); | |
| 181 | + try { | |
| 182 | + const resp = await fetch(path, { signal: ctl.signal }); | |
| 183 | + if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`); | |
| 184 | + return (await resp.json()) as T; | |
| 185 | + } finally { | |
| 186 | + clearTimeout(timer); | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +export interface Dish { | |
| 191 | + item: string; | |
| 192 | + description: string; | |
| 193 | + price: number | null; | |
| 194 | + currency: string; | |
| 195 | + image?: string | null; | |
| 196 | + tags: string[]; | |
| 197 | + section: string; | |
| 198 | + price_context: string; | |
| 199 | + price_source: string; | |
| 200 | + captured_at: string; | |
| 201 | + brand: string; | |
| 202 | + restaurant: string; | |
| 203 | + uid: string; | |
| 204 | + city: string; | |
| 205 | + region: string; | |
| 206 | + locations: number; | |
| 207 | +} | |
| 208 | + | |
| 209 | +export interface DishQuery { | |
| 210 | + q: string; region?: string; city?: string; cuisine?: string; | |
| 211 | + price_max?: number; limit?: number; offset?: number; | |
| 212 | +} | |
| 213 | + | |
| 214 | +export function fetchDishes(query: DishQuery) { | |
| 215 | + const params = new URLSearchParams(); | |
| 216 | + for (const [k, v] of Object.entries(query)) | |
| 217 | + if (v !== undefined && v !== "" && v !== null) params.set(k, String(v)); | |
| 218 | + return get<{ total: number; count: number; dishes: Dish[] }>( | |
| 219 | + `/api/dishes?${params.toString()}`); | |
| 220 | +} | |
| 221 | + | |
| 222 | +export interface RestaurantQuery { | |
| 223 | + region?: string; city?: string; cuisine?: string; establishment_type?: string; | |
| 224 | + diet?: string; service?: string; price_range?: string; chain?: string; | |
| 225 | + source?: string; q?: string; sort?: string; limit?: number; offset?: number; | |
| 226 | +} | |
| 227 | + | |
| 228 | +export function fetchRestaurants(query: RestaurantQuery = {}) { | |
| 229 | + const params = new URLSearchParams(); | |
| 230 | + for (const [k, v] of Object.entries(query)) | |
| 231 | + if (v !== undefined && v !== "" && v !== null) params.set(k, String(v)); | |
| 232 | + return get<{ total: number; count: number; restaurants: Restaurant[] }>( | |
| 233 | + `/api/restaurants?${params.toString()}`); | |
| 234 | +} | |
| 235 | + | |
| 236 | +export const fetchRestaurant = (uid: string) => | |
| 237 | + get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`); | |
| 238 | + | |
| 239 | +export const fetchFacets = (region?: string) => | |
| 240 | + get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`); | |
| 241 | + | |
| 242 | +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); | |
| 243 | + | |
| 244 | +export const fetchStats = () => get<Stats>("/api/stats"); | |
added
frontend/src/components/Icons.tsx
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: components/Icons.tsx | |
| 4 | +// Desc: Icônes SVG inline (traits 2 u, coins ronds) — même style que Lou·Ka. | |
| 5 | +// ============================================================================== | |
| 6 | +interface IcoProps { size?: number; strokeWidth?: number } | |
| 7 | + | |
| 8 | +const base = (size: number) => ({ | |
| 9 | + width: size, height: size, viewBox: "0 0 24 24", fill: "none", | |
| 10 | + stroke: "currentColor", strokeLinecap: "round" as const, | |
| 11 | + strokeLinejoin: "round" as const, | |
| 12 | +}); | |
| 13 | + | |
| 14 | +export const IcoSearch = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 15 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 16 | + <circle cx="11" cy="11" r="7" /> | |
| 17 | + <path d="m20 20-3.8-3.8" /> | |
| 18 | + </svg> | |
| 19 | +); | |
| 20 | + | |
| 21 | +export const IcoPlate = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 22 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 23 | + <circle cx="12" cy="12" r="9" /> | |
| 24 | + <circle cx="12" cy="12" r="3.5" /> | |
| 25 | + </svg> | |
| 26 | +); | |
| 27 | + | |
| 28 | +export const IcoChart = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 29 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 30 | + <path d="M4 20V10M10 20V4M16 20v-7M21 20H3" /> | |
| 31 | + </svg> | |
| 32 | +); | |
| 33 | + | |
| 34 | +export const IcoFolder = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 35 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 36 | + <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /> | |
| 37 | + </svg> | |
| 38 | +); | |
| 39 | + | |
| 40 | +export const IcoPin = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 41 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 42 | + <path d="M12 21s-7-5.4-7-11a7 7 0 0 1 14 0c0 5.6-7 11-7 11Z" /> | |
| 43 | + <circle cx="12" cy="10" r="2.6" /> | |
| 44 | + </svg> | |
| 45 | +); | |
| 46 | + | |
| 47 | +export const IcoTag = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 48 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 49 | + <path d="M3 3h8l10 10-8 8L3 11Z" /> | |
| 50 | + <circle cx="8" cy="8" r="1.6" /> | |
| 51 | + </svg> | |
| 52 | +); | |
| 53 | + | |
| 54 | +export const IcoClock = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 55 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 56 | + <circle cx="12" cy="12" r="9" /> | |
| 57 | + <path d="M12 7v5l3.2 2" /> | |
| 58 | + </svg> | |
| 59 | +); | |
| 60 | + | |
| 61 | +export const IcoCompass = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 62 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 63 | + <circle cx="12" cy="12" r="9" /> | |
| 64 | + <path d="m15.5 8.5-2 5-5 2 2-5Z" /> | |
| 65 | + </svg> | |
| 66 | +); | |
| 67 | + | |
| 68 | +export const IcoLeaf = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 69 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 70 | + <path d="M5 19C5 9 12 4 20 4c0 8-5 15-15 15Z" /> | |
| 71 | + <path d="M5 19c3-5 7-8 11-10" /> | |
| 72 | + </svg> | |
| 73 | +); | |
| 74 | + | |
| 75 | +export const IcoBag = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 76 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 77 | + <path d="M6 8h12l-1 12H7L6 8Z" /> | |
| 78 | + <path d="M9 8a3 3 0 0 1 6 0" /> | |
| 79 | + </svg> | |
| 80 | +); | |
added
frontend/src/components/Logo.tsx
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: components/Logo.tsx | |
| 4 | +// Desc: Icône officielle Resto·Ka — l'assiette servie, dans le langage | |
| 5 | +// « éditorial sharp » du Groupe KA (grille 100 × 100) : | |
| 6 | +// A. assiette : cercle ENCRE #141814 en contour seul (7,5 u) — la | |
| 7 | +// bordure encre signature du groupe ; | |
| 8 | +// B. couverts : fourchette (gauche) et couteau (droite) encre, traits | |
| 9 | +// francs à bouts ronds ; | |
| 10 | +// C. cœur d'assiette : disque central LIME électrique #d9f26b — le | |
| 11 | +// plat servi, l'accent du groupe. | |
| 12 | +// Version dark (fond encre) : contours papier, cœur lime inchangé. | |
| 13 | +// Baseline : « Chaque resto, chaque plat, chaque prix. » | |
| 14 | +// ============================================================================== | |
| 15 | + | |
| 16 | +/** Icône seule (fond transparent — dark = contours papier pour fond encre). */ | |
| 17 | +export function LogoIcon({ size = 30, dark = false }: { size?: number; dark?: boolean }) { | |
| 18 | + const ink = dark ? "#f5f3ee" : "#141814"; | |
| 19 | + return ( | |
| 20 | + <svg | |
| 21 | + className="logo-ico" | |
| 22 | + width={size} | |
| 23 | + height={size} | |
| 24 | + viewBox="0 0 100 100" | |
| 25 | + fill="none" | |
| 26 | + aria-hidden="true" | |
| 27 | + > | |
| 28 | + {/* B — fourchette (3 dents) à gauche */} | |
| 29 | + <path | |
| 30 | + d="M13 26 v14 M7.5 26 v9 M18.5 26 v9 M13 40 v34" | |
| 31 | + stroke={ink} | |
| 32 | + strokeWidth="5" | |
| 33 | + strokeLinecap="round" | |
| 34 | + /> | |
| 35 | + {/* B — couteau à droite (lame courbe) */} | |
| 36 | + <path | |
| 37 | + d="M87 26 q5 12 0 24 v24" | |
| 38 | + stroke={ink} | |
| 39 | + strokeWidth="5" | |
| 40 | + strokeLinecap="round" | |
| 41 | + strokeLinejoin="round" | |
| 42 | + /> | |
| 43 | + {/* C — cœur d'assiette : le plat servi, lime électrique */} | |
| 44 | + <circle cx="50" cy="50" r="14" fill="#d9f26b" stroke={ink} strokeWidth="4" /> | |
| 45 | + {/* A — assiette : contour encre seul, bordure franche */} | |
| 46 | + <circle cx="50" cy="50" r="31" stroke={ink} strokeWidth="7" /> | |
| 47 | + </svg> | |
| 48 | + ); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Logo horizontal complet : icône + wordmark « Resto-Ka » (Ka en pastille lime). */ | |
| 52 | +export default function Logo({ size = 30 }: { size?: number }) { | |
| 53 | + return ( | |
| 54 | + <span style={{ display: "inline-flex", alignItems: "center", gap: size * 0.3 }}> | |
| 55 | + <LogoIcon size={size} /> | |
| 56 | + <span style={{ color: "#141814", fontWeight: 700, letterSpacing: "-0.03em" }}> | |
| 57 | + Resto- | |
| 58 | + <span style={{ | |
| 59 | + background: "#d9f26b", color: "#141814", padding: "1px 6px 3px", | |
| 60 | + borderRadius: 5, marginLeft: 3, display: "inline-block", | |
| 61 | + transform: "rotate(-2deg)", | |
| 62 | + }}> | |
| 63 | + Ka | |
| 64 | + </span> | |
| 65 | + </span> | |
| 66 | + </span> | |
| 67 | + ); | |
| 68 | +} | |
added
frontend/src/components/Pager.tsx
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: components/Pager.tsx | |
| 4 | +// Desc: Pagination numérotée en bas des résultats (fenêtre 1 … n-1 n n+1 … N) | |
| 5 | +// pour ne jamais surcharger une page — style éditorial sharp. | |
| 6 | +// ============================================================================== | |
| 7 | + | |
| 8 | +export default function Pager({ page, pageCount, onPage }: { | |
| 9 | + page: number; // page courante, base 0 | |
| 10 | + pageCount: number; | |
| 11 | + onPage: (p: number) => void; | |
| 12 | +}) { | |
| 13 | + if (pageCount <= 1) return null; | |
| 14 | + | |
| 15 | + const nums: (number | "…")[] = []; | |
| 16 | + const push = (n: number | "…") => { | |
| 17 | + if (nums[nums.length - 1] !== n) nums.push(n); | |
| 18 | + }; | |
| 19 | + for (let i = 0; i < pageCount; i++) { | |
| 20 | + if (i === 0 || i === pageCount - 1 || Math.abs(i - page) <= 1) push(i); | |
| 21 | + else push("…"); | |
| 22 | + } | |
| 23 | + | |
| 24 | + const go = (p: number) => { | |
| 25 | + onPage(p); | |
| 26 | + window.scrollTo({ top: 0 }); | |
| 27 | + }; | |
| 28 | + | |
| 29 | + return ( | |
| 30 | + <nav className="pager" aria-label="Pagination"> | |
| 31 | + <button className="pg-btn" disabled={page === 0} onClick={() => go(page - 1)} | |
| 32 | + aria-label="Page précédente">←</button> | |
| 33 | + {nums.map((n, i) => | |
| 34 | + n === "…" ? ( | |
| 35 | + <span className="pg-ellipsis" key={`e${i}`}>…</span> | |
| 36 | + ) : ( | |
| 37 | + <button | |
| 38 | + key={n} | |
| 39 | + className={`pg-btn pg-num ${n === page ? "on" : ""}`} | |
| 40 | + aria-current={n === page ? "page" : undefined} | |
| 41 | + onClick={() => go(n)} | |
| 42 | + > | |
| 43 | + {n + 1} | |
| 44 | + </button> | |
| 45 | + ))} | |
| 46 | + <button className="pg-btn" disabled={page >= pageCount - 1} onClick={() => go(page + 1)} | |
| 47 | + aria-label="Page suivante">→</button> | |
| 48 | + </nav> | |
| 49 | + ); | |
| 50 | +} | |
added
frontend/src/components/RestoCard.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: components/RestoCard.tsx | |
| 4 | +// Desc: Carte de restaurant (grille de résultats) — même patron que la | |
| 5 | +// ListingCard de Lou·Ka : prix en tête, badge de contexte de prix | |
| 6 | +// TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte. | |
| 7 | +// ============================================================================== | |
| 8 | +import { Link } from "react-router-dom"; | |
| 9 | +import { | |
| 10 | + CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel, | |
| 11 | +} from "../api"; | |
| 12 | +import { IcoPlate } from "./Icons"; | |
| 13 | + | |
| 14 | +export default function RestoCard({ r }: { r: Restaurant }) { | |
| 15 | + const ms = r.menu_summary; | |
| 16 | + const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord | |
| 17 | + const logo = (r.images ?? [])[0] || null; | |
| 18 | + return ( | |
| 19 | + <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card"> | |
| 20 | + <div className="card-img"> | |
| 21 | + {img ? ( | |
| 22 | + <img src={img} alt={r.name} loading="lazy" /> | |
| 23 | + ) : logo ? ( | |
| 24 | + <div className="noimg"><img className="card-logo" src={logo} alt={r.name} loading="lazy" /></div> | |
| 25 | + ) : ( | |
| 26 | + <div className="noimg"><IcoPlate size={38} strokeWidth={1.6} /></div> | |
| 27 | + )} | |
| 28 | + {r.price_range && <span className="badge type">{r.price_range}</span>} | |
| 29 | + {ms && ( | |
| 30 | + <span className={`badge right ctx ctx-${ms.price_context}`}> | |
| 31 | + {CONTEXT_SHORT[ms.price_context] ?? ms.price_context} | |
| 32 | + </span> | |
| 33 | + )} | |
| 34 | + </div> | |
| 35 | + <div className="card-body"> | |
| 36 | + <div className="card-price"> | |
| 37 | + {ms?.price_median != null ? ( | |
| 38 | + <> | |
| 39 | + {fmtPrice(ms.price_median)} <small>plat médian</small> | |
| 40 | + </> | |
| 41 | + ) : ( | |
| 42 | + <span className="no-menu">Menu à venir</span> | |
| 43 | + )} | |
| 44 | + </div> | |
| 45 | + <div className="card-title">{r.name}</div> | |
| 46 | + <div className="card-meta"> | |
| 47 | + <span>{typeLabel(r.establishment_type)}</span> | |
| 48 | + {r.cuisines.slice(0, 2).map((c) => ( | |
| 49 | + <span key={c}> | |
| 50 | + <span className="sep" /> {cuisineLabel(c)} | |
| 51 | + </span> | |
| 52 | + ))} | |
| 53 | + </div> | |
| 54 | + <div className="card-meta"> | |
| 55 | + {r.city && <span>{r.city}</span>} | |
| 56 | + {r.city && r.region && <span className="sep" />} | |
| 57 | + {r.region && <span>{r.region}</span>} | |
| 58 | + </div> | |
| 59 | + <div className="card-foot"> | |
| 60 | + <span className="source-tag">{sourceName(r.source)}</span> | |
| 61 | + {ms && <span className="avail">{ms.item_count} plats au menu</span>} | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + </Link> | |
| 65 | + ); | |
| 66 | +} | |
added
frontend/src/main.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: main.tsx | |
| 4 | +// Desc: Point d'entrée React de Resto·Ka (même patron que Lou·Ka). | |
| 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
+370 −0
@@ -0,0 +1,370 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: pages/Home.tsx | |
| 4 | +// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans | |
| 5 | +// tous les menus), chips cuisines, barre de filtres (région, ville, | |
| 6 | +// type, diète, prix), grilles de résultats + pagination numérotée. | |
| 7 | +// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron | |
| 8 | +// Lou·Ka, design éditorial sharp Groupe KA). | |
| 9 | +// ============================================================================== | |
| 10 | +import { useEffect, useMemo, useState } from "react"; | |
| 11 | +import { Link, useSearchParams } from "react-router-dom"; | |
| 12 | +import { | |
| 13 | + CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel, | |
| 14 | + fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel, | |
| 15 | +} from "../api"; | |
| 16 | +import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from "../components/Icons"; | |
| 17 | +import Pager from "../components/Pager"; | |
| 18 | +import RestoCard from "../components/RestoCard"; | |
| 19 | + | |
| 20 | +const PAGE_SIZE = 24; | |
| 21 | +const DISH_PAGE_SIZE = 20; | |
| 22 | + | |
| 23 | +const FILTER_KEYS = ["q", "region", "city", "cuisine", "establishment_type", | |
| 24 | + "diet", "price_range", "sort", "mode"] as const; | |
| 25 | +type FilterKey = typeof FILTER_KEYS[number]; | |
| 26 | + | |
| 27 | +function DishRow({ d }: { d: Dish }) { | |
| 28 | + return ( | |
| 29 | + <Link to={`/resto/${encodeURIComponent(d.uid)}`} className="dish-row"> | |
| 30 | + {d.image ? ( | |
| 31 | + <img className="dish-img" src={d.image} alt={d.item} loading="lazy" /> | |
| 32 | + ) : ( | |
| 33 | + <span className="dish-img dish-noimg"><IcoPlate size={22} strokeWidth={1.8} /></span> | |
| 34 | + )} | |
| 35 | + <div className="dish-main"> | |
| 36 | + <div className="dish-line"> | |
| 37 | + <span className="dish-name">{d.item}</span> | |
| 38 | + <span className="mi-dots" aria-hidden="true" /> | |
| 39 | + <span className="dish-price">{fmtPrice(d.price)}</span> | |
| 40 | + </div> | |
| 41 | + {d.description && <p className="dish-desc">{d.description}</p>} | |
| 42 | + <div className="dish-meta"> | |
| 43 | + <b>{d.brand}</b> | |
| 44 | + <span className="sep" /> | |
| 45 | + <span>{d.section}</span> | |
| 46 | + <span className="sep" /> | |
| 47 | + {d.locations > 1 | |
| 48 | + ? <span>{d.locations} emplacements</span> | |
| 49 | + : <span>{d.city}{d.region ? ` · ${d.region}` : ""}</span>} | |
| 50 | + <span className={`ctx-pill ctx-${d.price_context}`}> | |
| 51 | + {CONTEXT_SHORT[d.price_context] ?? d.price_context} | |
| 52 | + </span> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + </Link> | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +export default function Home() { | |
| 60 | + const [params, setParams] = useSearchParams(); | |
| 61 | + const [facets, setFacets] = useState<Facets | null>(null); | |
| 62 | + const [restos, setRestos] = useState<Restaurant[]>([]); | |
| 63 | + const [dishes, setDishes] = useState<Dish[]>([]); | |
| 64 | + const [total, setTotal] = useState(0); | |
| 65 | + const [loading, setLoading] = useState(true); | |
| 66 | + const [error, setError] = useState(false); | |
| 67 | + const [page, setPage] = useState(0); | |
| 68 | + const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]); | |
| 69 | + const [sheetOpen, setSheetOpen] = useState(false); | |
| 70 | + | |
| 71 | + const filters = useMemo(() => { | |
| 72 | + const f: Partial<Record<FilterKey, string>> = {}; | |
| 73 | + for (const k of FILTER_KEYS) { | |
| 74 | + const v = params.get(k); | |
| 75 | + if (v) f[k] = v; | |
| 76 | + } | |
| 77 | + return f; | |
| 78 | + }, [params]); | |
| 79 | + | |
| 80 | + const mode = filters.mode === "plats" ? "plats" : "restos"; | |
| 81 | + const pageSize = mode === "plats" ? DISH_PAGE_SIZE : PAGE_SIZE; | |
| 82 | + | |
| 83 | + const setFilter = (key: FilterKey, value: string) => { | |
| 84 | + const next = new URLSearchParams(params); | |
| 85 | + if (value) next.set(key, value); | |
| 86 | + else next.delete(key); | |
| 87 | + setParams(next, { replace: true }); | |
| 88 | + setPage(0); | |
| 89 | + }; | |
| 90 | + | |
| 91 | + useEffect(() => { | |
| 92 | + fetchStats() | |
| 93 | + .then((s) => setStatChips([ | |
| 94 | + { label: "restaurants", value: s.restaurants }, | |
| 95 | + { label: "plats avec prix", value: s.items }, | |
| 96 | + { label: "régions couvertes", value: s.regions }, | |
| 97 | + { label: "chaînes suivies", value: s.chains }, | |
| 98 | + ])) | |
| 99 | + .catch(() => {}); | |
| 100 | + }, []); | |
| 101 | + | |
| 102 | + useEffect(() => { | |
| 103 | + fetchFacets(filters.region).then(setFacets).catch(() => {}); | |
| 104 | + }, [filters.region]); | |
| 105 | + | |
| 106 | + useEffect(() => { | |
| 107 | + setLoading(true); | |
| 108 | + setError(false); | |
| 109 | + if (mode === "plats") { | |
| 110 | + const q = (filters.q ?? "").trim(); | |
| 111 | + if (q.length < 2) { | |
| 112 | + setDishes([]); setTotal(0); setLoading(false); | |
| 113 | + return; | |
| 114 | + } | |
| 115 | + fetchDishes({ | |
| 116 | + q, region: filters.region, city: filters.city, cuisine: filters.cuisine, | |
| 117 | + limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE, | |
| 118 | + }) | |
| 119 | + .then((r) => { setDishes(r.dishes); setTotal(r.total); }) | |
| 120 | + .catch(() => setError(true)) | |
| 121 | + .finally(() => setLoading(false)); | |
| 122 | + } else { | |
| 123 | + const { mode: _m, ...rest } = filters; | |
| 124 | + fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE }) | |
| 125 | + .then((r) => { setRestos(r.restaurants); setTotal(r.total); }) | |
| 126 | + .catch(() => setError(true)) | |
| 127 | + .finally(() => setLoading(false)); | |
| 128 | + } | |
| 129 | + }, [filters, page, mode]); | |
| 130 | + | |
| 131 | + const activePills: { key: FilterKey; label: string }[] = []; | |
| 132 | + if (filters.region) activePills.push({ key: "region", label: filters.region }); | |
| 133 | + if (filters.city) activePills.push({ key: "city", label: filters.city }); | |
| 134 | + if (filters.cuisine) activePills.push({ key: "cuisine", label: cuisineLabel(filters.cuisine) }); | |
| 135 | + if (filters.establishment_type) activePills.push({ key: "establishment_type", label: typeLabel(filters.establishment_type) }); | |
| 136 | + if (filters.diet) activePills.push({ key: "diet", label: dietLabel(filters.diet) }); | |
| 137 | + if (filters.price_range) activePills.push({ key: "price_range", label: filters.price_range }); | |
| 138 | + if (filters.q) activePills.push({ key: "q", label: `« ${filters.q} »` }); | |
| 139 | + | |
| 140 | + const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); }; | |
| 141 | + | |
| 142 | + const filterbar = ( | |
| 143 | + <div className={`filterbar ${sheetOpen ? "open" : ""}`}> | |
| 144 | + <div className="sheet-head"> | |
| 145 | + Filtres | |
| 146 | + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer">✕</button> | |
| 147 | + </div> | |
| 148 | + <div className="f-primary"> | |
| 149 | + <div className="mode-toggle" role="tablist" aria-label="Chercher par"> | |
| 150 | + <button className={mode === "restos" ? "on" : ""} role="tab" | |
| 151 | + aria-selected={mode === "restos"} | |
| 152 | + onClick={() => setFilter("mode", "")}>Restos</button> | |
| 153 | + <button className={mode === "plats" ? "on" : ""} role="tab" | |
| 154 | + aria-selected={mode === "plats"} | |
| 155 | + onClick={() => setFilter("mode", "plats")}>Plats</button> | |
| 156 | + </div> | |
| 157 | + <label className="f-search"> | |
| 158 | + <IcoSearch size={16} /> | |
| 159 | + <input | |
| 160 | + type="search" | |
| 161 | + placeholder={mode === "plats" | |
| 162 | + ? "Poutine, tartare, pad thaï, sushi…" | |
| 163 | + : "Resto, chaîne, ville…"} | |
| 164 | + value={filters.q ?? ""} | |
| 165 | + onChange={(e) => setFilter("q", e.target.value)} | |
| 166 | + aria-label="Recherche" | |
| 167 | + /> | |
| 168 | + {filters.q && ( | |
| 169 | + <button className="f-clear" onClick={() => setFilter("q", "")} aria-label="Effacer">✕</button> | |
| 170 | + )} | |
| 171 | + </label> | |
| 172 | + <label className="f-ctl"> | |
| 173 | + <span>Région</span> | |
| 174 | + <select value={filters.region ?? ""} onChange={(e) => { setFilter("region", e.target.value); setFilter("city", ""); }}> | |
| 175 | + <option value="">Toutes</option> | |
| 176 | + {(facets?.regions ?? []).map((r) => ( | |
| 177 | + <option key={r.region} value={r.region}>{r.region} ({r.n})</option> | |
| 178 | + ))} | |
| 179 | + </select> | |
| 180 | + </label> | |
| 181 | + <label className="f-ctl"> | |
| 182 | + <span>Ville</span> | |
| 183 | + <select value={filters.city ?? ""} onChange={(e) => setFilter("city", e.target.value)}> | |
| 184 | + <option value="">Toutes</option> | |
| 185 | + {(facets?.cities ?? []).map((c) => ( | |
| 186 | + <option key={c} value={c}>{c}</option> | |
| 187 | + ))} | |
| 188 | + </select> | |
| 189 | + </label> | |
| 190 | + {mode === "restos" && ( | |
| 191 | + <> | |
| 192 | + <label className="f-ctl"> | |
| 193 | + <span>Type</span> | |
| 194 | + <select value={filters.establishment_type ?? ""} onChange={(e) => setFilter("establishment_type", e.target.value)}> | |
| 195 | + <option value="">Tous</option> | |
| 196 | + {(facets?.establishment_types ?? []).map((t) => ( | |
| 197 | + <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option> | |
| 198 | + ))} | |
| 199 | + </select> | |
| 200 | + </label> | |
| 201 | + <label className="f-ctl"> | |
| 202 | + <span>Diète</span> | |
| 203 | + <select value={filters.diet ?? ""} onChange={(e) => setFilter("diet", e.target.value)}> | |
| 204 | + <option value="">Toutes</option> | |
| 205 | + {(facets?.diets ?? []).map((d) => ( | |
| 206 | + <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option> | |
| 207 | + ))} | |
| 208 | + </select> | |
| 209 | + </label> | |
| 210 | + <label className="f-ctl"> | |
| 211 | + <span>Prix</span> | |
| 212 | + <select value={filters.price_range ?? ""} onChange={(e) => setFilter("price_range", e.target.value)}> | |
| 213 | + <option value="">Tous</option> | |
| 214 | + {["$", "$$", "$$$", "$$$$"].map((p) => ( | |
| 215 | + <option key={p} value={p}>{p}</option> | |
| 216 | + ))} | |
| 217 | + </select> | |
| 218 | + </label> | |
| 219 | + <label className="f-ctl"> | |
| 220 | + <span>Tri</span> | |
| 221 | + <select value={filters.sort ?? "name"} onChange={(e) => setFilter("sort", e.target.value)}> | |
| 222 | + <option value="name">Nom</option> | |
| 223 | + <option value="price">Fourchette de prix</option> | |
| 224 | + <option value="recent">Récents</option> | |
| 225 | + </select> | |
| 226 | + </label> | |
| 227 | + </> | |
| 228 | + )} | |
| 229 | + </div> | |
| 230 | + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> | |
| 231 | + Voir les {total} résultats | |
| 232 | + </button> | |
| 233 | + </div> | |
| 234 | + ); | |
| 235 | + | |
| 236 | + return ( | |
| 237 | + <div className="container"> | |
| 238 | + <section className="hero"> | |
| 239 | + <span className="kicker">Agrégateur — 17 régions du Québec</span> | |
| 240 | + <h1> | |
| 241 | + Tous les restos. Tous les menus.<br /> | |
| 242 | + <span className="hl">Tous les prix.</span> | |
| 243 | + </h1> | |
| 244 | + <p className="lede"> | |
| 245 | + Resto-Ka rassemble les restaurants du Québec avec leurs menus complets | |
| 246 | + et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez, | |
| 247 | + le contexte du prix (salle, emporter, livraison) est toujours affiché. | |
| 248 | + </p> | |
| 249 | + <div className="stat-row"> | |
| 250 | + <div className="stat-chip"><span className="pulse" />En direct</div> | |
| 251 | + {statChips.map((s) => ( | |
| 252 | + <div className="stat-chip" key={s.label}> | |
| 253 | + <b>{s.value.toLocaleString("fr-CA")}</b> {s.label} | |
| 254 | + </div> | |
| 255 | + ))} | |
| 256 | + </div> | |
| 257 | + <div className="benefits"> | |
| 258 | + <div className="benefit"> | |
| 259 | + <span className="benefit-ico"><IcoTag size={19} /></span> | |
| 260 | + <span className="benefit-label">Prix réels, jamais sans contexte</span> | |
| 261 | + </div> | |
| 262 | + <div className="benefit"> | |
| 263 | + <span className="benefit-ico blue"><IcoPin size={19} /></span> | |
| 264 | + <span className="benefit-label">17 régions du Québec</span> | |
| 265 | + </div> | |
| 266 | + <div className="benefit"> | |
| 267 | + <span className="benefit-ico"><IcoBag size={19} /></span> | |
| 268 | + <span className="benefit-label">Recherche par plat</span> | |
| 269 | + </div> | |
| 270 | + <div className="benefit"> | |
| 271 | + <span className="benefit-ico blue"><IcoLeaf size={19} /></span> | |
| 272 | + <span className="benefit-label">Filtres diète & cuisine</span> | |
| 273 | + </div> | |
| 274 | + </div> | |
| 275 | + </section> | |
| 276 | + | |
| 277 | + {/* chips cuisines (défilement horizontal) */} | |
| 278 | + <div className="chips" role="tablist" aria-label="Cuisines"> | |
| 279 | + <button className={`chip ${!filters.cuisine ? "on" : ""}`} | |
| 280 | + onClick={() => setFilter("cuisine", "")}>Toutes les cuisines</button> | |
| 281 | + {(facets?.cuisines ?? []).slice(0, 14).map((c) => ( | |
| 282 | + <button | |
| 283 | + key={c.cuisine} | |
| 284 | + className={`chip ${filters.cuisine === c.cuisine ? "on" : ""}`} | |
| 285 | + onClick={() => setFilter("cuisine", filters.cuisine === c.cuisine ? "" : c.cuisine)} | |
| 286 | + > | |
| 287 | + {cuisineLabel(c.cuisine)} · {c.n} | |
| 288 | + </button> | |
| 289 | + ))} | |
| 290 | + </div> | |
| 291 | + | |
| 292 | + {filterbar} | |
| 293 | + {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />} | |
| 294 | + <button className="fab" onClick={() => setSheetOpen(true)}> | |
| 295 | + <IcoSearch size={16} /> Filtres | |
| 296 | + </button> | |
| 297 | + | |
| 298 | + {activePills.length > 0 && ( | |
| 299 | + <div className="pills"> | |
| 300 | + {activePills.map((p) => ( | |
| 301 | + <button key={p.key} className="pill" onClick={() => setFilter(p.key, "")}> | |
| 302 | + {p.label} <span className="pill-x">✕</span> | |
| 303 | + </button> | |
| 304 | + ))} | |
| 305 | + <button className="pill pill-clear" onClick={clearAll}>Tout effacer</button> | |
| 306 | + </div> | |
| 307 | + )} | |
| 308 | + | |
| 309 | + <div className="results-head"> | |
| 310 | + <h2>{mode === "plats" ? "Plats" : "Restaurants"}</h2> | |
| 311 | + <span> | |
| 312 | + {mode === "plats" && (filters.q ?? "").trim().length < 2 | |
| 313 | + ? "tapez un plat (min. 2 lettres)" | |
| 314 | + : `${total.toLocaleString("fr-CA")} résultat${total > 1 ? "s" : ""}`} | |
| 315 | + </span> | |
| 316 | + </div> | |
| 317 | + | |
| 318 | + {error && ( | |
| 319 | + <div className="notice"> | |
| 320 | + <h2>Impossible de charger les résultats</h2> | |
| 321 | + <p>Réessayez dans un instant.</p> | |
| 322 | + </div> | |
| 323 | + )} | |
| 324 | + | |
| 325 | + {loading ? ( | |
| 326 | + <div className="grid"> | |
| 327 | + {Array.from({ length: 8 }).map((_, i) => ( | |
| 328 | + <div className="skel" key={i}> | |
| 329 | + <div className="sk-img" /> | |
| 330 | + <div className="sk-line" /> | |
| 331 | + <div className="sk-line short" /> | |
| 332 | + </div> | |
| 333 | + ))} | |
| 334 | + </div> | |
| 335 | + ) : mode === "plats" ? ( | |
| 336 | + (filters.q ?? "").trim().length < 2 ? ( | |
| 337 | + <div className="notice"> | |
| 338 | + <h2>Cherchez n'importe quel plat</h2> | |
| 339 | + <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus | |
| 340 | + du Québec sont indexés, avec leurs prix réels.</p> | |
| 341 | + </div> | |
| 342 | + ) : dishes.length === 0 ? ( | |
| 343 | + <div className="notice"> | |
| 344 | + <h2>Aucun plat trouvé</h2> | |
| 345 | + <p>Essayez un autre mot — la couverture grandit chaque semaine.</p> | |
| 346 | + </div> | |
| 347 | + ) : ( | |
| 348 | + <> | |
| 349 | + <div className="dish-list"> | |
| 350 | + {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)} | |
| 351 | + </div> | |
| 352 | + <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} /> | |
| 353 | + </> | |
| 354 | + ) | |
| 355 | + ) : restos.length === 0 && !error ? ( | |
| 356 | + <div className="notice"> | |
| 357 | + <h2>Aucun resto ne correspond</h2> | |
| 358 | + <p>Élargissez les filtres — la couverture grandit chaque semaine.</p> | |
| 359 | + </div> | |
| 360 | + ) : ( | |
| 361 | + <> | |
| 362 | + <div className="grid"> | |
| 363 | + {restos.map((r) => <RestoCard key={r.uid} r={r} />)} | |
| 364 | + </div> | |
| 365 | + <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} /> | |
| 366 | + </> | |
| 367 | + )} | |
| 368 | + </div> | |
| 369 | + ); | |
| 370 | +} | |
added
frontend/src/pages/Resto.tsx
+237 −0
@@ -0,0 +1,237 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: pages/Resto.tsx | |
| 4 | +// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka) | |
| 5 | +// + menu complet par sections avec prix, options et badge de CONTEXTE | |
| 6 | +// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte | |
| 7 | +// est inutilisable ; delivery affiché clairement étiqueté). | |
| 8 | +// ============================================================================== | |
| 9 | +import { useEffect, useState } from "react"; | |
| 10 | +import { Link, useParams } from "react-router-dom"; | |
| 11 | +import { | |
| 12 | + CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel, | |
| 13 | + fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel, | |
| 14 | +} from "../api"; | |
| 15 | +import { IcoCompass } from "../components/Icons"; | |
| 16 | + | |
| 17 | +function MenuBlock({ menu }: { menu: Menu }) { | |
| 18 | + const [openSections, setOpenSections] = useState<Set<number>>( | |
| 19 | + new Set(menu.sections.map((_, i) => i).slice(0, 4))); | |
| 20 | + const toggle = (i: number) => { | |
| 21 | + const next = new Set(openSections); | |
| 22 | + if (next.has(i)) next.delete(i); | |
| 23 | + else next.add(i); | |
| 24 | + setOpenSections(next); | |
| 25 | + }; | |
| 26 | + return ( | |
| 27 | + <section className="menu-block"> | |
| 28 | + <div className={`ctx-banner ctx-${menu.price_context}`}> | |
| 29 | + <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b> | |
| 30 | + <span> | |
| 31 | + source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)} | |
| 32 | + {" · "}{menu.item_count} plats | |
| 33 | + </span> | |
| 34 | + </div> | |
| 35 | + {menu.sections.map((sec, i) => ( | |
| 36 | + <div className="menu-section" key={`${sec.name}-${i}`}> | |
| 37 | + <button className="menu-section-head" onClick={() => toggle(i)} | |
| 38 | + aria-expanded={openSections.has(i)}> | |
| 39 | + {sec.image && <img className="ms-img" src={sec.image} alt="" loading="lazy" />} | |
| 40 | + <h3>{sec.name}</h3> | |
| 41 | + <span className="ms-meta">{sec.items.length} · {openSections.has(i) ? "−" : "+"}</span> | |
| 42 | + </button> | |
| 43 | + {openSections.has(i) && ( | |
| 44 | + <ul className="menu-items"> | |
| 45 | + {sec.items.map((it, j) => ( | |
| 46 | + <li className="menu-item" key={`${it.name}-${j}`}> | |
| 47 | + <div className="mi-row"> | |
| 48 | + <div className="mi-main"> | |
| 49 | + <div className="mi-line"> | |
| 50 | + <span className="mi-name"> | |
| 51 | + {it.name} | |
| 52 | + {(it.tags ?? []).map((t) => ( | |
| 53 | + <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em> | |
| 54 | + ))} | |
| 55 | + </span> | |
| 56 | + <span className="mi-dots" aria-hidden="true" /> | |
| 57 | + <span className="mi-price">{fmtPrice(it.price)}</span> | |
| 58 | + </div> | |
| 59 | + {it.description && <p className="mi-desc">{it.description}</p>} | |
| 60 | + </div> | |
| 61 | + {it.image && ( | |
| 62 | + <img className="mi-thumb" src={it.image} alt={it.name} loading="lazy" /> | |
| 63 | + )} | |
| 64 | + </div> | |
| 65 | + {(it.options ?? []).length > 0 && ( | |
| 66 | + <div className="mi-optgroups"> | |
| 67 | + {(it.options ?? []).map((op, k) => ( | |
| 68 | + <div className="og" key={`${op.group}-${k}`}> | |
| 69 | + <div className="og-head"> | |
| 70 | + <span className="og-name">{op.group}</span> | |
| 71 | + <span className={`og-req ${op.required ? "on" : ""}`}> | |
| 72 | + {op.required ? "Choix requis" : "Optionnel"} | |
| 73 | + </span> | |
| 74 | + </div> | |
| 75 | + <div className="og-choices"> | |
| 76 | + {op.choices.map((c, m) => ( | |
| 77 | + <span className="og-choice" key={`${c.name}-${m}`}> | |
| 78 | + {c.name} | |
| 79 | + {c.price_delta !== 0 && ( | |
| 80 | + <b className={c.price_delta > 0 ? "plus" : "moins"}> | |
| 81 | + {c.price_delta > 0 ? "+" : "−"} | |
| 82 | + {Math.abs(c.price_delta).toFixed(2).replace(".", ",")} $ | |
| 83 | + </b> | |
| 84 | + )} | |
| 85 | + </span> | |
| 86 | + ))} | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + ))} | |
| 90 | + </div> | |
| 91 | + )} | |
| 92 | + </li> | |
| 93 | + ))} | |
| 94 | + </ul> | |
| 95 | + )} | |
| 96 | + </div> | |
| 97 | + ))} | |
| 98 | + </section> | |
| 99 | + ); | |
| 100 | +} | |
| 101 | + | |
| 102 | +export default function RestoPage() { | |
| 103 | + const { uid } = useParams<{ uid: string }>(); | |
| 104 | + const [resto, setResto] = useState<Restaurant | null>(null); | |
| 105 | + const [notFound, setNotFound] = useState(false); | |
| 106 | + const [ctx, setCtx] = useState<string | null>(null); | |
| 107 | + | |
| 108 | + useEffect(() => { | |
| 109 | + if (!uid) return; | |
| 110 | + setResto(null); | |
| 111 | + setNotFound(false); | |
| 112 | + fetchRestaurant(uid) | |
| 113 | + .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); }) | |
| 114 | + .catch(() => setNotFound(true)); | |
| 115 | + window.scrollTo({ top: 0 }); | |
| 116 | + }, [uid]); | |
| 117 | + | |
| 118 | + if (notFound) { | |
| 119 | + return ( | |
| 120 | + <div className="notice container"> | |
| 121 | + <div className="big"><IcoCompass size={40} /></div> | |
| 122 | + <h2>Restaurant introuvable</h2> | |
| 123 | + <p><Link to="/" className="link-btn">Retour à la recherche</Link></p> | |
| 124 | + </div> | |
| 125 | + ); | |
| 126 | + } | |
| 127 | + if (!resto) { | |
| 128 | + return ( | |
| 129 | + <div className="container detail"> | |
| 130 | + <div className="skel"><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div> | |
| 131 | + </div> | |
| 132 | + ); | |
| 133 | + } | |
| 134 | + | |
| 135 | + const menus = resto.menus ?? []; | |
| 136 | + const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null; | |
| 137 | + | |
| 138 | + return ( | |
| 139 | + <div className="container detail"> | |
| 140 | + <nav className="crumbs" aria-label="Fil d'Ariane"> | |
| 141 | + <Link to="/">Restos</Link> › | |
| 142 | + {resto.region && ( | |
| 143 | + <> | |
| 144 | + <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> › | |
| 145 | + </> | |
| 146 | + )} | |
| 147 | + <span>{resto.name}</span> | |
| 148 | + </nav> | |
| 149 | + | |
| 150 | + <div className="detail-grid"> | |
| 151 | + <div> | |
| 152 | + {menus.length > 1 && ( | |
| 153 | + <div className="view-toggle ctx-toggle" role="tablist" aria-label="Contexte de prix"> | |
| 154 | + {menus.map((m) => ( | |
| 155 | + <button key={m.price_context} | |
| 156 | + className={m.price_context === menu?.price_context ? "on" : ""} | |
| 157 | + onClick={() => setCtx(m.price_context)}> | |
| 158 | + {CONTEXT_SHORT[m.price_context] ?? m.price_context} | |
| 159 | + </button> | |
| 160 | + ))} | |
| 161 | + </div> | |
| 162 | + )} | |
| 163 | + {menu ? <MenuBlock menu={menu} /> : ( | |
| 164 | + <div className="notice"> | |
| 165 | + <h2>Menu à venir</h2> | |
| 166 | + <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p> | |
| 167 | + </div> | |
| 168 | + )} | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + <aside className="panel"> | |
| 172 | + <div className="panel-head"> | |
| 173 | + {(resto.images ?? [])[0] && ( | |
| 174 | + <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" /> | |
| 175 | + )} | |
| 176 | + {resto.price_range && <div className="price">{resto.price_range}</div>} | |
| 177 | + </div> | |
| 178 | + <h1>{resto.name}</h1> | |
| 179 | + <div className="loc"> | |
| 180 | + {resto.address}{resto.address && resto.city ? ", " : ""}{resto.city} | |
| 181 | + {resto.postal_code ? ` (${resto.postal_code})` : ""} | |
| 182 | + </div> | |
| 183 | + <div className="kv"> | |
| 184 | + <div className="cell"> | |
| 185 | + <div className="k">Région</div> | |
| 186 | + <div className="v">{resto.region || "—"}</div> | |
| 187 | + </div> | |
| 188 | + <div className="cell"> | |
| 189 | + <div className="k">Type</div> | |
| 190 | + <div className="v">{typeLabel(resto.establishment_type)}</div> | |
| 191 | + </div> | |
| 192 | + {resto.chain && ( | |
| 193 | + <div className="cell"> | |
| 194 | + <div className="k">Chaîne</div> | |
| 195 | + <div className="v">{resto.chain}</div> | |
| 196 | + </div> | |
| 197 | + )} | |
| 198 | + <div className="cell"> | |
| 199 | + <div className="k">Statut</div> | |
| 200 | + <div className="v">{resto.status === "open" ? "Ouvert" : "Fermé temporairement"}</div> | |
| 201 | + </div> | |
| 202 | + </div> | |
| 203 | + {resto.cuisines.length > 0 && ( | |
| 204 | + <> | |
| 205 | + <div className="klabel">Cuisines</div> | |
| 206 | + <div className="amenity-row"> | |
| 207 | + {resto.cuisines.map((c) => ( | |
| 208 | + <span className="amenity" key={c}>{cuisineLabel(c)}</span> | |
| 209 | + ))} | |
| 210 | + </div> | |
| 211 | + </> | |
| 212 | + )} | |
| 213 | + {resto.dietary_options.length > 0 && ( | |
| 214 | + <> | |
| 215 | + <div className="klabel">Diètes offertes</div> | |
| 216 | + <div className="amenity-row"> | |
| 217 | + {resto.dietary_options.map((d) => ( | |
| 218 | + <span className="amenity" key={d}>{dietLabel(d)}</span> | |
| 219 | + ))} | |
| 220 | + </div> | |
| 221 | + </> | |
| 222 | + )} | |
| 223 | + {(resto.website || resto.url) && ( | |
| 224 | + <a className="cta" href={resto.url || resto.website} | |
| 225 | + target="_blank" rel="noopener noreferrer"> | |
| 226 | + Commander / voir chez le resto ↗ | |
| 227 | + </a> | |
| 228 | + )} | |
| 229 | + <div className="fine"> | |
| 230 | + Source : {sourceName(resto.source)} — les prix appartiennent au | |
| 231 | + resto et peuvent changer sans préavis. | |
| 232 | + </div> | |
| 233 | + </aside> | |
| 234 | + </div> | |
| 235 | + </div> | |
| 236 | + ); | |
| 237 | +} | |
added
frontend/src/pages/Sources.tsx
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: pages/Sources.tsx | |
| 4 | +// Desc: Registre public des sources connectées (même patron que la page | |
| 5 | +// Sources de Lou·Ka) : plateforme, contexte de prix produit, volumes, | |
| 6 | +// dernière synchronisation. | |
| 7 | +// ============================================================================== | |
| 8 | +import { useEffect, useState } from "react"; | |
| 9 | +import { CONTEXT_SHORT, Source, fetchSources, fmtTs } from "../api"; | |
| 10 | + | |
| 11 | +export default function SourcesPage() { | |
| 12 | + const [sources, setSources] = useState<Source[]>([]); | |
| 13 | + const [error, setError] = useState(false); | |
| 14 | + | |
| 15 | + useEffect(() => { | |
| 16 | + fetchSources().then((r) => setSources(r.sources)).catch(() => setError(true)); | |
| 17 | + }, []); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <div className="container sources"> | |
| 21 | + <span className="kicker">Transparence</span> | |
| 22 | + <h1>Les sources de Resto-Ka</h1> | |
| 23 | + <p className="sub"> | |
| 24 | + Chaque menu est capté à sa source et étiqueté selon son contexte de | |
| 25 | + prix. Les plateformes de commande des restos eux-mêmes (prix réels, | |
| 26 | + non majorés) passent toujours avant les plateformes de livraison. | |
| 27 | + </p> | |
| 28 | + {error && <div className="notice"><h2>Impossible de charger les sources</h2></div>} | |
| 29 | + <div className="src-wrap"> | |
| 30 | + <table className="src-table"> | |
| 31 | + <thead> | |
| 32 | + <tr> | |
| 33 | + <th>Source</th> | |
| 34 | + <th>Contexte de prix</th> | |
| 35 | + <th>Cadence</th> | |
| 36 | + <th>Restos actifs</th> | |
| 37 | + <th>Plats</th> | |
| 38 | + <th>Dernière synchro</th> | |
| 39 | + <th>Statut</th> | |
| 40 | + </tr> | |
| 41 | + </thead> | |
| 42 | + <tbody> | |
| 43 | + {sources.map((s) => ( | |
| 44 | + <tr key={s.id}> | |
| 45 | + <td> | |
| 46 | + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a> | |
| 47 | + {s.integrations && ( | |
| 48 | + <div className="src-integrations"> | |
| 49 | + {s.integrations.filter((i) => i.status !== "inactif").length} intégrations :{" "} | |
| 50 | + {s.integrations.filter((i) => i.status !== "inactif") | |
| 51 | + .map((i) => i.label).join(" · ")} | |
| 52 | + </div> | |
| 53 | + )} | |
| 54 | + </td> | |
| 55 | + <td> | |
| 56 | + <span className={`pill ctx-pill ctx-${s.price_context}`}> | |
| 57 | + {CONTEXT_SHORT[s.price_context ?? ""] ?? s.price_context} | |
| 58 | + </span> | |
| 59 | + </td> | |
| 60 | + <td>{s.cadence ?? "—"}</td> | |
| 61 | + <td><span className="count-pill">{s.active_restaurants}</span></td> | |
| 62 | + <td><span className="count-pill">{s.menu_items ?? 0}</span></td> | |
| 63 | + <td>{fmtTs(s.last_sync)}</td> | |
| 64 | + <td> | |
| 65 | + <span className={`pill ${s.status === "actif" ? "ok" : "todo"}`}> | |
| 66 | + {s.status} | |
| 67 | + </span> | |
| 68 | + </td> | |
| 69 | + </tr> | |
| 70 | + ))} | |
| 71 | + </tbody> | |
| 72 | + </table> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | +} | |
added
frontend/src/pages/Stats.tsx
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: pages/Stats.tsx | |
| 4 | +// Desc: Statistiques de couverture — tuiles + barres horizontales par région | |
| 5 | +// et par contexte de prix, journal des synchronisations (patron des | |
| 6 | +// tuiles/hbars de la page Stats de Lou·Ka). | |
| 7 | +// ============================================================================== | |
| 8 | +import { useEffect, useState } from "react"; | |
| 9 | +import { Link } from "react-router-dom"; | |
| 10 | +import { CONTEXT_LABELS, Stats, fetchStats, fmtTs, sourceName } from "../api"; | |
| 11 | + | |
| 12 | +function HBars({ rows }: { rows: { label: string; n: number; to?: string }[] }) { | |
| 13 | + const max = Math.max(1, ...rows.map((r) => r.n)); | |
| 14 | + return ( | |
| 15 | + <div className="hbars"> | |
| 16 | + {rows.map((r) => ( | |
| 17 | + <div className="hbar-row" key={r.label}> | |
| 18 | + <span className="hbar-label"> | |
| 19 | + {r.to ? <Link to={r.to}>{r.label}</Link> : r.label} | |
| 20 | + </span> | |
| 21 | + <span className="hbar-track"> | |
| 22 | + <span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} /> | |
| 23 | + </span> | |
| 24 | + <span className="hbar-value">{r.n.toLocaleString("fr-CA")}</span> | |
| 25 | + </div> | |
| 26 | + ))} | |
| 27 | + </div> | |
| 28 | + ); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export default function StatsPage() { | |
| 32 | + const [stats, setStats] = useState<Stats | null>(null); | |
| 33 | + const [error, setError] = useState(false); | |
| 34 | + | |
| 35 | + useEffect(() => { | |
| 36 | + fetchStats().then(setStats).catch(() => setError(true)); | |
| 37 | + }, []); | |
| 38 | + | |
| 39 | + if (error) return <div className="notice container"><h2>Stats indisponibles</h2></div>; | |
| 40 | + if (!stats) return <div className="notice container"><p>Chargement…</p></div>; | |
| 41 | + | |
| 42 | + return ( | |
| 43 | + <div className="container stats-page"> | |
| 44 | + <span className="kicker">Couverture</span> | |
| 45 | + <h1 className="stats-title">La table du Québec, en chiffres</h1> | |
| 46 | + <p className="sub"> | |
| 47 | + Volumes agrégés en direct — restaurants actifs, plats avec prix, | |
| 48 | + régions couvertes et fraîcheur des synchronisations. | |
| 49 | + </p> | |
| 50 | + | |
| 51 | + <div className="tiles"> | |
| 52 | + <div className="tile hero-tile"> | |
| 53 | + <div className="tile-v">{stats.restaurants.toLocaleString("fr-CA")}</div> | |
| 54 | + <div className="tile-k">Restaurants actifs</div> | |
| 55 | + </div> | |
| 56 | + <div className="tile"> | |
| 57 | + <div className="tile-v">{stats.items.toLocaleString("fr-CA")}</div> | |
| 58 | + <div className="tile-k">Plats avec prix</div> | |
| 59 | + </div> | |
| 60 | + <div className="tile"> | |
| 61 | + <div className="tile-v">{stats.menus.toLocaleString("fr-CA")}</div> | |
| 62 | + <div className="tile-k">Menus captés</div> | |
| 63 | + </div> | |
| 64 | + <div className="tile"> | |
| 65 | + <div className="tile-v">{stats.regions}</div> | |
| 66 | + <div className="tile-k">Régions couvertes / 17</div> | |
| 67 | + </div> | |
| 68 | + <div className="tile"> | |
| 69 | + <div className="tile-v">{stats.chains.toLocaleString("fr-CA")}</div> | |
| 70 | + <div className="tile-k">Chaînes suivies</div> | |
| 71 | + </div> | |
| 72 | + </div> | |
| 73 | + | |
| 74 | + <div className="viz-grid"> | |
| 75 | + <div className="viz-card"> | |
| 76 | + <h2>Restos par région</h2> | |
| 77 | + <div className="viz-sub">Fiches actives, doublons masqués</div> | |
| 78 | + <HBars rows={stats.by_region.map((r) => ({ | |
| 79 | + label: r.region, n: r.n, | |
| 80 | + to: `/?region=${encodeURIComponent(r.region)}`, | |
| 81 | + }))} /> | |
| 82 | + </div> | |
| 83 | + <div className="viz-card"> | |
| 84 | + <h2>Menus par contexte de prix</h2> | |
| 85 | + <div className="viz-sub">Un prix n'est jamais présenté sans son contexte</div> | |
| 86 | + <HBars rows={stats.by_context.map((c) => ({ | |
| 87 | + label: CONTEXT_LABELS[c.price_context] ?? c.price_context, n: c.n, | |
| 88 | + }))} /> | |
| 89 | + </div> | |
| 90 | + </div> | |
| 91 | + | |
| 92 | + <div className="viz-card"> | |
| 93 | + <h2>Dernières synchronisations</h2> | |
| 94 | + <div className="viz-sub">Journal du pipeline d'ingestion</div> | |
| 95 | + <div className="src-wrap" style={{ boxShadow: "none", border: "none" }}> | |
| 96 | + <table className="src-table"> | |
| 97 | + <thead> | |
| 98 | + <tr> | |
| 99 | + <th>Source</th><th>Quand</th><th>Trouvés</th><th>Ajoutés</th> | |
| 100 | + <th>Mis à jour</th><th>Retirés</th><th>État</th> | |
| 101 | + </tr> | |
| 102 | + </thead> | |
| 103 | + <tbody> | |
| 104 | + {stats.recent_syncs.map((s, i) => ( | |
| 105 | + <tr key={i}> | |
| 106 | + <td>{sourceName(s.source)}</td> | |
| 107 | + <td>{fmtTs(s.ts)}</td> | |
| 108 | + <td>{s.found}</td> | |
| 109 | + <td>{s.added}</td> | |
| 110 | + <td>{s.updated}</td> | |
| 111 | + <td>{s.removed}</td> | |
| 112 | + <td> | |
| 113 | + <span className={`pill ${s.ok ? "ok" : "todo"}`}> | |
| 114 | + {s.ok ? (s.message === "ok" ? "ok" : "alerte") : "échec"} | |
| 115 | + </span> | |
| 116 | + </td> | |
| 117 | + </tr> | |
| 118 | + ))} | |
| 119 | + </tbody> | |
| 120 | + </table> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + <div className="stats-foot"> | |
| 124 | + Les prix appartiennent aux restaurants et à leurs plateformes ; | |
| 125 | + Resto-Ka les agrège pour la découverte et renvoie toujours à la source. | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | +} | |
added
frontend/src/styles.css
+812 −0
@@ -0,0 +1,812 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | + File: styles.css | |
| 4 | + Desc: Identité visuelle Resto·Ka — système de design « éditorial sharp » | |
| 5 | + du GROUPE KA (identité partagée avec groupe-ka.com, Lou·Ka, Vrai-Prix, | |
| 6 | + ValoPlex) : | |
| 7 | + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono | |
| 8 | + · Palette : papier #f5f3ee · encre #141814 · vert profond #1c5c41 · | |
| 9 | + lime électrique #d9f26b · ambre #e8a33d | |
| 10 | + · Signature : bordures encre 1,5 px + ombres décalées dures (néo-brutalisme | |
| 11 | + raffiné), grain de film pleine page, surlignés lime inclinés | |
| 12 | + · Baseline : « Chaque resto, chaque plat, chaque prix. » | |
| 13 | + · 100 % adaptatif mobile (safe-areas iOS) | |
| 14 | +----------------------------------------------------------------------------- */ | |
| 15 | +:root { | |
| 16 | + /* palette officielle Groupe KA (éditorial sharp) */ | |
| 17 | + --paper: #f5f3ee; | |
| 18 | + --surface: #ffffff; | |
| 19 | + --surface-2: #faf9f5; | |
| 20 | + --ink: #141814; | |
| 21 | + --ink-2: #4d5551; | |
| 22 | + --ink-3: #8b928c; | |
| 23 | + --line: rgba(20, 24, 20, 0.14); | |
| 24 | + --line-strong: rgba(20, 24, 20, 0.85); | |
| 25 | + --green: #1c5c41; | |
| 26 | + --green-deep: #123f2e; | |
| 27 | + --lime: #d9f26b; | |
| 28 | + --lime-soft: #f0f9d2; | |
| 29 | + --amber: #e8a33d; | |
| 30 | + --amber-soft: #fdf3e2; | |
| 31 | + --danger: #b3423a; | |
| 32 | + --danger-soft: #fbe9e7; | |
| 33 | + | |
| 34 | + /* géométrie sharp : petits rayons, bordures franches */ | |
| 35 | + --r-card: 10px; | |
| 36 | + --r-ctl: 6px; | |
| 37 | + --r-pill: 999px; | |
| 38 | + | |
| 39 | + /* ombres décalées dures — la signature du groupe */ | |
| 40 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 41 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 42 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 43 | + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18); | |
| 44 | + | |
| 45 | + --font-display: "Space Grotesk", system-ui, sans-serif; | |
| 46 | + --font-body: "Inter", system-ui, sans-serif; | |
| 47 | + --font-mono: "JetBrains Mono", ui-monospace, monospace; | |
| 48 | +} | |
| 49 | + | |
| 50 | +* { box-sizing: border-box; } | |
| 51 | +html { scroll-behavior: smooth; } | |
| 52 | +body { | |
| 53 | + margin: 0; | |
| 54 | + background: var(--paper); | |
| 55 | + color: var(--ink); | |
| 56 | + font-family: var(--font-body); | |
| 57 | + font-size: 15px; | |
| 58 | + line-height: 1.55; | |
| 59 | + -webkit-font-smoothing: antialiased; | |
| 60 | + padding-bottom: env(safe-area-inset-bottom); | |
| 61 | +} | |
| 62 | +/* grain de film pleine page (signature Groupe KA) */ | |
| 63 | +body::before { | |
| 64 | + content: ""; | |
| 65 | + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999; | |
| 66 | + 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' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 67 | +} | |
| 68 | +#root { position: relative; z-index: 1; } | |
| 69 | + | |
| 70 | +h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.03em; margin: 0; } | |
| 71 | +a { color: inherit; text-decoration: none; } | |
| 72 | +button { font-family: inherit; } | |
| 73 | +img { display: block; } | |
| 74 | +::selection { background: var(--lime); color: var(--ink); } | |
| 75 | +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } | |
| 76 | + | |
| 77 | +/* surligné lime incliné — la marque des titres du groupe */ | |
| 78 | +h1 .hl, h2 .hl, .fbaseline .hl { | |
| 79 | + display: inline-block; background: var(--lime); color: var(--ink); | |
| 80 | + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg); | |
| 81 | +} | |
| 82 | +.kicker { | |
| 83 | + display: inline-flex; align-items: center; gap: 10px; | |
| 84 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; | |
| 85 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green); | |
| 86 | +} | |
| 87 | +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 88 | + | |
| 89 | +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; } | |
| 90 | +@media (max-width: 640px) { .container { padding: 0 16px; } } | |
| 91 | + | |
| 92 | +/* ================= Header ================= */ | |
| 93 | +.header { | |
| 94 | + position: sticky; top: 0; z-index: 50; | |
| 95 | + background: rgba(245, 243, 238, 0.88); | |
| 96 | + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); | |
| 97 | + border-bottom: 1.5px solid var(--ink); | |
| 98 | +} | |
| 99 | +.header-inner { display: flex; align-items: center; gap: 20px; height: 66px; } | |
| 100 | +.brand { | |
| 101 | + font-family: var(--font-display); font-weight: 700; font-size: 24px; | |
| 102 | + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; | |
| 103 | + color: var(--ink); | |
| 104 | +} | |
| 105 | +.brand .logo-ico { margin-right: 11px; flex: none; } | |
| 106 | +/* « Ka » en pastille lime inclinée — même geste que la carte membre Groupe KA */ | |
| 107 | +.brand .ka { | |
| 108 | + background: var(--lime); color: var(--ink); | |
| 109 | + padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; | |
| 110 | + display: inline-block; transform: rotate(-2deg); | |
| 111 | +} | |
| 112 | +.brand-tag { | |
| 113 | + font-family: var(--font-mono); font-size: 10px; font-weight: 500; | |
| 114 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); | |
| 115 | + margin-left: 14px; | |
| 116 | +} | |
| 117 | +@media (max-width: 900px) { .brand-tag { display: none; } } | |
| 118 | +.nav { margin-left: auto; display: flex; gap: 6px; } | |
| 119 | +.nav a { | |
| 120 | + padding: 8px 16px; border: 1.5px solid transparent; border-radius: var(--r-ctl); | |
| 121 | + font-family: var(--font-display); font-weight: 700; font-size: 14px; | |
| 122 | + color: var(--ink); transition: all 0.13s ease; min-height: 38px; | |
| 123 | + display: inline-flex; align-items: center; | |
| 124 | +} | |
| 125 | +.nav a:hover { background: var(--lime); border-color: var(--ink); } | |
| 126 | +.nav a.active { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 127 | + | |
| 128 | +/* ================= Ticker ================= */ | |
| 129 | +.ticker { | |
| 130 | + background: var(--ink); color: rgba(245, 243, 238, 0.85); overflow: hidden; | |
| 131 | + font-family: var(--font-mono); font-size: 11px; font-weight: 500; | |
| 132 | + letter-spacing: 0.08em; text-transform: uppercase; padding: 7px 0; | |
| 133 | + white-space: nowrap; border-bottom: 1.5px solid var(--ink); | |
| 134 | +} | |
| 135 | +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; } | |
| 136 | +.ticker span { padding: 0 26px; position: relative; } | |
| 137 | +.ticker span::after { content: "◆"; position: absolute; right: -6px; color: var(--lime); opacity: 0.9; font-size: 8px; top: 3px; } | |
| 138 | +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } } | |
| 139 | +@media (prefers-reduced-motion: reduce) { | |
| 140 | + .ticker-track { animation: none; } | |
| 141 | + * { transition-duration: 0.01ms !important; } | |
| 142 | +} | |
| 143 | + | |
| 144 | +/* ================= Hero ================= */ | |
| 145 | +.hero { padding: 58px 0 22px; } | |
| 146 | +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } } | |
| 147 | +.hero h1 { | |
| 148 | + font-size: clamp(36px, 5.4vw, 58px); font-weight: 700; line-height: 1.04; | |
| 149 | + letter-spacing: -0.04em; max-width: 920px; margin-top: 16px; color: var(--ink); | |
| 150 | +} | |
| 151 | +.hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; } | |
| 152 | +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } | |
| 153 | +.stat-chip { | |
| 154 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 155 | + padding: 7px 14px; font-size: 12.5px; font-weight: 500; | |
| 156 | + color: var(--ink-2); display: flex; gap: 8px; align-items: center; | |
| 157 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); | |
| 158 | +} | |
| 159 | +.stat-chip b { color: var(--ink); font-weight: 700; font-family: var(--font-display); } | |
| 160 | +.stat-chip .pulse { | |
| 161 | + width: 8px; height: 8px; border-radius: 50%; background: var(--green); | |
| 162 | + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; | |
| 163 | +} | |
| 164 | +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(28, 92, 65, 0.18); } } | |
| 165 | + | |
| 166 | +/* ================= Rangée d'avantages ================= */ | |
| 167 | +.benefits { | |
| 168 | + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| 169 | + gap: 14px; margin-top: 30px; | |
| 170 | +} | |
| 171 | +.benefit { display: flex; align-items: center; gap: 12px; } | |
| 172 | +.benefit-ico { | |
| 173 | + width: 42px; height: 42px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 174 | + flex: none; display: flex; align-items: center; justify-content: center; | |
| 175 | + background: var(--lime); color: var(--ink); box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); | |
| 176 | +} | |
| 177 | +.benefit-ico.blue { background: var(--surface); color: var(--green); } | |
| 178 | +.benefit-label { | |
| 179 | + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; | |
| 180 | + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink); | |
| 181 | +} | |
| 182 | +@media (max-width: 640px) { .benefits { grid-template-columns: 1fr 1fr; gap: 12px; } } | |
| 183 | + | |
| 184 | +/* ================= Filter bar ================= */ | |
| 185 | +.filterbar { | |
| 186 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 187 | + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 20px 0 6px; | |
| 188 | + display: flex; flex-direction: column; gap: 0; min-width: 0; | |
| 189 | +} | |
| 190 | +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; } | |
| 191 | +.f-search { | |
| 192 | + flex: 1 1 220px; min-width: 0; max-width: 440px; align-self: center; | |
| 193 | + display: flex; align-items: center; gap: 8px; | |
| 194 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface-2); | |
| 195 | + padding: 0 13px; min-height: 46px; color: var(--ink-2); | |
| 196 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08); | |
| 197 | + transition: box-shadow 0.15s ease, background 0.15s ease; | |
| 198 | +} | |
| 199 | +.f-search:focus-within { | |
| 200 | + background: var(--white, #fff); outline: 2px solid var(--green); outline-offset: 1px; | |
| 201 | + box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25); | |
| 202 | +} | |
| 203 | +.f-search input { | |
| 204 | + border: none; background: none; outline: none; flex: 1; min-width: 0; | |
| 205 | + font-size: 14px; color: var(--ink); font-family: inherit; | |
| 206 | +} | |
| 207 | +.f-clear { | |
| 208 | + border: 1px solid var(--line); background: var(--paper); color: var(--ink-2); | |
| 209 | + border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer; | |
| 210 | + flex: none; display: grid; place-items: center; | |
| 211 | +} | |
| 212 | +.f-ctl { | |
| 213 | + flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; | |
| 214 | + gap: 2px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); | |
| 215 | + padding: 7px 14px 6px; min-height: 52px; cursor: pointer; | |
| 216 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08); | |
| 217 | + transition: box-shadow 0.15s ease; | |
| 218 | +} | |
| 219 | +.f-ctl:focus-within { outline: 2px solid var(--green); outline-offset: 1px; box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25); } | |
| 220 | +.f-ctl > span { | |
| 221 | + font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 222 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); | |
| 223 | +} | |
| 224 | +.f-ctl select { | |
| 225 | + border: none; background: transparent; outline: none; font-family: var(--font-display); | |
| 226 | + font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; | |
| 227 | + appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px; | |
| 228 | + text-overflow: ellipsis; | |
| 229 | + 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"); | |
| 230 | + background-repeat: no-repeat; background-position: right center; | |
| 231 | +} | |
| 232 | + | |
| 233 | +/* pastilles de filtres actifs */ | |
| 234 | +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; } | |
| 235 | +.pill { | |
| 236 | + display: inline-flex; align-items: center; gap: 7px; | |
| 237 | + border: 1.5px solid var(--ink); background: var(--lime); color: var(--ink); | |
| 238 | + border-radius: var(--r-ctl); padding: 5px 12px; font-size: 12.5px; font-weight: 700; | |
| 239 | + font-family: var(--font-display); cursor: pointer; | |
| 240 | + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2); transition: all 0.12s ease; | |
| 241 | +} | |
| 242 | +.pill:hover { background: var(--ink); color: var(--lime); } | |
| 243 | +.pill-x { font-size: 10px; opacity: 0.65; } | |
| 244 | +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); } | |
| 245 | +.pill-clear:hover { background: var(--danger); color: #fff; } | |
| 246 | +.link-btn { | |
| 247 | + background: none; border: none; padding: 0; color: var(--green); font: inherit; | |
| 248 | + text-decoration: underline; cursor: pointer; | |
| 249 | +} | |
| 250 | +.btn { | |
| 251 | + display: inline-flex; align-items: center; justify-content: center; gap: 8px; | |
| 252 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 10px 20px; | |
| 253 | + font-family: var(--font-display); font-weight: 700; font-size: 14px; | |
| 254 | + cursor: pointer; min-height: 44px; | |
| 255 | + transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.1s ease; | |
| 256 | +} | |
| 257 | +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; } | |
| 258 | +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: var(--shadow-off-mid); } | |
| 259 | +.btn-primary:hover { background: var(--green-deep); } | |
| 260 | +.btn-ghost { background: transparent; color: var(--ink); } | |
| 261 | +.btn-ghost:hover { background: var(--lime); } | |
| 262 | +.btn:disabled { opacity: 0.4; cursor: default; } | |
| 263 | + | |
| 264 | +/* ================= Chips ================= */ | |
| 265 | +.chips { display: flex; gap: 8px; margin: 18px 0 4px; overflow-x: auto; padding: 4px 4px 8px 0; scrollbar-width: none; } | |
| 266 | +.chips::-webkit-scrollbar { display: none; } | |
| 267 | +.chip { | |
| 268 | + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); | |
| 269 | + border-radius: var(--r-ctl); padding: 8px 16px; font-size: 13px; font-weight: 700; | |
| 270 | + cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px; | |
| 271 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); transition: all 0.13s ease; | |
| 272 | +} | |
| 273 | +.chip:hover { background: var(--lime-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.15); } | |
| 274 | +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.3); } | |
| 275 | + | |
| 276 | +/* ================= Results ================= */ | |
| 277 | +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; } | |
| 278 | +.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); } | |
| 279 | +.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; } | |
| 280 | +.grid { | |
| 281 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); | |
| 282 | + gap: 22px; padding-bottom: 30px; | |
| 283 | +} | |
| 284 | +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } } | |
| 285 | + | |
| 286 | +.card { | |
| 287 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 288 | + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft); | |
| 289 | + transition: transform 0.18s ease, box-shadow 0.18s ease; | |
| 290 | +} | |
| 291 | +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); } | |
| 292 | +.card:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } | |
| 293 | +.card-img { position: relative; aspect-ratio: 16/9; background: var(--surface-2); overflow: hidden; border-bottom: 1.5px solid var(--ink); } | |
| 294 | +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; } | |
| 295 | +.card:hover .card-img img { transform: scale(1.04); } | |
| 296 | +.card-img .noimg { | |
| 297 | + display: flex; align-items: center; justify-content: center; height: 100%; | |
| 298 | + color: var(--ink-3); | |
| 299 | + background: | |
| 300 | + radial-gradient(circle at 82% 20%, var(--lime-soft) 0, transparent 46%), | |
| 301 | + radial-gradient(circle at 12% 85%, var(--amber-soft) 0, transparent 42%), | |
| 302 | + var(--surface-2); | |
| 303 | +} | |
| 304 | +.card-logo { max-width: 46%; max-height: 68%; object-fit: contain; } | |
| 305 | +.card:hover .card-logo { transform: none; } | |
| 306 | +.badge { | |
| 307 | + position: absolute; top: 10px; left: 10px; border: 1.5px solid var(--ink); | |
| 308 | + border-radius: var(--r-ctl); padding: 3px 10px; | |
| 309 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; letter-spacing: 0.05em; | |
| 310 | + background: rgba(255, 255, 255, 0.95); color: var(--ink); | |
| 311 | + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.25); | |
| 312 | +} | |
| 313 | +.badge.type { background: var(--ink); color: var(--lime); } | |
| 314 | +.badge.right { left: auto; right: 10px; } | |
| 315 | +/* badge de contexte de prix — TOUJOURS visible (un prix sans contexte est inutilisable) */ | |
| 316 | +.badge.ctx-takeout, .ctx-pill.ctx-takeout { background: var(--lime); color: var(--ink); } | |
| 317 | +.badge.ctx-dine-in, .ctx-pill.ctx-dine-in { background: var(--green); color: #fff; } | |
| 318 | +.badge.ctx-delivery, .ctx-pill.ctx-delivery { background: var(--amber); color: var(--ink); } | |
| 319 | +.card-body { padding: 15px 17px; display: flex; flex-direction: column; gap: 6px; flex: 1; } | |
| 320 | +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.03em; color: var(--ink); } | |
| 321 | +.card-price small { font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; } | |
| 322 | +.card-price .no-menu { font-size: 14px; font-weight: 600; color: var(--ink-3); } | |
| 323 | +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); } | |
| 324 | +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; } | |
| 325 | +.card-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; } | |
| 326 | +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; } | |
| 327 | +.source-tag { | |
| 328 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 329 | + text-transform: uppercase; letter-spacing: 0.06em; color: var(--green); | |
| 330 | + background: var(--lime-soft); border: 1px solid rgba(28, 92, 65, 0.35); | |
| 331 | + border-radius: var(--r-ctl); padding: 3px 9px; | |
| 332 | + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; | |
| 333 | +} | |
| 334 | +.avail { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); font-weight: 500; text-align: right; } | |
| 335 | + | |
| 336 | +/* ================= Bascule Restos / Plats ================= */ | |
| 337 | +.mode-toggle { | |
| 338 | + display: inline-flex; align-self: center; border: 1.5px solid var(--ink); | |
| 339 | + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); | |
| 340 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); flex: none; | |
| 341 | +} | |
| 342 | +.mode-toggle button { | |
| 343 | + border: 0; background: transparent; padding: 12px 20px; cursor: pointer; | |
| 344 | + font-family: var(--font-display); font-size: 14px; font-weight: 700; color: var(--ink-2); | |
| 345 | + min-height: 46px; | |
| 346 | +} | |
| 347 | +.mode-toggle button + button { border-left: 1.5px solid var(--ink); } | |
| 348 | +.mode-toggle button.on { background: var(--ink); color: var(--lime); } | |
| 349 | + | |
| 350 | +/* ================= Résultats par plat ================= */ | |
| 351 | +.dish-list { | |
| 352 | + display: flex; flex-direction: column; gap: 12px; padding-bottom: 10px; | |
| 353 | +} | |
| 354 | +.dish-row { | |
| 355 | + display: flex; gap: 14px; align-items: flex-start; | |
| 356 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 357 | + border-radius: var(--r-card); padding: 14px 16px; | |
| 358 | + box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.08); | |
| 359 | + transition: transform 0.15s ease, box-shadow 0.15s ease; | |
| 360 | +} | |
| 361 | +.dish-row:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); } | |
| 362 | +.dish-img { | |
| 363 | + width: 62px; height: 62px; flex: none; object-fit: cover; | |
| 364 | + border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2); | |
| 365 | +} | |
| 366 | +.dish-noimg { display: flex; align-items: center; justify-content: center; color: var(--ink-3); } | |
| 367 | +.dish-main { flex: 1; min-width: 0; } | |
| 368 | +.dish-line { display: flex; align-items: baseline; gap: 10px; } | |
| 369 | +.dish-name { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); } | |
| 370 | +.dish-price { font-family: var(--font-display); font-weight: 700; font-size: 16.5px; color: var(--ink); white-space: nowrap; } | |
| 371 | +.dish-desc { | |
| 372 | + margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 620px; | |
| 373 | + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; | |
| 374 | +} | |
| 375 | +.dish-meta { | |
| 376 | + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; | |
| 377 | + margin-top: 7px; font-size: 12px; color: var(--ink-2); | |
| 378 | +} | |
| 379 | +.dish-meta b { color: var(--ink); } | |
| 380 | +.dish-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; } | |
| 381 | +.dish-meta .ctx-pill { | |
| 382 | + border: 1px solid var(--ink); border-radius: 4px; padding: 2px 8px; | |
| 383 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 384 | + text-transform: uppercase; letter-spacing: 0.05em; | |
| 385 | +} | |
| 386 | + | |
| 387 | +/* ================= Pager (numéroté) ================= */ | |
| 388 | +.pager { | |
| 389 | + display: flex; align-items: center; justify-content: center; gap: 6px; | |
| 390 | + flex-wrap: wrap; padding: 18px 0 60px; | |
| 391 | +} | |
| 392 | +.pg-btn { | |
| 393 | + min-width: 40px; min-height: 40px; padding: 0 12px; | |
| 394 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 395 | + background: var(--surface); color: var(--ink); cursor: pointer; | |
| 396 | + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; | |
| 397 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); | |
| 398 | + transition: all 0.12s ease; | |
| 399 | +} | |
| 400 | +.pg-btn:hover:not(:disabled) { background: var(--lime-soft); } | |
| 401 | +.pg-btn.on { background: var(--ink); color: var(--lime); } | |
| 402 | +.pg-btn:disabled { opacity: 0.35; cursor: default; box-shadow: none; } | |
| 403 | +.pg-btn:active:not(:disabled) { transform: translate(2px, 2px); box-shadow: none; } | |
| 404 | +.pg-ellipsis { color: var(--ink-3); font-family: var(--font-mono); padding: 0 4px; } | |
| 405 | + | |
| 406 | +/* ================= Skeletons ================= */ | |
| 407 | +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } | |
| 408 | +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line-strong); overflow: hidden; background: var(--surface); } | |
| 409 | +.skel .sk-img, .skel .sk-line { | |
| 410 | + background: linear-gradient(90deg, #eceae4 25%, #f6f4ef 50%, #eceae4 75%); | |
| 411 | + background-size: 800px 100%; animation: shimmer 1.4s infinite linear; | |
| 412 | +} | |
| 413 | +.skel .sk-img { aspect-ratio: 16/9; } | |
| 414 | +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; } | |
| 415 | +.skel .sk-line.short { width: 45%; } | |
| 416 | + | |
| 417 | +/* ================= Empty / error ================= */ | |
| 418 | +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); } | |
| 419 | +.notice .big { font-size: 44px; margin-bottom: 10px; color: var(--green); } | |
| 420 | +.notice h2 { color: var(--ink); } | |
| 421 | + | |
| 422 | +/* ================= Fiche resto ================= */ | |
| 423 | +.detail { padding: 30px 0 90px; } | |
| 424 | +.crumbs { | |
| 425 | + font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; | |
| 426 | + letter-spacing: 0.08em; text-transform: uppercase; | |
| 427 | + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; | |
| 428 | +} | |
| 429 | +.crumbs a { border-bottom: 1.5px solid transparent; } | |
| 430 | +.crumbs a:hover { color: var(--green); border-color: var(--green); } | |
| 431 | +.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } | |
| 432 | +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } } | |
| 433 | +.detail-grid > * { min-width: 0; } | |
| 434 | + | |
| 435 | +.panel { | |
| 436 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 437 | + box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px; | |
| 438 | +} | |
| 439 | +@media (max-width: 900px) { .panel { position: static; order: -1; } } | |
| 440 | +.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } | |
| 441 | +.panel-logo { max-width: 110px; max-height: 54px; object-fit: contain; border-radius: var(--r-ctl); } | |
| 442 | +.panel .price { | |
| 443 | + font-family: var(--font-display); font-size: 30px; font-weight: 700; | |
| 444 | + letter-spacing: -0.03em; color: var(--ink); | |
| 445 | + background: var(--lime); border-radius: 8px; padding: 0 10px 2px; | |
| 446 | + display: inline-block; transform: rotate(-1deg); | |
| 447 | +} | |
| 448 | +.panel h1 { font-size: 23px; margin: 10px 0 2px; } | |
| 449 | +.panel .loc { color: var(--ink-2); font-size: 14px; } | |
| 450 | +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; } | |
| 451 | +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } } | |
| 452 | +.kv .cell { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 10px 14px; } | |
| 453 | +.kv .cell .k { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); font-weight: 700; } | |
| 454 | +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); color: var(--ink); } | |
| 455 | +.klabel { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; margin-bottom: 8px; } | |
| 456 | +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; } | |
| 457 | +.amenity { | |
| 458 | + background: var(--lime-soft); color: var(--green); font-size: 12px; font-weight: 600; | |
| 459 | + border: 1px solid rgba(28, 92, 65, 0.35); border-radius: var(--r-ctl); padding: 4px 11px; | |
| 460 | +} | |
| 461 | +.cta { | |
| 462 | + display: block; text-align: center; background: var(--ink); color: var(--lime); | |
| 463 | + font-weight: 700; font-family: var(--font-display); border: 1.5px solid var(--ink); | |
| 464 | + border-radius: var(--r-ctl); padding: 14px; min-height: 48px; | |
| 465 | + box-shadow: var(--shadow-off-mid); transition: all 0.14s ease; | |
| 466 | +} | |
| 467 | +.cta:hover { background: var(--green-deep); } | |
| 468 | +.cta:active { transform: translate(2px, 2px); box-shadow: none; } | |
| 469 | +.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; } | |
| 470 | + | |
| 471 | +/* --- menu (sections, items, options) --------------------------------------- */ | |
| 472 | +.view-toggle { | |
| 473 | + display: inline-flex; border: 1.5px solid var(--ink); | |
| 474 | + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); | |
| 475 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); | |
| 476 | +} | |
| 477 | +.view-toggle button { | |
| 478 | + border: 0; background: transparent; padding: 8px 16px; cursor: pointer; | |
| 479 | + font-family: var(--font-display); font-size: 12.5px; font-weight: 700; color: var(--ink-2); | |
| 480 | +} | |
| 481 | +.view-toggle button + button { border-left: 1.5px solid var(--ink); } | |
| 482 | +.view-toggle button.on { background: var(--ink); color: var(--lime); } | |
| 483 | +.ctx-toggle { margin-bottom: 14px; } | |
| 484 | + | |
| 485 | +.menu-block { display: flex; flex-direction: column; gap: 16px; } | |
| 486 | +.ctx-banner { | |
| 487 | + border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 12px 18px; | |
| 488 | + display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline; | |
| 489 | + font-size: 12.5px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1); | |
| 490 | +} | |
| 491 | +.ctx-banner b { font-family: var(--font-display); font-size: 14px; } | |
| 492 | +.ctx-banner span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); } | |
| 493 | +.ctx-banner.ctx-takeout { background: var(--lime-soft); } | |
| 494 | +.ctx-banner.ctx-takeout b { color: var(--green); } | |
| 495 | +.ctx-banner.ctx-dine-in { background: var(--surface); } | |
| 496 | +.ctx-banner.ctx-dine-in b { color: var(--green-deep); } | |
| 497 | +.ctx-banner.ctx-delivery { background: var(--amber-soft); } | |
| 498 | +.ctx-banner.ctx-delivery b { color: #9a6414; } | |
| 499 | + | |
| 500 | +.menu-section { | |
| 501 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 502 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; | |
| 503 | +} | |
| 504 | +.menu-section-head { | |
| 505 | + width: 100%; display: flex; align-items: center; gap: 12px; | |
| 506 | + border: none; background: none; cursor: pointer; | |
| 507 | + padding: 14px 18px; text-align: left; | |
| 508 | +} | |
| 509 | +.menu-section-head h3 { font-size: 17px; color: var(--ink); letter-spacing: -0.02em; flex: 1; } | |
| 510 | +.menu-section-head:hover { background: var(--lime-soft); } | |
| 511 | +.ms-img { | |
| 512 | + width: 36px; height: 36px; object-fit: cover; border-radius: var(--r-ctl); | |
| 513 | + border: 1.5px solid var(--ink); flex: none; | |
| 514 | +} | |
| 515 | +.ms-meta { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 600; white-space: nowrap; } | |
| 516 | +.menu-items { list-style: none; margin: 0; padding: 0 18px 14px; } | |
| 517 | +.menu-item { padding: 12px 0; border-top: 1px solid var(--line); } | |
| 518 | +.mi-row { display: flex; gap: 14px; align-items: flex-start; } | |
| 519 | +.mi-main { flex: 1; min-width: 0; } | |
| 520 | +.mi-line { display: flex; align-items: baseline; gap: 10px; } | |
| 521 | +.mi-name { font-weight: 600; font-size: 14.5px; color: var(--ink); } | |
| 522 | +.mi-dots { flex: 1; border-bottom: 2px dotted var(--line-strong); opacity: 0.35; transform: translateY(-3px); min-width: 20px; } | |
| 523 | +.mi-price { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); white-space: nowrap; } | |
| 524 | +.mi-desc { margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 560px; } | |
| 525 | +.mi-thumb { | |
| 526 | + width: 68px; height: 68px; flex: none; object-fit: cover; | |
| 527 | + border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2); | |
| 528 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); | |
| 529 | +} | |
| 530 | +.mi-tag { | |
| 531 | + font-style: normal; font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 532 | + text-transform: uppercase; letter-spacing: 0.06em; border-radius: 4px; | |
| 533 | + padding: 2px 7px; margin-left: 7px; | |
| 534 | + background: var(--lime-soft); color: var(--green); border: 1px solid rgba(28, 92, 65, 0.3); | |
| 535 | + vertical-align: 2px; | |
| 536 | +} | |
| 537 | +.mi-tag-epice { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.5); } | |
| 538 | +.mi-tag-rupture { background: var(--surface-2); color: var(--ink-3); border-color: var(--line); } | |
| 539 | + | |
| 540 | +/* groupes d'options : blocs structurés, choix en pastilles avec ± prix */ | |
| 541 | +.mi-optgroups { | |
| 542 | + margin-top: 10px; display: flex; flex-direction: column; gap: 8px; | |
| 543 | + background: var(--surface-2); border: 1px solid var(--line); | |
| 544 | + border-radius: var(--r-ctl); padding: 10px 12px; | |
| 545 | +} | |
| 546 | +.og-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } | |
| 547 | +.og-name { | |
| 548 | + font-family: var(--font-mono); font-weight: 700; color: var(--ink); | |
| 549 | + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; | |
| 550 | +} | |
| 551 | +.og-req { | |
| 552 | + font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 553 | + text-transform: uppercase; letter-spacing: 0.05em; border-radius: 4px; padding: 2px 7px; | |
| 554 | + background: var(--surface); color: var(--ink-3); border: 1px solid var(--line); | |
| 555 | +} | |
| 556 | +.og-req.on { background: var(--lime); color: var(--ink); border-color: var(--ink); } | |
| 557 | +.og-choices { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 558 | +.og-choice { | |
| 559 | + display: inline-flex; align-items: center; gap: 6px; | |
| 560 | + background: var(--surface); border: 1px solid var(--line-strong); | |
| 561 | + border-radius: var(--r-ctl); padding: 4px 10px; | |
| 562 | + font-size: 12px; color: var(--ink-2); | |
| 563 | +} | |
| 564 | +.og-choice b { font-family: var(--font-mono); font-weight: 700; font-size: 11px; } | |
| 565 | +.og-choice b.plus { color: #9a6414; } | |
| 566 | +.og-choice b.moins { color: var(--green); } | |
| 567 | + | |
| 568 | +/* ================= Sources page ================= */ | |
| 569 | +.sources { padding: 44px 0 90px; } | |
| 570 | +.sources h1 { font-size: clamp(28px, 4vw, 42px); margin: 12px 0 6px; color: var(--ink); } | |
| 571 | +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; } | |
| 572 | +.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); } | |
| 573 | +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; } | |
| 574 | +.src-table th { | |
| 575 | + text-align: left; font-family: var(--font-mono); font-size: 10px; | |
| 576 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-2); | |
| 577 | + padding: 13px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); | |
| 578 | +} | |
| 579 | +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; } | |
| 580 | +.src-table tr:last-child td { border-bottom: none; } | |
| 581 | +.src-table tr:hover td { background: var(--lime-soft); } | |
| 582 | +.src-table a { color: var(--green); font-weight: 600; border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); } | |
| 583 | +.src-integrations { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; max-width: 380px; } | |
| 584 | +.src-table .pill, .pill.ok, .pill.todo { | |
| 585 | + display: inline-block; border-radius: 4px; padding: 3px 9px; | |
| 586 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 587 | + text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--ink); | |
| 588 | + box-shadow: none; cursor: default; transform: none; | |
| 589 | +} | |
| 590 | +.pill.ok { background: var(--lime); color: var(--ink); } | |
| 591 | +.pill.todo { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.6); } | |
| 592 | +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; color: var(--ink); } | |
| 593 | + | |
| 594 | +/* ================= Page Statistiques ================= */ | |
| 595 | +.stats-page { padding: 44px 0 90px; } | |
| 596 | +.stats-title { font-size: clamp(30px, 4.6vw, 48px); margin: 12px 0 6px; color: var(--ink); } | |
| 597 | +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; } | |
| 598 | +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; } | |
| 599 | +.tile { | |
| 600 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 601 | + padding: 18px 20px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1); | |
| 602 | +} | |
| 603 | +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; color: var(--ink); } | |
| 604 | +.tile-k { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; } | |
| 605 | +.hero-tile { background: var(--ink); color: var(--paper); border-color: var(--ink); box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.25); } | |
| 606 | +.hero-tile .tile-v { color: var(--lime); } | |
| 607 | +.hero-tile .tile-k { color: rgba(245, 243, 238, 0.6); } | |
| 608 | + | |
| 609 | +.viz-card { | |
| 610 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 611 | + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; | |
| 612 | +} | |
| 613 | +.viz-card h2 { font-size: 20px; letter-spacing: -0.02em; color: var(--ink); } | |
| 614 | +.viz-sub { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; } | |
| 615 | +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; } | |
| 616 | +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } } | |
| 617 | + | |
| 618 | +.hbars { display: flex; flex-direction: column; gap: 7px; } | |
| 619 | +.hbar-row { | |
| 620 | + display: grid; grid-template-columns: minmax(96px, 190px) 1fr auto; | |
| 621 | + gap: 12px; align-items: center; min-height: 26px; cursor: default; | |
| 622 | +} | |
| 623 | +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 624 | +.hbar-label a { border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); } | |
| 625 | +.hbar-label a:hover { color: var(--green); } | |
| 626 | +.hbar-track { background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 4px; height: 18px; overflow: hidden; } | |
| 627 | +.hbar-fill { | |
| 628 | + display: block; height: 100%; background: var(--green); border-radius: 0; | |
| 629 | + min-width: 2px; transition: background 0.12s ease; | |
| 630 | +} | |
| 631 | +.hbar-row:hover .hbar-fill { background: var(--lime); } | |
| 632 | +.hbar-value { font-family: var(--font-mono); font-size: 11px; font-weight: 700; white-space: nowrap; color: var(--ink); } | |
| 633 | +@media (max-width: 640px) { | |
| 634 | + .hbar-row { grid-template-columns: minmax(80px, 120px) 1fr auto; gap: 8px; } | |
| 635 | + .viz-card { padding: 20px 16px 14px; } | |
| 636 | +} | |
| 637 | +.stats-foot { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.02em; margin-top: 6px; } | |
| 638 | + | |
| 639 | +/* ================= Mobile : feuille de filtres + FAB ================= */ | |
| 640 | +.sheet-head { display: none; } | |
| 641 | +.sheet-apply { display: none; } | |
| 642 | +.sheet-backdrop { display: none; } | |
| 643 | +.fab { display: none; } | |
| 644 | + | |
| 645 | +@media (max-width: 640px) { | |
| 646 | + .header-inner { height: 58px; } | |
| 647 | + .brand { font-size: 20px; } | |
| 648 | + .nav a { padding: 8px 13px; font-size: 13.5px; } | |
| 649 | + .ticker { font-size: 10px; padding: 6px 0; } | |
| 650 | + .hero h1 { font-size: clamp(30px, 8.6vw, 42px); } | |
| 651 | + .hero p.lede { font-size: 15px; } | |
| 652 | + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; } | |
| 653 | + .stat-row::-webkit-scrollbar { display: none; } | |
| 654 | + .stat-chip { flex: 0 0 auto; white-space: nowrap; } | |
| 655 | + | |
| 656 | + /* la barre de filtres devient une feuille coulissante (bottom sheet) */ | |
| 657 | + .filterbar { display: none; } | |
| 658 | + .filterbar.open .f-primary { flex-direction: column; } | |
| 659 | + .filterbar.open .f-search { align-self: stretch; max-width: none; min-height: 46px; } | |
| 660 | + .filterbar.open .f-search input { font-size: 16px; } /* anti-zoom iOS */ | |
| 661 | + .filterbar.open .f-ctl select { max-width: none; width: 100%; } | |
| 662 | + .filterbar.open { | |
| 663 | + display: flex; flex-direction: column; gap: 12px; | |
| 664 | + position: fixed; left: 0; right: 0; bottom: 0; z-index: 95; | |
| 665 | + margin: 0; border-radius: 16px 16px 0 0; border: none; border-top: 2px solid var(--ink); | |
| 666 | + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; | |
| 667 | + padding: 16px 18px calc(18px + env(safe-area-inset-bottom)); | |
| 668 | + box-shadow: 0 -16px 48px rgba(20, 24, 20, 0.28); | |
| 669 | + animation: sheet-up 0.22s ease; | |
| 670 | + } | |
| 671 | + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } } | |
| 672 | + .filterbar.open .sheet-head { | |
| 673 | + display: flex; justify-content: space-between; align-items: center; | |
| 674 | + font-family: var(--font-display); font-weight: 700; font-size: 17px; | |
| 675 | + letter-spacing: -0.01em; color: var(--ink); | |
| 676 | + position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px; | |
| 677 | + border-bottom: 1px solid var(--line); margin-bottom: 2px; z-index: 1; | |
| 678 | + } | |
| 679 | + .sheet-close { | |
| 680 | + border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl); | |
| 681 | + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; color: var(--ink); | |
| 682 | + } | |
| 683 | + .filterbar.open .sheet-apply { display: flex; width: 100%; } | |
| 684 | + .sheet-backdrop { | |
| 685 | + display: block; position: fixed; inset: 0; z-index: 90; | |
| 686 | + background: rgba(20, 24, 20, 0.45); backdrop-filter: blur(2px); | |
| 687 | + } | |
| 688 | + .fab { | |
| 689 | + display: flex; align-items: center; gap: 6px; | |
| 690 | + position: fixed; left: 50%; transform: translateX(-50%); | |
| 691 | + bottom: calc(74px + env(safe-area-inset-bottom)); z-index: 80; | |
| 692 | + background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); | |
| 693 | + border-radius: var(--r-ctl); padding: 13px 24px; font-family: var(--font-display); | |
| 694 | + font-weight: 700; font-size: 15px; cursor: pointer; | |
| 695 | + box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.3); | |
| 696 | + } | |
| 697 | + .fab:active { transform: translateX(-50%) translate(2px, 2px); box-shadow: none; } | |
| 698 | + | |
| 699 | + .detail { padding-top: 20px; } | |
| 700 | + .panel { padding: 20px; } | |
| 701 | + .panel .price { font-size: 26px; } | |
| 702 | + .results-head { margin-top: 20px; } | |
| 703 | + .chips { margin-right: -16px; padding-right: 16px; } | |
| 704 | + .notice { padding: 48px 16px; } | |
| 705 | + .menu-items { padding: 0 14px 12px; } | |
| 706 | + .menu-section-head { padding: 13px 14px; } | |
| 707 | + .mi-thumb { width: 56px; height: 56px; } | |
| 708 | +} | |
| 709 | + | |
| 710 | +/* ================= Barre de navigation inférieure (mobile) ================= */ | |
| 711 | +.tabbar { display: none; } | |
| 712 | +@media (max-width: 760px) { | |
| 713 | + .tabbar { | |
| 714 | + display: grid; | |
| 715 | + position: fixed; left: 0; right: 0; bottom: 0; z-index: 85; | |
| 716 | + background: rgba(245, 243, 238, 0.94); | |
| 717 | + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); | |
| 718 | + border-top: 1.5px solid var(--ink); | |
| 719 | + padding: 6px 4px calc(6px + env(safe-area-inset-bottom)); | |
| 720 | + } | |
| 721 | + .tabbar-3 { grid-template-columns: repeat(3, 1fr); } | |
| 722 | + .tabbar a { | |
| 723 | + display: flex; flex-direction: column; align-items: center; gap: 3px; | |
| 724 | + padding: 5px 2px; min-height: 48px; justify-content: center; | |
| 725 | + font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--ink-3); | |
| 726 | + border-radius: var(--r-ctl); transition: color 0.12s ease; | |
| 727 | + } | |
| 728 | + .tabbar a.active { color: var(--green); } | |
| 729 | + .tabbar a svg { display: block; } | |
| 730 | + body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); } | |
| 731 | + .footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); } | |
| 732 | +} | |
| 733 | + | |
| 734 | +/* Anti-zoom iOS : les champs doivent faire >= 16px */ | |
| 735 | +@media (max-width: 900px) { | |
| 736 | + .f-search input, .f-ctl select { font-size: 16px; } | |
| 737 | +} | |
| 738 | + | |
| 739 | +/* ================= Footer ================= */ | |
| 740 | +.footer { | |
| 741 | + background: var(--ink); color: rgba(245, 243, 238, 0.72); margin-top: 20px; | |
| 742 | + padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; | |
| 743 | + border-top: 2px solid var(--ink); | |
| 744 | +} | |
| 745 | +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 30px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 6px; display: flex; align-items: center; } | |
| 746 | +.footer .fbrand .logo-ico { margin-right: 13px; } | |
| 747 | +.footer .fbrand .ka { | |
| 748 | + background: var(--lime); color: var(--ink); | |
| 749 | + padding: 1px 7px 3px; border-radius: 5px; margin-left: 4px; | |
| 750 | + display: inline-block; transform: rotate(-2deg); | |
| 751 | +} | |
| 752 | +.footer .fbaseline { color: rgba(245, 243, 238, 0.65); font-size: 14px; margin-bottom: 16px; } | |
| 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: 10.5px; letter-spacing: 0.04em; color: rgba(245, 243, 238, 0.42); margin-top: 18px; } | |
| 756 | + | |
| 757 | +/* --- Menu déroulant mobile ------------------------------------------------- */ | |
| 758 | +.menu-btn { | |
| 759 | + display: none; position: relative; z-index: 60; | |
| 760 | + width: 44px; height: 44px; margin-left: auto; | |
| 761 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 762 | + background: var(--surface); cursor: pointer; padding: 0; | |
| 763 | + flex-direction: column; align-items: center; justify-content: center; gap: 5px; | |
| 764 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); | |
| 765 | + transition: box-shadow 0.15s ease, transform 0.15s ease; | |
| 766 | +} | |
| 767 | +.menu-btn:active { transform: translate(2px, 2px); box-shadow: none; } | |
| 768 | +.menu-btn span { | |
| 769 | + display: block; width: 18px; height: 2px; background: var(--ink); | |
| 770 | + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; | |
| 771 | +} | |
| 772 | +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } | |
| 773 | +.menu-btn.open span:nth-child(2) { opacity: 0; } | |
| 774 | +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } | |
| 775 | + | |
| 776 | +.mobile-menu { | |
| 777 | + display: none; position: absolute; left: 0; right: 0; top: 100%; | |
| 778 | + background: var(--paper); border-bottom: 2px solid var(--ink); | |
| 779 | + box-shadow: 0 18px 30px rgba(20, 24, 20, 0.14); | |
| 780 | + padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); | |
| 781 | + max-height: 0; overflow: hidden; opacity: 0; | |
| 782 | + transition: max-height 0.3s ease, opacity 0.22s ease; | |
| 783 | +} | |
| 784 | +.mobile-menu.open { max-height: 480px; opacity: 1; } | |
| 785 | +.mm-link { | |
| 786 | + display: flex; align-items: center; gap: 12px; | |
| 787 | + padding: 15px 10px; min-height: 52px; | |
| 788 | + font-family: var(--font-display); font-weight: 700; font-size: 19px; | |
| 789 | + letter-spacing: -0.02em; border-bottom: 1px solid var(--line); | |
| 790 | + color: var(--ink); | |
| 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; color: var(--green); } | |
| 797 | +.mm-ico { width: 26px; text-align: center; font-size: 17px; } | |
| 798 | +.mm-arrow { margin-left: auto; opacity: 0.25; color: var(--green); 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; | |
| 802 | + font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.02em; | |
| 803 | +} | |
| 804 | +.mm-backdrop { | |
| 805 | + position: fixed; inset: 0; z-index: 40; | |
| 806 | + background: rgba(20, 24, 20, 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 | +} | |
added
frontend/tsconfig.json
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Author: Simon-Pierre Boucher <contact@spboucher.ai> — File: tsconfig.json — Desc: Resto·Ka frontend, mêmes options que Lou·Ka.", | |
| 3 | + "compilerOptions": { | |
| 4 | + "target": "ES2020", | |
| 5 | + "useDefineForClassFields": true, | |
| 6 | + "lib": ["ES2020", "DOM", "DOM.Iterable"], | |
| 7 | + "module": "ESNext", | |
| 8 | + "skipLibCheck": true, | |
| 9 | + "moduleResolution": "bundler", | |
| 10 | + "allowImportingTsExtensions": true, | |
| 11 | + "resolveJsonModule": true, | |
| 12 | + "isolatedModules": true, | |
| 13 | + "noEmit": true, | |
| 14 | + "jsx": "react-jsx", | |
| 15 | + "strict": true, | |
| 16 | + "noUnusedLocals": false, | |
| 17 | + "noUnusedParameters": false, | |
| 18 | + "noFallthroughCasesInSwitch": true | |
| 19 | + }, | |
| 20 | + "include": ["src"] | |
| 21 | +} | |
added
frontend/vite.config.ts
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: vite.config.ts | |
| 4 | +// Desc: Configuration Vite (proxy API en dev, build vers dist/) — même patron | |
| 5 | +// que Lou·Ka. | |
| 6 | +// ============================================================================== | |
| 7 | +import { defineConfig } from "vite"; | |
| 8 | +import react from "@vitejs/plugin-react"; | |
| 9 | + | |
| 10 | +export default defineConfig({ | |
| 11 | + plugins: [react()], | |
| 12 | + server: { | |
| 13 | + proxy: { "/api": "http://localhost:8115" }, | |
| 14 | + }, | |
| 15 | + build: { outDir: "dist" }, | |
| 16 | +}); | |
added
requirements.txt
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: requirements.txt | |
| 4 | +# Desc: Resto·Ka — dépendances backend (mêmes versions plancher que Lou·Ka). | |
| 5 | +# ============================================================================== | |
| 6 | +fastapi>=0.110 | |
| 7 | +uvicorn>=0.29 | |
| 8 | +requests>=2.31 | |
| 9 | +beautifulsoup4>=4.12 | |
added
restoka/__init__.py
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/__init__.py | |
| 4 | +# Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix). | |
| 5 | +# Paquet principal ; architecture calquée sur Lou·Ka (famille ·Ka). | |
| 6 | +# ============================================================================== | |
added
restoka/connectors/__init__.py
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/connectors/__init__.py | |
| 4 | +# Desc: Registre AUTO-DÉCOUVRANT des connecteurs : tout module de ce paquet | |
| 5 | +# contenant une sous-classe de BaseConnector avec un source_id non | |
| 6 | +# vide est enregistré automatiquement (même patron que Lou·Ka). | |
| 7 | +# ============================================================================== | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import importlib | |
| 11 | +import pkgutil | |
| 12 | +import sys | |
| 13 | + | |
| 14 | +from .base import BaseConnector | |
| 15 | + | |
| 16 | +CONNECTORS: dict[str, type[BaseConnector]] = {} | |
| 17 | + | |
| 18 | +for _mod in pkgutil.iter_modules(__path__): | |
| 19 | + if _mod.name in ("base", "__init__"): | |
| 20 | + continue | |
| 21 | + try: | |
| 22 | + module = importlib.import_module(f"{__name__}.{_mod.name}") | |
| 23 | + except Exception as exc: # un connecteur cassé ne bloque pas les autres | |
| 24 | + print(f"[resto-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr) | |
| 25 | + continue | |
| 26 | + for obj in vars(module).values(): | |
| 27 | + if (isinstance(obj, type) and issubclass(obj, BaseConnector) | |
| 28 | + and obj is not BaseConnector and getattr(obj, "source_id", "")): | |
| 29 | + CONNECTORS[obj.source_id] = obj | |
added
restoka/connectors/base.py
+157 −0
@@ -0,0 +1,157 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/connectors/base.py | |
| 4 | +# Desc: Classe de base des connecteurs + backends de fetch (requests direct, | |
| 5 | +# Firecrawl ou Scrapfly pour les sites JavaScript / anti-bot). | |
| 6 | +# Calquée sur louka/connectors/base.py — mêmes conventions. | |
| 7 | +# ============================================================================== | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import json | |
| 11 | +import os | |
| 12 | +import time | |
| 13 | + | |
| 14 | +import requests | |
| 15 | + | |
| 16 | +from ..schema import Restaurant | |
| 17 | + | |
| 18 | +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | |
| 19 | + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " | |
| 20 | + "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)") | |
| 21 | + | |
| 22 | +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" | |
| 23 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 24 | + | |
| 25 | + | |
| 26 | +class BaseConnector: | |
| 27 | + """Un connecteur = un adaptateur propre à une source (ou une plateforme). | |
| 28 | + | |
| 29 | + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne | |
| 30 | + la liste complète des restaurants (avec menu) actuellement publiés par la | |
| 31 | + source. Le pipeline (ingest.py) s'occupe du diff avec la base de données. | |
| 32 | + """ | |
| 33 | + | |
| 34 | + source_id: str = "" | |
| 35 | + request_delay: float = 0.6 # politesse entre requêtes | |
| 36 | + timeout: int = 30 | |
| 37 | + use_detail_cache: bool = True # cache BD des payloads détail | |
| 38 | + | |
| 39 | + def __init__(self) -> None: | |
| 40 | + self.session = requests.Session() | |
| 41 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 42 | + self._last_request = 0.0 | |
| 43 | + self._detail_con = None | |
| 44 | + | |
| 45 | + # -- backends ------------------------------------------------------------- | |
| 46 | + def _throttle(self) -> None: | |
| 47 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 48 | + if wait > 0: | |
| 49 | + time.sleep(wait) | |
| 50 | + | |
| 51 | + def get(self, url: str, **kw) -> requests.Response: | |
| 52 | + """GET direct avec throttling poli.""" | |
| 53 | + self._throttle() | |
| 54 | + resp = self.session.get(url, timeout=self.timeout, **kw) | |
| 55 | + self._last_request = time.time() | |
| 56 | + resp.raise_for_status() | |
| 57 | + return resp | |
| 58 | + | |
| 59 | + def post(self, url: str, **kw) -> requests.Response: | |
| 60 | + """POST direct avec throttling poli (APIs internes des plateformes).""" | |
| 61 | + self._throttle() | |
| 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 | + def get_rendered(self, url: str) -> str: | |
| 68 | + """HTML rendu (JavaScript exécuté) via Firecrawl. | |
| 69 | + | |
| 70 | + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). À utiliser | |
| 71 | + pour les sites de restos SPA relativement ouverts. | |
| 72 | + """ | |
| 73 | + key = os.environ.get("FIRECRAWL_API_KEY") | |
| 74 | + if not key: | |
| 75 | + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)") | |
| 76 | + resp = requests.post( | |
| 77 | + FIRECRAWL_API, | |
| 78 | + json={"url": url, "formats": ["html"]}, | |
| 79 | + headers={"Authorization": f"Bearer {key}"}, | |
| 80 | + timeout=90, | |
| 81 | + ) | |
| 82 | + resp.raise_for_status() | |
| 83 | + data = resp.json() | |
| 84 | + return (data.get("data") or {}).get("html", "") | |
| 85 | + | |
| 86 | + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 87 | + rendering_wait: int = 0, country: str = "ca", | |
| 88 | + wait_for_selector: str | None = None, | |
| 89 | + js_scenario: list | str | None = None, | |
| 90 | + proxy_pool: str | None = None, headers: dict | None = None, | |
| 91 | + method: str = "GET", body: str | None = None) -> dict: | |
| 92 | + """Appel Scrapfly complet — retourne le dict `result` (content, status…). | |
| 93 | + | |
| 94 | + Requis pour les plateformes de livraison (anti-bot agressif) ; voir | |
| 95 | + CLAUDE.md §10 et §15 avant d'industrialiser sur ces sources. | |
| 96 | + """ | |
| 97 | + import base64 | |
| 98 | + key = os.environ.get("SCRAPFLY_KEY") | |
| 99 | + if not key: | |
| 100 | + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)") | |
| 101 | + params: dict = {"key": key, "url": url, "country": country} | |
| 102 | + if asp: | |
| 103 | + params["asp"] = "true" | |
| 104 | + if render_js: | |
| 105 | + params["render_js"] = "true" | |
| 106 | + if rendering_wait: | |
| 107 | + params["rendering_wait"] = rendering_wait | |
| 108 | + if wait_for_selector: | |
| 109 | + params["wait_for_selector"] = wait_for_selector | |
| 110 | + if proxy_pool: | |
| 111 | + params["proxy_pool"] = proxy_pool | |
| 112 | + if js_scenario is not None: | |
| 113 | + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) | |
| 114 | + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() | |
| 115 | + if headers: | |
| 116 | + for k, v in headers.items(): | |
| 117 | + params[f"headers[{k}]"] = v | |
| 118 | + self._throttle() | |
| 119 | + if method.upper() == "POST": | |
| 120 | + resp = requests.post(SCRAPFLY_API, params=params, | |
| 121 | + data=(body or ""), timeout=180) | |
| 122 | + else: | |
| 123 | + resp = requests.get(SCRAPFLY_API, params=params, timeout=180) | |
| 124 | + self._last_request = time.time() | |
| 125 | + try: | |
| 126 | + return resp.json().get("result") or {} | |
| 127 | + except ValueError: | |
| 128 | + return {} | |
| 129 | + | |
| 130 | + def get_scrapfly(self, url: str, **kw) -> str: | |
| 131 | + """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS).""" | |
| 132 | + return self.scrapfly(url, **kw).get("content") or "" | |
| 133 | + | |
| 134 | + def detail(self, external_id: str, key: str, fetch_fn) -> dict: | |
| 135 | + """Payload « détail » avec cache : `fetch_fn` n'est appelé que si le | |
| 136 | + resto est nouveau ou si sa clé (hash du contenu liste) a changé. | |
| 137 | + | |
| 138 | + Évite de recapturer un menu complet (dizaines de requêtes) quand rien | |
| 139 | + n'a bougé. `fetch_fn` doit retourner un dict JSON-sérialisable. | |
| 140 | + """ | |
| 141 | + if not self.use_detail_cache: | |
| 142 | + return fetch_fn() or {} | |
| 143 | + from .. import db | |
| 144 | + if self._detail_con is None: | |
| 145 | + self._detail_con = db.connect() | |
| 146 | + cached = db.get_cached_detail(self._detail_con, self.source_id, | |
| 147 | + str(external_id), key) | |
| 148 | + if cached is not None: | |
| 149 | + return cached | |
| 150 | + payload = fetch_fn() or {} | |
| 151 | + db.put_cached_detail(self._detail_con, self.source_id, | |
| 152 | + str(external_id), key, payload) | |
| 153 | + return payload | |
| 154 | + | |
| 155 | + # -- contrat -------------------------------------------------------------- | |
| 156 | + def fetch(self) -> list[Restaurant]: | |
| 157 | + raise NotImplementedError | |
added
restoka/connectors/ueat.py
+343 −0
@@ -0,0 +1,343 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/connectors/ueat.py | |
| 4 | +# Desc: Connecteur TEMPLATISÉ pour UEAT (foodtech de Québec — plateforme de | |
| 5 | +# commande en ligne marque blanche, très répandue au QC). Couvre tous | |
| 6 | +# les restos listés dans data/sources.json -> source « ueat » -> | |
| 7 | +# « integrations » (un GUID d'intégration par marque/resto). | |
| 8 | +# | |
| 9 | +# Mode d'extraction : API GraphQL interne (https://api.ueat.io/graphql), | |
| 10 | +# la même que consomme le widget de commande officiel — données | |
| 11 | +# structurées, aucun rendu navigateur requis. Auth anonyme : | |
| 12 | +# en-têtes `x-ueatapikey: <GUID d'intégration>` + `x-ueatculture: fr`. | |
| 13 | +# | |
| 14 | +# Contexte de prix produit : TAKEOUT (prix réels de la commande en | |
| 15 | +# ligne du resto lui-même, non majorés — source de première partie, | |
| 16 | +# voir CLAUDE.md §9 Palier 1 et §15). | |
| 17 | +# | |
| 18 | +# Couverture par intégration : | |
| 19 | +# - restaurant { name url logo } -> identité de la marque | |
| 20 | +# - franchises(modeType: TAKEOUT) -> UNE FICHE PAR ADRESSE | |
| 21 | +# (succursales reliées par `chain`, jamais fusionnées — §12.1) | |
| 22 | +# - menu { categories } + items(categoryId) -> sections & plats | |
| 23 | +# (sous-catégories via menu(parentCategoryId), récursif) | |
| 24 | +# - variations d'item -> options « Format » | |
| 25 | +# - item.question (1er niveau) -> groupes d'options | |
| 26 | +# (garnitures, choix de base…) — mis en cache par marque, refait | |
| 27 | +# seulement quand le menu change (detail_cache). | |
| 28 | +# | |
| 29 | +# Limite documentée : le menu est capté au niveau de la MARQUE (menu | |
| 30 | +# par défaut du siège). Les écarts locaux de prix entre succursales | |
| 31 | +# d'une même bannière ne sont pas captés (menus standardisés §9 P6). | |
| 32 | +# ============================================================================== | |
| 33 | +from __future__ import annotations | |
| 34 | + | |
| 35 | +import datetime | |
| 36 | +import hashlib | |
| 37 | +import json | |
| 38 | +import sys | |
| 39 | +from pathlib import Path | |
| 40 | + | |
| 41 | +from ..normalize import infer_item_tags | |
| 42 | +from ..schema import Restaurant | |
| 43 | +from .base import BaseConnector | |
| 44 | + | |
| 45 | +API = "https://api.ueat.io/graphql" | |
| 46 | +ORDER_URL = "https://order.ueat.io/integration/{guid}/fr" | |
| 47 | +SOURCES_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "sources.json" | |
| 48 | + | |
| 49 | +# Nombre maximal de niveaux de sous-catégories suivis (garde-fou récursion). | |
| 50 | +MAX_DEPTH = 3 | |
| 51 | +# Nombre maximal d'items dont on va chercher les groupes d'options (par marque, | |
| 52 | +# par sync) — garde-fou volume ; le cache évite de refaire les items connus. | |
| 53 | +MAX_QUESTION_FETCH = 400 | |
| 54 | + | |
| 55 | +_Q_FRANCHISES = """ | |
| 56 | +query getFranchises($orderType: OrderTypeEnum!) { | |
| 57 | + restaurant { name url logo } | |
| 58 | + franchises(modeType: $orderType) { | |
| 59 | + companyProfileId code name currency countryCode | |
| 60 | + address { civicNumber streetName postalCode city province } | |
| 61 | + } | |
| 62 | +} | |
| 63 | +""" | |
| 64 | + | |
| 65 | +_Q_CATEGORIES = """ | |
| 66 | +query menuCategories { | |
| 67 | + menu { categories { id name description numberOfMenuItems hasSubCategory | |
| 68 | + image(size: 1024) } } | |
| 69 | +} | |
| 70 | +""" | |
| 71 | + | |
| 72 | +_Q_SUBMENU = """ | |
| 73 | +query menu($parentCategoryId: Int!) { | |
| 74 | + menu(parentCategoryId: $parentCategoryId) { | |
| 75 | + categories { id name description numberOfMenuItems hasSubCategory | |
| 76 | + image(size: 1024) } | |
| 77 | + items { id name description subtitle image regularPrice unitPrice | |
| 78 | + hasQuestions isOutOfStock isTemporaryDisabled | |
| 79 | + labels { name } | |
| 80 | + variations { id name description unitPrice regularPrice | |
| 81 | + variations { id name unitPrice regularPrice } } } | |
| 82 | + } | |
| 83 | +} | |
| 84 | +""" | |
| 85 | + | |
| 86 | +_Q_ITEMS = """ | |
| 87 | +query items($categoryId: Int!) { | |
| 88 | + items(categoryId: $categoryId) { | |
| 89 | + id name description subtitle image regularPrice unitPrice hasQuestions | |
| 90 | + isOutOfStock isTemporaryDisabled | |
| 91 | + labels { name } | |
| 92 | + variations { id name description unitPrice regularPrice | |
| 93 | + variations { id name unitPrice regularPrice } } | |
| 94 | + } | |
| 95 | +} | |
| 96 | +""" | |
| 97 | + | |
| 98 | +_Q_QUESTION = """ | |
| 99 | +query questions($menuItemId: Int!) { | |
| 100 | + item(id: $menuItemId) { | |
| 101 | + id name | |
| 102 | + question { | |
| 103 | + questionText selectionType minimum maximum | |
| 104 | + choices { name unitPrice isNotAdded } | |
| 105 | + } | |
| 106 | + } | |
| 107 | +} | |
| 108 | +""" | |
| 109 | + | |
| 110 | + | |
| 111 | +def _price(val) -> float | None: | |
| 112 | + if isinstance(val, (int, float)) and val > 0: | |
| 113 | + return round(float(val), 2) | |
| 114 | + return None | |
| 115 | + | |
| 116 | + | |
| 117 | +class UeatConnector(BaseConnector): | |
| 118 | + source_id = "ueat" | |
| 119 | + request_delay = 0.35 | |
| 120 | + | |
| 121 | + # -- transport -------------------------------------------------------------- | |
| 122 | + def _gql(self, api_key: str, query: str, variables: dict | None = None, | |
| 123 | + operation: str | None = None) -> dict: | |
| 124 | + """Une requête GraphQL UEAT ; lève sur toute erreur GraphQL.""" | |
| 125 | + body: dict = {"query": query} | |
| 126 | + if variables: | |
| 127 | + body["variables"] = variables | |
| 128 | + if operation: | |
| 129 | + body["operationName"] = operation | |
| 130 | + resp = self.post(API, json=body, headers={ | |
| 131 | + "x-ueatapikey": api_key, "x-ueatculture": "fr"}) | |
| 132 | + data = resp.json() | |
| 133 | + if data.get("errors"): | |
| 134 | + raise RuntimeError(data["errors"][0].get("message", "erreur GraphQL")) | |
| 135 | + return data.get("data") or {} | |
| 136 | + | |
| 137 | + # -- menu --------------------------------------------------------------------- | |
| 138 | + def _item_options(self, api_key: str, item: dict, | |
| 139 | + base: float) -> list[dict]: | |
| 140 | + """Groupes d'options d'un item : variations inline + 1er niveau de | |
| 141 | + « questions » (les niveaux suivants dépendent des réponses — non captés). | |
| 142 | + | |
| 143 | + `base` = prix affiché de l'item (celui du menu) : les deltas des | |
| 144 | + variations sont calculés par rapport à LUI, jamais par rapport à un | |
| 145 | + unitPrice nul (sinon on affiche des « +10,60 $ » qui sont en fait le | |
| 146 | + prix absolu du format de base).""" | |
| 147 | + options: list[dict] = [] | |
| 148 | + variations = item.get("variations") or [] | |
| 149 | + if variations: | |
| 150 | + choices = [] | |
| 151 | + for v in variations: | |
| 152 | + vp = _price(v.get("unitPrice")) | |
| 153 | + choices.append({"name": (v.get("name") or "").strip(), | |
| 154 | + "price_delta": (round(vp - base, 2) | |
| 155 | + if vp is not None else 0.0)}) | |
| 156 | + if choices: | |
| 157 | + options.append({"group": "Format", "required": True, | |
| 158 | + "choices": choices}) | |
| 159 | + if item.get("hasQuestions"): | |
| 160 | + try: | |
| 161 | + data = self._gql(api_key, _Q_QUESTION, | |
| 162 | + {"menuItemId": item["id"]}, "questions") | |
| 163 | + q = (data.get("item") or {}).get("question") | |
| 164 | + except Exception: | |
| 165 | + q = None # une question inaccessible ne bloque pas l'item | |
| 166 | + if q and q.get("choices"): | |
| 167 | + choices = [ | |
| 168 | + {"name": (c.get("name") or "").strip(), | |
| 169 | + "price_delta": _price(c.get("unitPrice")) or 0.0} | |
| 170 | + for c in q["choices"] if not c.get("isNotAdded") | |
| 171 | + ] | |
| 172 | + # si la « question » ne fait que répéter les variations | |
| 173 | + # (choix de format), ne pas dupliquer le groupe Format | |
| 174 | + var_names = {(v.get("name") or "").strip().lower() | |
| 175 | + for v in variations} | |
| 176 | + q_names = {c["name"].lower() for c in choices} | |
| 177 | + if choices and (not var_names or q_names != var_names): | |
| 178 | + options.append({ | |
| 179 | + "group": (q.get("questionText") or "Options").strip(), | |
| 180 | + "required": (q.get("minimum") or 0) >= 1, | |
| 181 | + "choices": choices, | |
| 182 | + }) | |
| 183 | + return options | |
| 184 | + | |
| 185 | + def _to_menu_item(self, api_key: str, item: dict, | |
| 186 | + budget: list[int]) -> dict | None: | |
| 187 | + name = (item.get("name") or "").strip() | |
| 188 | + if not name: | |
| 189 | + return None | |
| 190 | + price = _price(item.get("unitPrice")) | |
| 191 | + variations = item.get("variations") or [] | |
| 192 | + if price is None and variations: | |
| 193 | + vprices = [_price(v.get("unitPrice")) for v in variations] | |
| 194 | + vprices = [p for p in vprices if p is not None] | |
| 195 | + price = min(vprices) if vprices else None | |
| 196 | + labels = [l.get("name") or "" for l in item.get("labels") or []] | |
| 197 | + out = { | |
| 198 | + "id": str(item.get("id") or ""), | |
| 199 | + "name": name, | |
| 200 | + "description": (item.get("description") or "").strip(), | |
| 201 | + "price": price, | |
| 202 | + "currency": "CAD", | |
| 203 | + "tags": infer_item_tags(name, item.get("description") or "", labels), | |
| 204 | + } | |
| 205 | + if item.get("image"): | |
| 206 | + out["image"] = item["image"] | |
| 207 | + if item.get("isOutOfStock"): | |
| 208 | + out["tags"].append("rupture") | |
| 209 | + can_fetch_q = budget[0] < MAX_QUESTION_FETCH | |
| 210 | + if item.get("hasQuestions") and can_fetch_q: | |
| 211 | + budget[0] += 1 | |
| 212 | + elif item.get("hasQuestions"): | |
| 213 | + item = dict(item, hasQuestions=False) # budget épuisé : variations seulement | |
| 214 | + options = self._item_options(api_key, item, price or 0.0) | |
| 215 | + if options: | |
| 216 | + out["options"] = options | |
| 217 | + return out | |
| 218 | + | |
| 219 | + def _walk_category(self, api_key: str, cat: dict, budget: list[int], | |
| 220 | + depth: int = 0) -> list[dict]: | |
| 221 | + """Retourne les sections (aplaties « Parent — Enfant ») d'une catégorie.""" | |
| 222 | + cat_name = (cat.get("name") or "").strip() | |
| 223 | + sections: list[dict] = [] | |
| 224 | + if cat.get("hasSubCategory") and depth < MAX_DEPTH: | |
| 225 | + data = self._gql(api_key, _Q_SUBMENU, | |
| 226 | + {"parentCategoryId": cat["id"]}, "menu") | |
| 227 | + sub = data.get("menu") or {} | |
| 228 | + items = [self._to_menu_item(api_key, it, budget) | |
| 229 | + for it in sub.get("items") or []] | |
| 230 | + items = [it for it in items if it] | |
| 231 | + if items: | |
| 232 | + sec = {"name": cat_name, "items": items} | |
| 233 | + if cat.get("image"): | |
| 234 | + sec["image"] = cat["image"] | |
| 235 | + sections.append(sec) | |
| 236 | + for child in sub.get("categories") or []: | |
| 237 | + child_secs = self._walk_category(api_key, child, budget, depth + 1) | |
| 238 | + for cs in child_secs: | |
| 239 | + cs["name"] = f"{cat_name} — {cs['name']}" | |
| 240 | + sections.extend(child_secs) | |
| 241 | + else: | |
| 242 | + data = self._gql(api_key, _Q_ITEMS, {"categoryId": cat["id"]}, "items") | |
| 243 | + items = [self._to_menu_item(api_key, it, budget) | |
| 244 | + for it in data.get("items") or []] | |
| 245 | + items = [it for it in items if it] | |
| 246 | + if items: | |
| 247 | + sec = {"name": cat_name, "items": items} | |
| 248 | + if cat.get("image"): | |
| 249 | + sec["image"] = cat["image"] | |
| 250 | + sections.append(sec) | |
| 251 | + return sections | |
| 252 | + | |
| 253 | + def _fetch_menu_sections(self, api_key: str) -> list[dict]: | |
| 254 | + data = self._gql(api_key, _Q_CATEGORIES, operation="menuCategories") | |
| 255 | + categories = ((data.get("menu") or {}).get("categories")) or [] | |
| 256 | + budget = [0] # compteur partagé de requêtes « questions » | |
| 257 | + sections: list[dict] = [] | |
| 258 | + for cat in categories: | |
| 259 | + sections.extend(self._walk_category(api_key, cat, budget)) | |
| 260 | + return sections | |
| 261 | + | |
| 262 | + # -- fetch ---------------------------------------------------------------------- | |
| 263 | + def _integrations(self) -> list[dict]: | |
| 264 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) | |
| 265 | + for src in registry.get("sources", []): | |
| 266 | + if src.get("id") == self.source_id: | |
| 267 | + return src.get("integrations") or [] | |
| 268 | + return [] | |
| 269 | + | |
| 270 | + def fetch(self) -> list[Restaurant]: | |
| 271 | + out: dict[str, Restaurant] = {} | |
| 272 | + for integ in self._integrations(): | |
| 273 | + guid = integ.get("key") or "" | |
| 274 | + if not guid or integ.get("status") == "inactif": | |
| 275 | + continue | |
| 276 | + try: | |
| 277 | + self._fetch_integration(guid, out) | |
| 278 | + except Exception as exc: # une marque ne bloque pas les autres | |
| 279 | + print(f"[resto-ka] ueat: intégration {integ.get('label') or guid} " | |
| 280 | + f"ignorée : {exc}", file=sys.stderr) | |
| 281 | + return list(out.values()) | |
| 282 | + | |
| 283 | + def _fetch_integration(self, guid: str, out: dict[str, Restaurant]) -> None: | |
| 284 | + data = self._gql(guid, _Q_FRANCHISES, {"orderType": "TAKEOUT"}, | |
| 285 | + "getFranchises") | |
| 286 | + brand = data.get("restaurant") or {} | |
| 287 | + brand_name = (brand.get("name") or "").strip() | |
| 288 | + franchises = [f for f in data.get("franchises") or [] | |
| 289 | + if ((f.get("address") or {}).get("province") or "") | |
| 290 | + .strip().lower() in ("québec", "quebec", "qc")] | |
| 291 | + if not brand_name or not franchises: | |
| 292 | + return | |
| 293 | + | |
| 294 | + # menu de marque, avec cache : recapturé seulement si l'empreinte des | |
| 295 | + # catégories (ids + nb d'items) a changé — sinon rejoué depuis le cache | |
| 296 | + cats = self._gql(guid, _Q_CATEGORIES, operation="menuCategories") | |
| 297 | + cat_list = ((cats.get("menu") or {}).get("categories")) or [] | |
| 298 | + # « v2 » = version du format de payload (images) : bust le cache v1 | |
| 299 | + cache_key = "v2-" + hashlib.sha256(json.dumps( | |
| 300 | + [(c.get("id"), c.get("numberOfMenuItems")) for c in cat_list], | |
| 301 | + sort_keys=True).encode()).hexdigest() | |
| 302 | + payload = self.detail(guid, cache_key, | |
| 303 | + lambda: {"sections": self._fetch_menu_sections(guid)}) | |
| 304 | + sections = payload.get("sections") or [] | |
| 305 | + captured_at = datetime.datetime.now(datetime.timezone.utc) \ | |
| 306 | + .strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 307 | + | |
| 308 | + is_chain = len(franchises) > 1 | |
| 309 | + for fr in franchises: | |
| 310 | + addr = fr.get("address") or {} | |
| 311 | + civic = (addr.get("civicNumber") or "").strip() | |
| 312 | + street = (addr.get("streetName") or "").strip() | |
| 313 | + profile_id = fr.get("companyProfileId") | |
| 314 | + if profile_id is None: | |
| 315 | + continue | |
| 316 | + menu = { | |
| 317 | + "currency": fr.get("currency") or "CAD", | |
| 318 | + "price_context": "takeout", # commande en ligne du resto : | |
| 319 | + "price_source": "ueat", # prix réels, non majorés (§9 P1) | |
| 320 | + "captured_at": captured_at, | |
| 321 | + "sections": sections, | |
| 322 | + } if sections else None | |
| 323 | + resto = Restaurant( | |
| 324 | + source=self.source_id, | |
| 325 | + external_id=f"{guid}:{profile_id}", | |
| 326 | + name=(fr.get("name") or brand_name).strip() | |
| 327 | + if is_chain else brand_name, | |
| 328 | + url=ORDER_URL.format(guid=guid), | |
| 329 | + chain=brand_name if is_chain else None, | |
| 330 | + address=f"{civic} {street}".strip(), | |
| 331 | + city=(addr.get("city") or "").strip(), | |
| 332 | + postal_code=(addr.get("postalCode") or "").strip(), | |
| 333 | + website=(brand.get("url") or "").strip(), | |
| 334 | + services=["takeout"], | |
| 335 | + languages=["fr", "en"], | |
| 336 | + images=[u for u in ([brand.get("logo") or ""] | |
| 337 | + + [s.get("image") or "" for s in sections][:4]) if u], | |
| 338 | + menu=menu, | |
| 339 | + ) | |
| 340 | + # préfixer le nom de la succursale par la marque si absent | |
| 341 | + if is_chain and brand_name.lower() not in resto.name.lower(): | |
| 342 | + resto.name = f"{brand_name} — {resto.name}" | |
| 343 | + out.setdefault(resto.uid, resto) | |
added
restoka/db.py
+414 −0
@@ -0,0 +1,414 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/db.py | |
| 4 | +# Desc: Persistance SQLite — upsert des restaurants avec détection de | |
| 5 | +# changements, menus conservés PAR CONTEXTE de prix (dine-in/takeout/ | |
| 6 | +# delivery, jamais écrasés entre eux), historique des prix par item, | |
| 7 | +# cycle de vie avec délai de grâce, détection de dérive (connecteur | |
| 8 | +# cassé), caches détail et géocodage. Calqué sur louka/db.py. | |
| 9 | +# ============================================================================== | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import json | |
| 13 | +import sqlite3 | |
| 14 | +import statistics | |
| 15 | +import time | |
| 16 | +from pathlib import Path | |
| 17 | + | |
| 18 | +from .schema import Restaurant | |
| 19 | + | |
| 20 | +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "restoka.db" | |
| 21 | + | |
| 22 | +# Nombre d'exécutions consécutives où un resto doit être absent de la source | |
| 23 | +# avant d'être marqué temporarily_closed (délai de grâce contre les ratés). | |
| 24 | +MISS_GRACE = 2 | |
| 25 | + | |
| 26 | +# Dérive source : si une source retourne <= DRIFT_RATIO × sa médiane historique | |
| 27 | +# (médiane >= DRIFT_MIN_BASE restos), on alerte et on suspend les retraits. | |
| 28 | +DRIFT_RATIO = 0.25 | |
| 29 | +DRIFT_MIN_BASE = 4 | |
| 30 | +DRIFT_HISTORY = 5 | |
| 31 | + | |
| 32 | +# Dérive menu : si le nombre d'items d'un resto chute sous MENU_DROP_RATIO × | |
| 33 | +# le compte précédent (précédent >= MENU_DROP_MIN items), on conserve l'ancien | |
| 34 | +# menu et on consigne l'alerte (connecteur probablement cassé — CLAUDE.md §18). | |
| 35 | +MENU_DROP_RATIO = 0.25 | |
| 36 | +MENU_DROP_MIN = 8 | |
| 37 | + | |
| 38 | +_SCHEMA = """ | |
| 39 | +CREATE TABLE IF NOT EXISTS restaurants ( | |
| 40 | + uid TEXT PRIMARY KEY, -- source:external_id | |
| 41 | + source TEXT NOT NULL, | |
| 42 | + external_id TEXT NOT NULL, | |
| 43 | + name TEXT, | |
| 44 | + chain TEXT, | |
| 45 | + cuisines TEXT, -- JSON (taxonomie §6.1) | |
| 46 | + establishment_type TEXT, | |
| 47 | + price_range TEXT, | |
| 48 | + address TEXT, | |
| 49 | + city TEXT, | |
| 50 | + region TEXT, -- une des 17 régions | |
| 51 | + postal_code TEXT, | |
| 52 | + lat REAL, | |
| 53 | + lng REAL, | |
| 54 | + phone TEXT, | |
| 55 | + website TEXT, | |
| 56 | + url TEXT, | |
| 57 | + hours TEXT, -- JSON | |
| 58 | + services TEXT, -- JSON | |
| 59 | + dietary_options TEXT, -- JSON | |
| 60 | + languages TEXT, -- JSON | |
| 61 | + images TEXT, -- JSON (logo + photos, URLs absolues) | |
| 62 | + status TEXT DEFAULT 'open', | |
| 63 | + geocode_failed INTEGER DEFAULT 0, | |
| 64 | + content_hash TEXT, | |
| 65 | + first_seen REAL, | |
| 66 | + last_seen REAL, | |
| 67 | + updated_at REAL, | |
| 68 | + miss_count INTEGER DEFAULT 0, | |
| 69 | + active INTEGER DEFAULT 1, | |
| 70 | + dup_of TEXT, -- uid canonique si doublon inter-sources | |
| 71 | + dup_sources TEXT -- JSON : autres sources où le resto est publié | |
| 72 | +); | |
| 73 | +CREATE INDEX IF NOT EXISTS idx_restaurants_source ON restaurants(source); | |
| 74 | +CREATE INDEX IF NOT EXISTS idx_restaurants_city ON restaurants(city); | |
| 75 | +CREATE INDEX IF NOT EXISTS idx_restaurants_region ON restaurants(region); | |
| 76 | +CREATE INDEX IF NOT EXISTS idx_restaurants_active ON restaurants(active); | |
| 77 | +CREATE INDEX IF NOT EXISTS idx_restaurants_geo ON restaurants(lat, lng); | |
| 78 | + | |
| 79 | +-- Un menu PAR CONTEXTE de prix : un menu dine-in n'est JAMAIS écrasé par un | |
| 80 | +-- menu delivery (CLAUDE.md §12.2) — on conserve les deux, l'API sert le | |
| 81 | +-- meilleur contexte disponible (dine-in > takeout > delivery). | |
| 82 | +CREATE TABLE IF NOT EXISTS menus ( | |
| 83 | + uid TEXT NOT NULL, -- restaurants.uid | |
| 84 | + price_context TEXT NOT NULL, -- dine-in | takeout | delivery | |
| 85 | + price_source TEXT NOT NULL, -- source du prix (traçabilité) | |
| 86 | + currency TEXT DEFAULT 'CAD', | |
| 87 | + captured_at TEXT, -- ISO-8601 (fraîcheur) | |
| 88 | + item_count INTEGER, | |
| 89 | + sections TEXT, -- JSON : sections -> items -> options | |
| 90 | + PRIMARY KEY (uid, price_context) | |
| 91 | +); | |
| 92 | + | |
| 93 | +-- Historique des prix par item (détection des hausses, crédibilité §12.2). | |
| 94 | +CREATE TABLE IF NOT EXISTS item_price_log ( | |
| 95 | + uid TEXT NOT NULL, | |
| 96 | + price_context TEXT NOT NULL, | |
| 97 | + item_key TEXT NOT NULL, -- "section :: item" normalisé | |
| 98 | + ts REAL NOT NULL, | |
| 99 | + price REAL | |
| 100 | +); | |
| 101 | +CREATE INDEX IF NOT EXISTS idx_item_price_log ON item_price_log(uid, item_key); | |
| 102 | + | |
| 103 | +CREATE TABLE IF NOT EXISTS sync_log ( | |
| 104 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 105 | + source TEXT, | |
| 106 | + ts REAL, | |
| 107 | + found INTEGER, | |
| 108 | + added INTEGER, | |
| 109 | + updated INTEGER, | |
| 110 | + removed INTEGER, | |
| 111 | + ok INTEGER, | |
| 112 | + message TEXT, | |
| 113 | + stats TEXT -- JSON : items totaux, taux sans prix, alertes… | |
| 114 | +); | |
| 115 | + | |
| 116 | +CREATE TABLE IF NOT EXISTS detail_cache ( | |
| 117 | + source TEXT NOT NULL, | |
| 118 | + external_id TEXT NOT NULL, | |
| 119 | + key TEXT, | |
| 120 | + payload TEXT, | |
| 121 | + fetched_at REAL, | |
| 122 | + PRIMARY KEY (source, external_id) | |
| 123 | +); | |
| 124 | + | |
| 125 | +CREATE TABLE IF NOT EXISTS geocode_cache ( | |
| 126 | + address TEXT PRIMARY KEY, | |
| 127 | + lat REAL, | |
| 128 | + lng REAL, | |
| 129 | + provider TEXT, | |
| 130 | + failed INTEGER DEFAULT 0, | |
| 131 | + ts REAL | |
| 132 | +); | |
| 133 | +""" | |
| 134 | + | |
| 135 | + | |
| 136 | +# Colonnes ajoutées après la v1 — migration automatique des bases existantes | |
| 137 | +# (même mécanisme que louka/db.py). | |
| 138 | +_MIGRATIONS = { | |
| 139 | + "restaurants": { | |
| 140 | + "images": "TEXT", | |
| 141 | + }, | |
| 142 | +} | |
| 143 | + | |
| 144 | + | |
| 145 | +def connect(path: Path | None = None) -> sqlite3.Connection: | |
| 146 | + db_path = path or DB_PATH | |
| 147 | + db_path.parent.mkdir(parents=True, exist_ok=True) | |
| 148 | + con = sqlite3.connect(db_path) | |
| 149 | + con.row_factory = sqlite3.Row | |
| 150 | + con.executescript(_SCHEMA) | |
| 151 | + for table, cols in _MIGRATIONS.items(): | |
| 152 | + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} | |
| 153 | + for col, decl in cols.items(): | |
| 154 | + if col not in existing: | |
| 155 | + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") | |
| 156 | + con.commit() | |
| 157 | + return con | |
| 158 | + | |
| 159 | + | |
| 160 | +# --------------------------------------------------------------------------- | |
| 161 | +# Menus & historique des prix | |
| 162 | +# --------------------------------------------------------------------------- | |
| 163 | + | |
| 164 | +def _item_key(section: str, item: str) -> str: | |
| 165 | + return f"{(section or '').strip().lower()} :: {(item or '').strip().lower()}" | |
| 166 | + | |
| 167 | + | |
| 168 | +def _menu_items(menu: dict) -> dict[str, float | None]: | |
| 169 | + """{item_key: prix} pour la comparaison et l'historique.""" | |
| 170 | + out: dict[str, float | None] = {} | |
| 171 | + for sec in menu.get("sections") or []: | |
| 172 | + for it in sec.get("items") or []: | |
| 173 | + out[_item_key(sec.get("name"), it.get("name"))] = it.get("price") | |
| 174 | + return out | |
| 175 | + | |
| 176 | + | |
| 177 | +def upsert_menu(con: sqlite3.Connection, uid: str, menu: dict, | |
| 178 | + now: float) -> dict: | |
| 179 | + """Insère/actualise le menu d'un resto POUR SON CONTEXTE de prix. | |
| 180 | + | |
| 181 | + - historise chaque changement de prix d'item (item_price_log) ; | |
| 182 | + - garde-fou : chute anormale du nombre d'items -> on conserve l'ancien | |
| 183 | + menu et on retourne une alerte (connecteur probablement cassé). | |
| 184 | + """ | |
| 185 | + ctx = menu["price_context"] | |
| 186 | + new_items = _menu_items(menu) | |
| 187 | + row = con.execute( | |
| 188 | + "SELECT sections, item_count FROM menus WHERE uid=? AND price_context=?", | |
| 189 | + (uid, ctx)).fetchone() | |
| 190 | + | |
| 191 | + alert = None | |
| 192 | + if row is not None: | |
| 193 | + old_count = row["item_count"] or 0 | |
| 194 | + if (old_count >= MENU_DROP_MIN | |
| 195 | + and len(new_items) <= MENU_DROP_RATIO * old_count): | |
| 196 | + return {"alert": (f"{uid}[{ctx}]: {len(new_items)} item(s) contre " | |
| 197 | + f"{old_count} auparavant — ancien menu conservé")} | |
| 198 | + # sections est stocké comme liste JSON : reconstruire le dict attendu | |
| 199 | + try: | |
| 200 | + old_sections = json.loads(row["sections"] or "[]") | |
| 201 | + except ValueError: | |
| 202 | + old_sections = [] | |
| 203 | + old_items = _menu_items({"sections": old_sections}) | |
| 204 | + for key, price in new_items.items(): | |
| 205 | + if key not in old_items or old_items[key] != price: | |
| 206 | + con.execute( | |
| 207 | + "INSERT INTO item_price_log (uid, price_context, item_key," | |
| 208 | + " ts, price) VALUES (?,?,?,?,?)", | |
| 209 | + (uid, ctx, key, now, price)) | |
| 210 | + else: | |
| 211 | + for key, price in new_items.items(): | |
| 212 | + if price is not None: | |
| 213 | + con.execute( | |
| 214 | + "INSERT INTO item_price_log (uid, price_context, item_key," | |
| 215 | + " ts, price) VALUES (?,?,?,?,?)", | |
| 216 | + (uid, ctx, key, now, price)) | |
| 217 | + | |
| 218 | + con.execute( | |
| 219 | + "INSERT INTO menus (uid, price_context, price_source, currency," | |
| 220 | + " captured_at, item_count, sections) VALUES (?,?,?,?,?,?,?)" | |
| 221 | + " ON CONFLICT(uid, price_context) DO UPDATE SET" | |
| 222 | + " price_source=excluded.price_source, currency=excluded.currency," | |
| 223 | + " captured_at=excluded.captured_at, item_count=excluded.item_count," | |
| 224 | + " sections=excluded.sections", | |
| 225 | + (uid, ctx, menu["price_source"], menu.get("currency", "CAD"), | |
| 226 | + menu.get("captured_at"), len(new_items), | |
| 227 | + json.dumps(menu.get("sections") or [], ensure_ascii=False))) | |
| 228 | + return {"alert": alert} | |
| 229 | + | |
| 230 | + | |
| 231 | +# --------------------------------------------------------------------------- | |
| 232 | +# Synchronisation d'une source | |
| 233 | +# --------------------------------------------------------------------------- | |
| 234 | + | |
| 235 | +def _drift_alert(con: sqlite3.Connection, source: str, found: int) -> str | None: | |
| 236 | + hist = con.execute( | |
| 237 | + "SELECT found FROM sync_log WHERE source=? AND ok=1" | |
| 238 | + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall() | |
| 239 | + if len(hist) < 3: | |
| 240 | + return None | |
| 241 | + med_found = statistics.median(r["found"] for r in hist) | |
| 242 | + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found: | |
| 243 | + return (f"dérive: {found} resto(s) trouvé(s) contre une médiane de " | |
| 244 | + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur") | |
| 245 | + return None | |
| 246 | + | |
| 247 | + | |
| 248 | +def sync_source(con: sqlite3.Connection, source: str, | |
| 249 | + restaurants: list[Restaurant]) -> dict: | |
| 250 | + """Synchronise les restaurants d'une source. | |
| 251 | + | |
| 252 | + - nouveau resto -> insertion (+ menu, + historique de prix initial) | |
| 253 | + - resto modifié -> mise à jour (comparaison de content_hash) | |
| 254 | + - resto disparu -> miss_count += 1, puis status=temporarily_closed | |
| 255 | + après MISS_GRACE exécutions (délai de grâce, jamais de suppression) | |
| 256 | + - dérive détectée -> alerte consignée, retraits suspendus | |
| 257 | + """ | |
| 258 | + now = time.time() | |
| 259 | + added = updated = 0 | |
| 260 | + seen_uids = set() | |
| 261 | + menu_alerts: list[str] = [] | |
| 262 | + | |
| 263 | + n = len(restaurants) | |
| 264 | + total_items = 0 | |
| 265 | + items_sans_prix = 0 | |
| 266 | + for r in restaurants: | |
| 267 | + if r.menu: | |
| 268 | + for sec in r.menu.get("sections") or []: | |
| 269 | + for it in sec.get("items") or []: | |
| 270 | + total_items += 1 | |
| 271 | + if it.get("price") is None: | |
| 272 | + items_sans_prix += 1 | |
| 273 | + | |
| 274 | + alert = _drift_alert(con, source, n) | |
| 275 | + | |
| 276 | + for resto in restaurants: | |
| 277 | + seen_uids.add(resto.uid) | |
| 278 | + h = resto.content_hash() | |
| 279 | + row = con.execute("SELECT content_hash FROM restaurants WHERE uid=?", | |
| 280 | + (resto.uid,)).fetchone() | |
| 281 | + params = dict( | |
| 282 | + uid=resto.uid, source=resto.source, external_id=resto.external_id, | |
| 283 | + name=resto.name, chain=resto.chain, | |
| 284 | + cuisines=json.dumps(resto.cuisines, ensure_ascii=False), | |
| 285 | + establishment_type=resto.establishment_type, | |
| 286 | + price_range=resto.price_range, address=resto.address, | |
| 287 | + city=resto.city, region=resto.region, | |
| 288 | + postal_code=resto.postal_code, lat=resto.lat, lng=resto.lng, | |
| 289 | + phone=resto.phone, website=resto.website, url=resto.url, | |
| 290 | + hours=json.dumps(resto.hours, ensure_ascii=False), | |
| 291 | + services=json.dumps(resto.services, ensure_ascii=False), | |
| 292 | + dietary_options=json.dumps(resto.dietary_options, ensure_ascii=False), | |
| 293 | + languages=json.dumps(resto.languages, ensure_ascii=False), | |
| 294 | + images=json.dumps(resto.images, ensure_ascii=False), | |
| 295 | + status=resto.status, content_hash=h, now=now, | |
| 296 | + ) | |
| 297 | + if row is None: | |
| 298 | + con.execute( | |
| 299 | + """INSERT INTO restaurants (uid, source, external_id, name, | |
| 300 | + chain, cuisines, establishment_type, price_range, address, | |
| 301 | + city, region, postal_code, lat, lng, phone, website, url, | |
| 302 | + hours, services, dietary_options, languages, images, | |
| 303 | + status, content_hash, first_seen, last_seen, updated_at, | |
| 304 | + miss_count, active) | |
| 305 | + VALUES (:uid,:source,:external_id,:name,:chain,:cuisines, | |
| 306 | + :establishment_type,:price_range,:address,:city,:region, | |
| 307 | + :postal_code,:lat,:lng,:phone,:website,:url,:hours, | |
| 308 | + :services,:dietary_options,:languages,:images,:status, | |
| 309 | + :content_hash,:now,:now,:now,0,1)""", params) | |
| 310 | + added += 1 | |
| 311 | + elif row["content_hash"] != h: | |
| 312 | + # COALESCE : ne jamais écraser des coordonnées géocodées par null | |
| 313 | + con.execute( | |
| 314 | + """UPDATE restaurants SET name=:name, chain=:chain, | |
| 315 | + cuisines=:cuisines, establishment_type=:establishment_type, | |
| 316 | + price_range=:price_range, address=:address, city=:city, | |
| 317 | + region=:region, postal_code=:postal_code, | |
| 318 | + lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng), | |
| 319 | + phone=:phone, website=:website, url=:url, hours=:hours, | |
| 320 | + services=:services, dietary_options=:dietary_options, | |
| 321 | + languages=:languages, images=:images, status=:status, | |
| 322 | + content_hash=:content_hash, last_seen=:now, updated_at=:now, | |
| 323 | + miss_count=0, active=1 WHERE uid=:uid""", params) | |
| 324 | + updated += 1 | |
| 325 | + else: | |
| 326 | + con.execute( | |
| 327 | + "UPDATE restaurants SET last_seen=?, miss_count=0, active=1," | |
| 328 | + " status='open' WHERE uid=?", (now, resto.uid)) | |
| 329 | + | |
| 330 | + if resto.menu: | |
| 331 | + res = upsert_menu(con, resto.uid, resto.menu, now) | |
| 332 | + if res.get("alert"): | |
| 333 | + menu_alerts.append(res["alert"]) | |
| 334 | + | |
| 335 | + # Restos de cette source qui n'apparaissent plus : délai de grâce, puis | |
| 336 | + # status=temporarily_closed (politique de grâce §13 — jamais de suppression). | |
| 337 | + removed = missed = 0 | |
| 338 | + if not alert: | |
| 339 | + for r in con.execute( | |
| 340 | + "SELECT uid, miss_count FROM restaurants WHERE source=? AND active=1", | |
| 341 | + (source,)).fetchall(): | |
| 342 | + if r["uid"] in seen_uids: | |
| 343 | + continue | |
| 344 | + missed += 1 | |
| 345 | + if r["miss_count"] + 1 >= MISS_GRACE: | |
| 346 | + con.execute( | |
| 347 | + "UPDATE restaurants SET active=0, status='temporarily_closed'," | |
| 348 | + " miss_count=?, updated_at=? WHERE uid=?", | |
| 349 | + (r["miss_count"] + 1, now, r["uid"])) | |
| 350 | + removed += 1 | |
| 351 | + else: | |
| 352 | + con.execute( | |
| 353 | + "UPDATE restaurants SET miss_count=miss_count+1 WHERE uid=?", | |
| 354 | + (r["uid"],)) | |
| 355 | + | |
| 356 | + stats = { | |
| 357 | + "menu_items": total_items, | |
| 358 | + "null_price_rate": (round(items_sans_prix / total_items, 3) | |
| 359 | + if total_items else 0.0), | |
| 360 | + "missed": missed, | |
| 361 | + } | |
| 362 | + if alert: | |
| 363 | + stats["alert"] = alert | |
| 364 | + if menu_alerts: | |
| 365 | + stats["menu_alerts"] = menu_alerts | |
| 366 | + con.execute( | |
| 367 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok," | |
| 368 | + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)", | |
| 369 | + (source, now, n, added, updated, removed, alert or "ok", | |
| 370 | + json.dumps(stats, ensure_ascii=False))) | |
| 371 | + con.commit() | |
| 372 | + out = {"source": source, "found": n, "added": added, "updated": updated, | |
| 373 | + "removed": removed, "menu_items": total_items} | |
| 374 | + if alert: | |
| 375 | + out["alert"] = alert | |
| 376 | + if menu_alerts: | |
| 377 | + out["menu_alerts"] = len(menu_alerts) | |
| 378 | + return out | |
| 379 | + | |
| 380 | + | |
| 381 | +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None: | |
| 382 | + con.execute( | |
| 383 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)" | |
| 384 | + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message)) | |
| 385 | + con.commit() | |
| 386 | + | |
| 387 | + | |
| 388 | +# --------------------------------------------------------------------------- | |
| 389 | +# Cache des pages détail (« détail si nouveau/modifié ») | |
| 390 | +# --------------------------------------------------------------------------- | |
| 391 | + | |
| 392 | +def get_cached_detail(con: sqlite3.Connection, source: str, | |
| 393 | + external_id: str, key: str) -> dict | None: | |
| 394 | + row = con.execute( | |
| 395 | + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?", | |
| 396 | + (source, external_id)).fetchone() | |
| 397 | + if row and row["key"] == key and row["payload"]: | |
| 398 | + try: | |
| 399 | + return json.loads(row["payload"]) | |
| 400 | + except ValueError: | |
| 401 | + return None | |
| 402 | + return None | |
| 403 | + | |
| 404 | + | |
| 405 | +def put_cached_detail(con: sqlite3.Connection, source: str, | |
| 406 | + external_id: str, key: str, payload: dict) -> None: | |
| 407 | + con.execute( | |
| 408 | + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)" | |
| 409 | + " VALUES (?,?,?,?,?)" | |
| 410 | + " ON CONFLICT(source, external_id) DO UPDATE SET" | |
| 411 | + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at", | |
| 412 | + (source, external_id, key, json.dumps(payload, ensure_ascii=False), | |
| 413 | + time.time())) | |
| 414 | + con.commit() | |
added
restoka/dedup.py
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/dedup.py | |
| 4 | +# Desc: Déduplication inter-sources des RESTAURANTS (CLAUDE.md §12.1) : | |
| 5 | +# un même resto publié sur plusieurs plateformes est fusionné vers une | |
| 6 | +# fiche canonique (dup_of) ; les succursales d'une chaîne ne sont | |
| 7 | +# JAMAIS fusionnées entre elles (une adresse = une fiche). Les menus | |
| 8 | +# restent conservés PAR CONTEXTE de prix (db.menus) — la préférence | |
| 9 | +# dine-in > takeout > delivery est appliquée au service (web.py). | |
| 10 | +# Inspiré de louka/dedup.py et de l'identité forte d'Auto·Ka. | |
| 11 | +# ============================================================================== | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import json | |
| 15 | +import re | |
| 16 | +import time | |
| 17 | + | |
| 18 | +from . import db | |
| 19 | +from .regions import strip_accents | |
| 20 | + | |
| 21 | +# Ordre de préférence des sources pour le choix de la fiche canonique : | |
| 22 | +# sources de première partie (prix réels) avant les plateformes de livraison. | |
| 23 | +SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow", | |
| 24 | + "site-resto", "ubereats", "doordash", "skip"] | |
| 25 | + | |
| 26 | + | |
| 27 | +def _name_key(name: str) -> str: | |
| 28 | + """Nom normalisé : accents, ponctuation, mots vides d'enseigne.""" | |
| 29 | + s = strip_accents((name or "").lower()) | |
| 30 | + s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b", | |
| 31 | + " ", s) | |
| 32 | + s = re.sub(r"[^a-z0-9]+", " ", s) | |
| 33 | + return re.sub(r"\s+", " ", s).strip() | |
| 34 | + | |
| 35 | + | |
| 36 | +def _phone_key(phone: str) -> str: | |
| 37 | + return re.sub(r"\D", "", phone or "")[-10:] | |
| 38 | + | |
| 39 | + | |
| 40 | +def fingerprints(row: dict) -> list[str]: | |
| 41 | + """Empreintes d'identité d'un resto. Deux fiches partageant une empreinte | |
| 42 | + sont candidates à la fusion (même établissement, même adresse).""" | |
| 43 | + name = _name_key(row.get("name") or "") | |
| 44 | + if not name: | |
| 45 | + return [] | |
| 46 | + out = [] | |
| 47 | + # nom + géolocalisation arrondie (~110 m) — la plus fiable | |
| 48 | + lat, lng = row.get("lat"), row.get("lng") | |
| 49 | + if lat is not None and lng is not None: | |
| 50 | + out.append(f"{name}|{round(lat, 3)},{round(lng, 3)}") | |
| 51 | + # nom + code postal (repli quand pas encore géocodé) | |
| 52 | + pc = (row.get("postal_code") or "").replace(" ", "").upper() | |
| 53 | + if pc: | |
| 54 | + out.append(f"{name}|{pc}") | |
| 55 | + # nom + téléphone | |
| 56 | + ph = _phone_key(row.get("phone") or "") | |
| 57 | + if ph: | |
| 58 | + out.append(f"{name}|{ph}") | |
| 59 | + return out | |
| 60 | + | |
| 61 | + | |
| 62 | +def _priority(source: str) -> int: | |
| 63 | + try: | |
| 64 | + return SOURCE_PRIORITY.index(source) | |
| 65 | + except ValueError: | |
| 66 | + return len(SOURCE_PRIORITY) | |
| 67 | + | |
| 68 | + | |
| 69 | +def run() -> dict: | |
| 70 | + """Déduplique les restos actifs. Retourne des statistiques.""" | |
| 71 | + con = db.connect() | |
| 72 | + rows = [dict(r) for r in con.execute( | |
| 73 | + "SELECT uid, source, name, phone, postal_code, lat, lng" | |
| 74 | + " FROM restaurants WHERE active=1")] | |
| 75 | + groups: dict[str, set[str]] = {} | |
| 76 | + by_uid = {r["uid"]: r for r in rows} | |
| 77 | + for r in rows: | |
| 78 | + for fp in fingerprints(r): | |
| 79 | + groups.setdefault(fp, set()).add(r["uid"]) | |
| 80 | + | |
| 81 | + # union des groupes qui partagent une fiche (empreintes multiples) | |
| 82 | + parent: dict[str, str] = {} | |
| 83 | + | |
| 84 | + def find(u: str) -> str: | |
| 85 | + while parent.get(u, u) != u: | |
| 86 | + parent[u] = parent.get(parent[u], parent[u]) | |
| 87 | + u = parent[u] | |
| 88 | + return u | |
| 89 | + | |
| 90 | + def union(a: str, b: str) -> None: | |
| 91 | + ra, rb = find(a), find(b) | |
| 92 | + if ra != rb: | |
| 93 | + parent[rb] = ra | |
| 94 | + | |
| 95 | + for uids in groups.values(): | |
| 96 | + uids = sorted(uids) | |
| 97 | + for other in uids[1:]: | |
| 98 | + union(uids[0], other) | |
| 99 | + | |
| 100 | + clusters: dict[str, list[str]] = {} | |
| 101 | + for r in rows: | |
| 102 | + clusters.setdefault(find(r["uid"]), []).append(r["uid"]) | |
| 103 | + | |
| 104 | + now = time.time() | |
| 105 | + merged = 0 | |
| 106 | + for members in clusters.values(): | |
| 107 | + if len(members) < 2: | |
| 108 | + continue | |
| 109 | + # une fiche par SOURCE au maximum peut être fusionnée : deux fiches de | |
| 110 | + # la même source = deux succursales (jamais fusionnées, §12.1) | |
| 111 | + by_source: dict[str, str] = {} | |
| 112 | + for uid in sorted(members, key=lambda u: _priority(by_uid[u]["source"])): | |
| 113 | + src = by_uid[uid]["source"] | |
| 114 | + if src in by_source: | |
| 115 | + continue | |
| 116 | + by_source[src] = uid | |
| 117 | + canon, *dups = list(by_source.values()) | |
| 118 | + if not dups: | |
| 119 | + continue | |
| 120 | + dup_sources = sorted({by_uid[u]["source"] for u in dups}) | |
| 121 | + con.execute("UPDATE restaurants SET dup_sources=?, updated_at=? WHERE uid=?", | |
| 122 | + (json.dumps(dup_sources, ensure_ascii=False), now, canon)) | |
| 123 | + for uid in dups: | |
| 124 | + con.execute("UPDATE restaurants SET dup_of=?, updated_at=? WHERE uid=?", | |
| 125 | + (canon, now, uid)) | |
| 126 | + merged += 1 | |
| 127 | + print(f"[resto-ka] dedup: {canon} <- {dups}") # journaliser (§12.1) | |
| 128 | + con.commit() | |
| 129 | + con.close() | |
| 130 | + return {"clusters": sum(1 for m in clusters.values() if len(m) > 1), | |
| 131 | + "merged": merged} | |
added
restoka/geocode.py
+173 −0
@@ -0,0 +1,173 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/geocode.py | |
| 4 | +# Desc: Géocodage des adresses de restaurants (lat/lng OBLIGATOIRES, §5) via | |
| 5 | +# Nominatim (OpenStreetMap) avec repli Adresses Québec (MERN/ArcGIS). | |
| 6 | +# Cache persistant (geocode_cache), politesse 1 req/s, validation | |
| 7 | +# provinciale des coordonnées. Adapté de louka/geocode.py. | |
| 8 | +# ============================================================================== | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import re | |
| 12 | +import time | |
| 13 | + | |
| 14 | +import requests | |
| 15 | + | |
| 16 | +from . import db | |
| 17 | +from .regions import attach_region, strip_accents | |
| 18 | + | |
| 19 | +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" | |
| 20 | +# Repli officiel du gouvernement du Québec (couvre les adresses récentes) | |
| 21 | +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" | |
| 22 | + "Adresse_Geocodage/GeocodeServer/findAddressCandidates") | |
| 23 | +AQ_MIN_SCORE = 75 | |
| 24 | +# ASCII pur : le serveur ArcGIS retourne 500 si le User-Agent contient des accents | |
| 25 | +USER_AGENT = "RestoKaBot/1.0 (agregateur restaurants Quebec; +contact@spboucher.ai)" | |
| 26 | +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s | |
| 27 | +RETRY_FAILED_AFTER = 30 * 86400 | |
| 28 | + | |
| 29 | +# garde-fou provincial (même boîte que schema.finalize()) | |
| 30 | +_BBOX_QC = (44.50, 63.00, -80.00, -56.00) | |
| 31 | + | |
| 32 | + | |
| 33 | +def norm_key(address: str, city: str) -> str: | |
| 34 | + """Clé de cache : adresse+ville normalisées (casse, accents, espaces).""" | |
| 35 | + s = strip_accents(f"{address} {city}".lower()) | |
| 36 | + s = re.sub(r"\b(?:app?t?|suite|local|bureau|#)\.?\s*[\w-]+\b", " ", s) | |
| 37 | + s = re.sub(r"[^a-z0-9]+", " ", s) | |
| 38 | + return re.sub(r"\s+", " ", s).strip() | |
| 39 | + | |
| 40 | + | |
| 41 | +class Geocoder: | |
| 42 | + def __init__(self, con) -> None: | |
| 43 | + self.con = con | |
| 44 | + self.session = requests.Session() | |
| 45 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 46 | + self._last = 0.0 | |
| 47 | + | |
| 48 | + def _throttle(self) -> None: | |
| 49 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 50 | + if wait > 0: | |
| 51 | + time.sleep(wait) | |
| 52 | + | |
| 53 | + @staticmethod | |
| 54 | + def _clean(address: str) -> str: | |
| 55 | + """Nettoyages qui aident Nominatim sur les adresses de commerces : | |
| 56 | + suffixes de local/suite, mentions d'unité après virgule.""" | |
| 57 | + s = (address or "").strip() | |
| 58 | + s = re.sub(r",?\s*(?:local|suite|bureau|unite|app\.?|apt\.?|#|s?suite)\s*" | |
| 59 | + r"[\w-]+\s*$", "", s, flags=re.I) | |
| 60 | + s = re.sub(r",\s*[A-Z]?\d+[A-Z]?\s*$", "", s) # « …, F10 » | |
| 61 | + return s.strip().strip(",") | |
| 62 | + | |
| 63 | + def _query(self, address: str, city: str) -> str: | |
| 64 | + q = self._clean(address) | |
| 65 | + if city and strip_accents(city.lower()) not in strip_accents(q.lower()): | |
| 66 | + q += f", {city}" | |
| 67 | + if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower(): | |
| 68 | + q += ", Québec" | |
| 69 | + return q + ", Canada" | |
| 70 | + | |
| 71 | + def _nominatim(self, q: str) -> tuple[float, float] | None: | |
| 72 | + self._throttle() | |
| 73 | + try: | |
| 74 | + resp = self.session.get(NOMINATIM_URL, params={ | |
| 75 | + "q": q, "format": "jsonv2", "limit": 1, "countrycodes": "ca", | |
| 76 | + }, timeout=20) | |
| 77 | + self._last = time.time() | |
| 78 | + resp.raise_for_status() | |
| 79 | + hits = resp.json() | |
| 80 | + except Exception: | |
| 81 | + self._last = time.time() | |
| 82 | + return None | |
| 83 | + if not hits: | |
| 84 | + return None | |
| 85 | + try: | |
| 86 | + return float(hits[0]["lat"]), float(hits[0]["lon"]) | |
| 87 | + except (KeyError, ValueError): | |
| 88 | + return None | |
| 89 | + | |
| 90 | + def _adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None: | |
| 91 | + self._throttle() | |
| 92 | + try: | |
| 93 | + resp = self.session.get(AQ_URL, params={ | |
| 94 | + "SingleLine": f"{self._clean(address)}, {city}", "f": "json", | |
| 95 | + "outSR": '{"wkid":4326}', "maxLocations": 1, | |
| 96 | + }, timeout=20) | |
| 97 | + self._last = time.time() | |
| 98 | + resp.raise_for_status() | |
| 99 | + cands = resp.json().get("candidates") or [] | |
| 100 | + except Exception: | |
| 101 | + self._last = time.time() | |
| 102 | + return None | |
| 103 | + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE: | |
| 104 | + return None | |
| 105 | + loc = cands[0].get("location") or {} | |
| 106 | + try: | |
| 107 | + return float(loc["y"]), float(loc["x"]) | |
| 108 | + except (KeyError, ValueError): | |
| 109 | + return None | |
| 110 | + | |
| 111 | + def geocode(self, address: str, city: str) -> tuple[float, float] | None: | |
| 112 | + """Géocode avec cache. None si échec (JAMAIS de coordonnées bidon).""" | |
| 113 | + if not address: | |
| 114 | + return None | |
| 115 | + key = norm_key(address, city) | |
| 116 | + row = self.con.execute( | |
| 117 | + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?", | |
| 118 | + (key,)).fetchone() | |
| 119 | + if row is not None: | |
| 120 | + if not row["failed"]: | |
| 121 | + return row["lat"], row["lng"] | |
| 122 | + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: | |
| 123 | + return None | |
| 124 | + q = self._query(address, city) | |
| 125 | + hit = self._nominatim(q) | |
| 126 | + provider = "nominatim" | |
| 127 | + if hit is None or not (_BBOX_QC[0] <= hit[0] <= _BBOX_QC[1] | |
| 128 | + and _BBOX_QC[2] <= hit[1] <= _BBOX_QC[3]): | |
| 129 | + hit = self._adresses_quebec(address, city) | |
| 130 | + provider = "adresses-quebec" | |
| 131 | + if hit is not None and (_BBOX_QC[0] <= hit[0] <= _BBOX_QC[1] | |
| 132 | + and _BBOX_QC[2] <= hit[1] <= _BBOX_QC[3]): | |
| 133 | + self.con.execute( | |
| 134 | + "INSERT OR REPLACE INTO geocode_cache (address, lat, lng," | |
| 135 | + " provider, failed, ts) VALUES (?,?,?,?,0,?)", | |
| 136 | + (key, hit[0], hit[1], provider, time.time())) | |
| 137 | + self.con.commit() | |
| 138 | + return hit | |
| 139 | + self.con.execute( | |
| 140 | + "INSERT OR REPLACE INTO geocode_cache (address, lat, lng, provider," | |
| 141 | + " failed, ts) VALUES (?,NULL,NULL,NULL,1,?)", (key, time.time())) | |
| 142 | + self.con.commit() | |
| 143 | + return None | |
| 144 | + | |
| 145 | + | |
| 146 | +def run_batch(limit: int = 200) -> dict: | |
| 147 | + """Géocode les restos actifs sans coordonnées (max `limit` requêtes), | |
| 148 | + puis rattache la région manquante à partir des coordonnées obtenues.""" | |
| 149 | + con = db.connect() | |
| 150 | + geo = Geocoder(con) | |
| 151 | + done = failed = 0 | |
| 152 | + rows = con.execute( | |
| 153 | + "SELECT uid, address, city, postal_code, region FROM restaurants" | |
| 154 | + " WHERE active=1 AND (lat IS NULL OR lng IS NULL) AND geocode_failed=0" | |
| 155 | + " LIMIT ?", (limit,)).fetchall() | |
| 156 | + for r in rows: | |
| 157 | + hit = geo.geocode(r["address"], r["city"]) | |
| 158 | + if hit is None: | |
| 159 | + con.execute("UPDATE restaurants SET geocode_failed=1 WHERE uid=?", | |
| 160 | + (r["uid"],)) | |
| 161 | + failed += 1 | |
| 162 | + continue | |
| 163 | + region = r["region"] or attach_region(r["city"], r["postal_code"], | |
| 164 | + hit[0], hit[1]) | |
| 165 | + con.execute( | |
| 166 | + "UPDATE restaurants SET lat=?, lng=?, region=?, geocode_failed=0" | |
| 167 | + " WHERE uid=?", (hit[0], hit[1], region, r["uid"])) | |
| 168 | + done += 1 | |
| 169 | + con.commit() | |
| 170 | + con.close() | |
| 171 | + stats = {"geocoded": done, "failed": failed, "pending": max(0, len(rows) - done - failed)} | |
| 172 | + print(f"[resto-ka] geocode: {stats}") | |
| 173 | + return stats | |
added
restoka/ingest.py
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/ingest.py | |
| 4 | +# Desc: Pipeline d'ingestion — exécute les connecteurs et synchronise la base | |
| 5 | +# (ajouts / mises à jour / retraits avec délai de grâce). Une fiche | |
| 6 | +# malformée (menu sans price_context…) est rejetée, jamais stockée. | |
| 7 | +# Calqué sur louka/ingest.py. | |
| 8 | +# ============================================================================== | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import sys | |
| 12 | +import time | |
| 13 | +import traceback | |
| 14 | + | |
| 15 | +from . import db | |
| 16 | +from .connectors import CONNECTORS | |
| 17 | + | |
| 18 | + | |
| 19 | +def run(sources: list[str] | None = None) -> list[dict]: | |
| 20 | + """Exécute l'ingestion pour toutes les sources (ou celles demandées).""" | |
| 21 | + con = db.connect() | |
| 22 | + results = [] | |
| 23 | + targets = sources or list(CONNECTORS.keys()) | |
| 24 | + for sid in targets: | |
| 25 | + cls = CONNECTORS.get(sid) | |
| 26 | + if cls is None: | |
| 27 | + print(f"[resto-ka] connecteur inconnu : {sid}", file=sys.stderr) | |
| 28 | + continue | |
| 29 | + t0 = time.time() | |
| 30 | + print(f"[resto-ka] sync {sid} ...") | |
| 31 | + try: | |
| 32 | + restos = cls().fetch() | |
| 33 | + finalized, dropped = [], 0 | |
| 34 | + for r in restos: | |
| 35 | + try: | |
| 36 | + finalized.append(r.finalize()) | |
| 37 | + except Exception as exc: # une fiche malformée ne bloque pas la source | |
| 38 | + dropped += 1 | |
| 39 | + print(f"[resto-ka] fiche rejetée : {exc}", file=sys.stderr) | |
| 40 | + stats = db.sync_source(con, sid, finalized) | |
| 41 | + stats["seconds"] = round(time.time() - t0, 1) | |
| 42 | + if dropped: | |
| 43 | + stats["dropped"] = dropped | |
| 44 | + if stats.get("alert"): | |
| 45 | + print(f"[resto-ka] ⚠ ALERTE {sid} : {stats['alert']}") | |
| 46 | + print(f"[resto-ka] {stats}") | |
| 47 | + results.append(stats) | |
| 48 | + except Exception as exc: # robustesse : une source ne bloque pas les autres | |
| 49 | + db.log_failure(con, sid, f"{exc}") | |
| 50 | + traceback.print_exc() | |
| 51 | + results.append({"source": sid, "error": str(exc)}) | |
| 52 | + try: | |
| 53 | + con.execute("PRAGMA optimize") | |
| 54 | + except Exception: | |
| 55 | + pass | |
| 56 | + con.close() | |
| 57 | + return results | |
| 58 | + | |
| 59 | + | |
| 60 | +def enrich() -> None: | |
| 61 | + """Étapes d'enrichissement post-sync (géocodage, région, déduplication).""" | |
| 62 | + try: # géocoder les nouvelles adresses (lat/lng obligatoires §5) | |
| 63 | + from . import geocode | |
| 64 | + geocode.run_batch() | |
| 65 | + except Exception as exc: | |
| 66 | + print(f"[resto-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr) | |
| 67 | + try: # déduplication inter-sources (APRÈS géocodage : coords requises) | |
| 68 | + from . import dedup | |
| 69 | + stats = dedup.run() | |
| 70 | + print(f"[resto-ka] dedup: {stats}") | |
| 71 | + except Exception as exc: | |
| 72 | + print(f"[resto-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr) | |
| 73 | + | |
| 74 | + | |
| 75 | +def watch(interval_seconds: int = 7 * 86400 // 7) -> None: | |
| 76 | + """Boucle de rafraîchissement périodique (défaut : quotidien — les grandes | |
| 77 | + plateformes se rafraîchissent au moins chaque semaine, CLAUDE.md §14).""" | |
| 78 | + while True: | |
| 79 | + run() | |
| 80 | + enrich() | |
| 81 | + print(f"[resto-ka] prochaine synchronisation dans {interval_seconds}s") | |
| 82 | + time.sleep(interval_seconds) | |
| 83 | + | |
| 84 | + | |
| 85 | +if __name__ == "__main__": | |
| 86 | + run(sys.argv[1:] or None) | |
| 87 | + enrich() | |
added
restoka/normalize.py
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/normalize.py | |
| 4 | +# Desc: Couche de normalisation commune : nettoyage des noms/adresses, | |
| 5 | +# parsing des prix, classification des cuisines et du type | |
| 6 | +# d'établissement, diètes, fourchette de prix ($ à $$$$). | |
| 7 | +# Calquée sur louka/normalize.py (famille ·Ka). | |
| 8 | +# ============================================================================== | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import html | |
| 12 | +import re | |
| 13 | +import statistics | |
| 14 | + | |
| 15 | +from .regions import strip_accents | |
| 16 | + | |
| 17 | +__all__ = [ | |
| 18 | + "strip_accents", "clean_name", "clean_address", "parse_price", | |
| 19 | + "classify_cuisines", "classify_establishment", "infer_dietary", | |
| 20 | + "price_range_from_menu", "CUISINES", "PRICE_CONTEXTS", | |
| 21 | +] | |
| 22 | + | |
| 23 | +# Taxonomie des cuisines (CLAUDE.md §6.1) — multi-valué. | |
| 24 | +CUISINES = [ | |
| 25 | + "quebecois", "francais", "italien", "pizza", "burgers", "poulet", | |
| 26 | + "bbq-grillades", "fruits-de-mer", "sushi-japonais", "chinois", "thai", | |
| 27 | + "vietnamien", "coreen", "indien", "libanais-moyen-orient", "mexicain", | |
| 28 | + "grec", "mediterraneen", "dejeuner-brunch", "cafe-dessert", | |
| 29 | + "vegetarien-vegan", "fast-food", "autre", | |
| 30 | +] | |
| 31 | + | |
| 32 | +# Contextes de prix admissibles (CLAUDE.md §6.2) — un prix sans contexte est | |
| 33 | +# inutilisable ; `delivery` est majoré de 25-30 % vs la salle. | |
| 34 | +PRICE_CONTEXTS = ("dine-in", "takeout", "delivery") | |
| 35 | + | |
| 36 | +ESTABLISHMENT_TYPES = ( | |
| 37 | + "restaurant", "fast-food", "cafe", "bar", "food-truck", "traiteur", | |
| 38 | + "boulangerie-patisserie", "casse-croute", "microbrasserie", "hotel", | |
| 39 | + "ghost-kitchen", | |
| 40 | +) | |
| 41 | + | |
| 42 | + | |
| 43 | +def clean_name(name: str) -> str: | |
| 44 | + """Nettoie un nom de resto/section/plat : HTML résiduel, espaces, casse SPAM.""" | |
| 45 | + s = html.unescape(name or "") | |
| 46 | + s = re.sub(r"<[^>]+>", " ", s) | |
| 47 | + s = re.sub(r"\s+", " ", s).strip() | |
| 48 | + # « CRÉEZ VOTRE BOL » -> « Créez Votre Bol » seulement si TOUT est en capitales | |
| 49 | + if len(s) > 3 and s == s.upper() and any(c.isalpha() for c in s): | |
| 50 | + s = s.title() | |
| 51 | + return s | |
| 52 | + | |
| 53 | + | |
| 54 | +def clean_address(address: str) -> str: | |
| 55 | + s = html.unescape(address or "") | |
| 56 | + s = re.sub(r"\s+", " ", s).strip().strip(",") | |
| 57 | + # « 8245 Local A15 » -> garder le civique | |
| 58 | + s = re.sub(r"^(\d+)\s+(?:local|suite|bureau|unite|#)\s*[\w-]+\s", r"\1 ", s, | |
| 59 | + flags=re.I) | |
| 60 | + return s | |
| 61 | + | |
| 62 | + | |
| 63 | +_PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?") | |
| 64 | + | |
| 65 | + | |
| 66 | +def parse_price(text: str | float | None) -> float | None: | |
| 67 | + """Extrait un prix en dollars d'un texte (« 16,50 $ », « à partir de 12$ »).""" | |
| 68 | + if text is None: | |
| 69 | + return None | |
| 70 | + if isinstance(text, (int, float)): | |
| 71 | + return round(float(text), 2) if float(text) > 0 else None | |
| 72 | + m = _PRICE_RE.search(text.replace("\xa0", " ")) | |
| 73 | + if not m: | |
| 74 | + return None | |
| 75 | + raw = m.group(1).replace(" ", "").replace(" ", "").replace(",", ".") | |
| 76 | + try: | |
| 77 | + val = round(float(raw), 2) | |
| 78 | + except ValueError: | |
| 79 | + return None | |
| 80 | + return val if val > 0 else None | |
| 81 | + | |
| 82 | + | |
| 83 | +# --- classification des cuisines --------------------------------------------- | |
| 84 | +# mots-clés (sans accents, minuscules) -> cuisine. L'ordre n'importe pas : | |
| 85 | +# multi-valué, toutes les cuisines détectées sont retournées. | |
| 86 | +_CUISINE_KEYWORDS: dict[str, tuple[str, ...]] = { | |
| 87 | + "pizza": ("pizza", "pizzeria", "pizzas"), | |
| 88 | + "sushi-japonais": ("sushi", "maki", "sashimi", "izakaya", "ramen", | |
| 89 | + "japonais", "poke", "bento", "tempura"), | |
| 90 | + "burgers": ("burger", "burgers", "smash"), | |
| 91 | + "poulet": ("poulet", "chicken", "rotisserie", "shish taouk"), | |
| 92 | + "italien": ("italien", "trattoria", "osteria", "pasta", "pates fraiches", | |
| 93 | + "risotto", "gnocchi"), | |
| 94 | + "francais": ("bistro francais", "brasserie francaise", "cuisine francaise"), | |
| 95 | + "quebecois": ("poutine", "casse-croute", "tourtiere", "cabane a sucre", | |
| 96 | + "smoked meat", "quebecois"), | |
| 97 | + "chinois": ("chinois", "dim sum", "szechuan", "cantonais", "wok", | |
| 98 | + "dumpling", "boba", "bubble tea"), | |
| 99 | + "thai": ("thai", "pad thai", "thailandais"), | |
| 100 | + "vietnamien": ("pho", "vietnamien", "banh mi", "tonkinoise"), | |
| 101 | + "coreen": ("coreen", "kimchi", "bibimbap", "bbq coreen"), | |
| 102 | + "indien": ("indien", "curry", "tandoori", "biryani", "naan"), | |
| 103 | + "libanais-moyen-orient": ("libanais", "shawarma", "falafel", "kebab", | |
| 104 | + "moyen-orient", "hummus", "syrien"), | |
| 105 | + "mexicain": ("taco", "tacos", "mexicain", "burrito", "quesadilla", | |
| 106 | + "taqueria"), | |
| 107 | + "grec": ("grec", "gyro", "souvlaki"), | |
| 108 | + "mediterraneen": ("mediterraneen", "meze"), | |
| 109 | + "fruits-de-mer": ("fruits de mer", "poissonnerie", "homard", "crabe", | |
| 110 | + "huitres", "fish and chips"), | |
| 111 | + "bbq-grillades": ("bbq", "grillades", "steakhouse", "cotes levees", | |
| 112 | + "smokehouse", "grill"), | |
| 113 | + "dejeuner-brunch": ("dejeuner", "brunch", "matin", "creperie", "oeufs", | |
| 114 | + "pancake", "waffle", "gaufres"), | |
| 115 | + "cafe-dessert": ("cafe", "espresso", "patisserie", "dessert", "creme glacee", | |
| 116 | + "chocolat", "gelato", "beigne", "the ", "salon de the"), | |
| 117 | + "vegetarien-vegan": ("vegan", "vegetalien", "vegetarien", "vege"), | |
| 118 | + "fast-food": ("fast food", "restauration rapide", "frites"), | |
| 119 | +} | |
| 120 | + | |
| 121 | + | |
| 122 | +def classify_cuisines(name: str, menu_text: str = "") -> list[str]: | |
| 123 | + """Classe un resto dans la taxonomie cuisine à partir de son nom et du texte | |
| 124 | + de son menu. Retourne au moins ["autre"].""" | |
| 125 | + hay = strip_accents(f"{name} {menu_text}".lower()) | |
| 126 | + found = [c for c, kws in _CUISINE_KEYWORDS.items() | |
| 127 | + if any(k in hay for k in kws)] | |
| 128 | + # le nom du resto prime : ne garder que les 4 meilleures correspondances | |
| 129 | + return found[:4] if found else ["autre"] | |
| 130 | + | |
| 131 | + | |
| 132 | +def classify_establishment(name: str, cuisines: list[str]) -> str: | |
| 133 | + hay = strip_accents((name or "").lower()) | |
| 134 | + if any(k in hay for k in ("cafe", "salon de the", "boba", "bubble tea", | |
| 135 | + "espresso", "torrefacteur")): | |
| 136 | + return "cafe" | |
| 137 | + if any(k in hay for k in ("boulangerie", "patisserie")): | |
| 138 | + return "boulangerie-patisserie" | |
| 139 | + if any(k in hay for k in ("casse-croute", "cantine", "roulotte")): | |
| 140 | + return "casse-croute" | |
| 141 | + if any(k in hay for k in ("food truck", "camion")): | |
| 142 | + return "food-truck" | |
| 143 | + if any(k in hay for k in ("pub ", "taverne", "bar ", "brasserie artisanale")): | |
| 144 | + return "bar" | |
| 145 | + if any(k in hay for k in ("microbrasserie", "brouepub")): | |
| 146 | + return "microbrasserie" | |
| 147 | + if any(k in hay for k in ("traiteur",)): | |
| 148 | + return "traiteur" | |
| 149 | + if "fast-food" in cuisines: | |
| 150 | + return "fast-food" | |
| 151 | + return "restaurant" | |
| 152 | + | |
| 153 | + | |
| 154 | +_DIET_KEYWORDS = { | |
| 155 | + "vegan": ("vegan", "vegetalien", "vegetalienne"), | |
| 156 | + "vegetarien": ("vegetarien", "vegetarienne", "vege", "veggie"), | |
| 157 | + "sans-gluten": ("sans gluten", "gluten free", "sans-gluten"), | |
| 158 | + "halal": ("halal",), | |
| 159 | + "casher": ("casher", "kascher", "kosher"), | |
| 160 | + "sans-noix": ("sans noix", "sans arachide"), | |
| 161 | + "epice": ("epice", "piquant", "spicy"), | |
| 162 | +} | |
| 163 | + | |
| 164 | + | |
| 165 | +def infer_dietary(menu: dict | None) -> list[str]: | |
| 166 | + """Diètes offertes, déduites des tags et descriptions du menu.""" | |
| 167 | + if not menu: | |
| 168 | + return [] | |
| 169 | + parts: list[str] = [] | |
| 170 | + for sec in menu.get("sections") or []: | |
| 171 | + for it in sec.get("items") or []: | |
| 172 | + parts.append(it.get("name") or "") | |
| 173 | + parts.append(it.get("description") or "") | |
| 174 | + parts.extend(it.get("tags") or []) | |
| 175 | + hay = strip_accents(" ".join(parts).lower()) | |
| 176 | + return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)] | |
| 177 | + | |
| 178 | + | |
| 179 | +def infer_item_tags(name: str, description: str = "", | |
| 180 | + labels: list[str] | None = None) -> list[str]: | |
| 181 | + """Tags diète d'un item de menu (vegan, sans-gluten, épicé…).""" | |
| 182 | + hay = strip_accents(" ".join([name or "", description or ""] | |
| 183 | + + list(labels or [])).lower()) | |
| 184 | + return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)] | |
| 185 | + | |
| 186 | + | |
| 187 | +def price_range_from_menu(menu: dict | None) -> str: | |
| 188 | + """Fourchette $ à $$$$ estimée sur le prix médian des items non nuls.""" | |
| 189 | + if not menu: | |
| 190 | + return "" | |
| 191 | + prices = [it.get("price") for sec in menu.get("sections") or [] | |
| 192 | + for it in sec.get("items") or [] | |
| 193 | + if isinstance(it.get("price"), (int, float)) and it["price"] > 3] | |
| 194 | + if not prices: | |
| 195 | + return "" | |
| 196 | + med = statistics.median(prices) | |
| 197 | + if med < 15: | |
| 198 | + return "$" | |
| 199 | + if med < 30: | |
| 200 | + return "$$" | |
| 201 | + if med < 60: | |
| 202 | + return "$$$" | |
| 203 | + return "$$$$" | |
added
restoka/regions.py
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/regions.py | |
| 4 | +# Desc: Rattachement obligatoire de chaque restaurant à l'une des 17 régions | |
| 5 | +# administratives du Québec : table ville → région, repli par préfixe | |
| 6 | +# de code postal, puis par coordonnées (boîtes englobantes). | |
| 7 | +# ============================================================================== | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import re | |
| 11 | +import unicodedata | |
| 12 | + | |
| 13 | +REGIONS = [ | |
| 14 | + "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale", | |
| 15 | + "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue", | |
| 16 | + "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine", | |
| 17 | + "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", | |
| 18 | + "Montérégie", "Centre-du-Québec", | |
| 19 | +] | |
| 20 | + | |
| 21 | + | |
| 22 | +def strip_accents(text: str) -> str: | |
| 23 | + return "".join(c for c in unicodedata.normalize("NFD", text) | |
| 24 | + if unicodedata.category(c) != "Mn") | |
| 25 | + | |
| 26 | + | |
| 27 | +def _key(city: str) -> str: | |
| 28 | + s = strip_accents((city or "").strip().lower()) | |
| 29 | + s = re.sub(r"^(ville de|city of)\s+", "", s) | |
| 30 | + s = re.sub(r"\s*\(.*\)$", "", s) # « Fiset Sorel-Tracy (arr.) » | |
| 31 | + s = re.sub(r"[^a-z0-9]+", "-", s) | |
| 32 | + return s.strip("-") | |
| 33 | + | |
| 34 | + | |
| 35 | +# Ville (clé normalisée) → région. Municipalités principales + arrondissements | |
| 36 | +# fréquents dans les adresses des plateformes. Étendre au besoin (une ville | |
| 37 | +# inconnue n'est jamais inventée : repli code postal, puis coordonnées). | |
| 38 | +VILLE_REGION: dict[str, str] = { | |
| 39 | + # --- Montréal (île) ------------------------------------------------------- | |
| 40 | + **{v: "Montréal" for v in ( | |
| 41 | + "montreal", "montreal-est", "montreal-ouest", "montreal-nord", | |
| 42 | + "anjou", "lasalle", "lachine", "verdun", "outremont", "westmount", | |
| 43 | + "saint-leonard", "saint-laurent", "ahuntsic", "ahuntsic-cartierville", | |
| 44 | + "rosemont", "villeray", "hochelaga-maisonneuve", "mercier", | |
| 45 | + "riviere-des-prairies", "pointe-aux-trembles", "pierrefonds", | |
| 46 | + "roxboro", "dollard-des-ormeaux", "pointe-claire", "kirkland", | |
| 47 | + "beaconsfield", "baie-d-urfe", "sainte-anne-de-bellevue", "senneville", | |
| 48 | + "dorval", "mont-royal", "ville-mont-royal", "cote-saint-luc", | |
| 49 | + "hampstead", "montreal-royal", "le-plateau-mont-royal", "griffintown", | |
| 50 | + "ville-marie", "cote-des-neiges", "notre-dame-de-grace", | |
| 51 | + "ile-bizard", "sainte-genevieve", | |
| 52 | + )}, | |
| 53 | + # --- Laval ---------------------------------------------------------------- | |
| 54 | + **{v: "Laval" for v in ( | |
| 55 | + "laval", "chomedey", "sainte-rose", "vimont", "duvernay", "fabreville", | |
| 56 | + "sainte-dorothee", "pont-viau", "laval-des-rapides", "auteuil", | |
| 57 | + )}, | |
| 58 | + # --- Capitale-Nationale --------------------------------------------------- | |
| 59 | + **{v: "Capitale-Nationale" for v in ( | |
| 60 | + "quebec", "l-ancienne-lorette", "ancienne-lorette", | |
| 61 | + "saint-augustin-de-desmaures", "sainte-foy", "beauport", "charlesbourg", | |
| 62 | + "loretteville", "val-belair", "cap-rouge", "sillery", "limoilou", | |
| 63 | + "wendake", "boischatel", "l-ange-gardien", "chateau-richer", | |
| 64 | + "sainte-anne-de-beaupre", "beaupre", "saint-ferreol-les-neiges", | |
| 65 | + "baie-saint-paul", "la-malbaie", "clermont", "saint-simeon", | |
| 66 | + "petite-riviere-saint-francois", "donnacona", "pont-rouge", | |
| 67 | + "saint-raymond", "portneuf", "cap-sante", "neuville", | |
| 68 | + "lac-beauport", "stoneham", "stoneham-et-tewkesbury", "deschambault", | |
| 69 | + "deschambault-grondines", | |
| 70 | + "sainte-catherine-de-la-jacques-cartier", "shannon", "sainte-brigitte-de-laval", | |
| 71 | + "ile-d-orleans", "saint-pierre-de-l-ile-d-orleans", | |
| 72 | + )}, | |
| 73 | + # --- Chaudière-Appalaches ------------------------------------------------- | |
| 74 | + **{v: "Chaudière-Appalaches" for v in ( | |
| 75 | + "levis", "saint-romuald", "charny", "saint-nicolas", | |
| 76 | + "saint-jean-chrysostome", "pintendre", "saint-redempteur", | |
| 77 | + "breakeyville", "saint-etienne-de-lauzon", "saint-henri", | |
| 78 | + "montmagny", "l-islet", "saint-jean-port-joli", "sainte-marie", | |
| 79 | + "saint-georges", "saint-joseph-de-beauce", "beauceville", | |
| 80 | + "thetford-mines", "lac-etchemin", "saint-anselme", "scott", | |
| 81 | + "saint-come-liniere", "st-georges-de-beauce", "saint-georges-de-beauce", | |
| 82 | + "vallee-jonction", "saint-lambert-de-lauzon", "laurier-station", | |
| 83 | + "saint-apollinaire", "saint-agapit", | |
| 84 | + )}, | |
| 85 | + # --- Montérégie ----------------------------------------------------------- | |
| 86 | + **{v: "Montérégie" for v in ( | |
| 87 | + "longueuil", "brossard", "saint-lambert", "greenfield-park", | |
| 88 | + "saint-hubert", "boucherville", "saint-bruno-de-montarville", | |
| 89 | + "sainte-julie", "varennes", "vercheres", "contrecoeur", "beloeil", | |
| 90 | + "mont-saint-hilaire", "otterburn-park", "mcmasterville", | |
| 91 | + "saint-basile-le-grand", "chambly", "carignan", "richelieu", "marieville", | |
| 92 | + "saint-jean-sur-richelieu", "iberville", "saint-luc", | |
| 93 | + "granby", "waterloo", "cowansville", "bromont", "farnham", | |
| 94 | + "salaberry-de-valleyfield", "vaudreuil-dorion", "l-ile-perrot", | |
| 95 | + "pincourt", "saint-lazare", "hudson", "rigaud", "les-coteaux", | |
| 96 | + "coteau-du-lac", "beauharnois", "chateauguay", "mercier-monteregie", | |
| 97 | + "sainte-catherine", "saint-constant", "delson", "candiac", | |
| 98 | + "la-prairie", "saint-philippe", "saint-remi", "napierville", | |
| 99 | + "sorel-tracy", "fiset-sorel-tracy", "sainte-anne-de-sorel", | |
| 100 | + "saint-hyacinthe", "sainte-helene-de-bagot", "acton-vale", "lac-brome", | |
| 101 | + "sutton-monteregie", | |
| 102 | + "huntingdon", "ormstown", "kahnawake", "saint-amable", | |
| 103 | + )}, | |
| 104 | + # --- Estrie ---------------------------------------------------------------- | |
| 105 | + **{v: "Estrie" for v in ( | |
| 106 | + "sherbrooke", "magog", "orford", "north-hatley", "ayer-s-cliff", | |
| 107 | + "coaticook", "stanstead", "rock-forest", "fleurimont", "lennoxville", | |
| 108 | + "windsor", "val-des-sources", "asbestos", "danville", "richmond", | |
| 109 | + "east-angus", "cookshire-eaton", "lac-megantic", "sutton", | |
| 110 | + "knowlton", "eastman", "valcourt", "waterville", "compton", | |
| 111 | + )}, | |
| 112 | + # --- Mauricie --------------------------------------------------------------- | |
| 113 | + **{v: "Mauricie" for v in ( | |
| 114 | + "trois-rivieres", "cap-de-la-madeleine", "shawinigan", "grand-mere", | |
| 115 | + "la-tuque", "louiseville", "yamachiche", "saint-tite", | |
| 116 | + "sainte-anne-de-la-perade", "batiscan", "nicolet-mauricie", "maskinonge", | |
| 117 | + "saint-boniface", "saint-etienne-des-gres", "notre-dame-du-mont-carmel", | |
| 118 | + )}, | |
| 119 | + # --- Centre-du-Québec ------------------------------------------------------- | |
| 120 | + **{v: "Centre-du-Québec" for v in ( | |
| 121 | + "drummondville", "victoriaville", "becancour", "nicolet", | |
| 122 | + "plessisville", "princeville", "warwick", "kingsey-falls", | |
| 123 | + "saint-cyrille-de-wendover", "gentilly", "daveluyville", | |
| 124 | + )}, | |
| 125 | + # --- Lanaudière ------------------------------------------------------------- | |
| 126 | + **{v: "Lanaudière" for v in ( | |
| 127 | + "terrebonne", "mascouche", "repentigny", "l-assomption", "charlemagne", | |
| 128 | + "lachenaie", "la-plaine", "joliette", "notre-dame-des-prairies", | |
| 129 | + "saint-charles-borromee", "berthierville", "lavaltrie", "lanoraie", | |
| 130 | + "rawdon", "saint-donat", "saint-michel-des-saints", "saint-lin-laurentides", | |
| 131 | + "saint-roch-de-l-achigan", "l-epiphanie", "saint-sulpice", | |
| 132 | + )}, | |
| 133 | + # --- Laurentides ------------------------------------------------------------ | |
| 134 | + **{v: "Laurentides" for v in ( | |
| 135 | + "saint-jerome", "saint-antoine", "bellefeuille", "lafontaine", | |
| 136 | + "blainville", "boisbriand", "sainte-therese", "rosemere", "lorraine", | |
| 137 | + "bois-des-filion", "deux-montagnes", "saint-eustache", "mirabel", | |
| 138 | + "oka", "pointe-calumet", "sainte-marthe-sur-le-lac", "saint-joseph-du-lac", | |
| 139 | + "prevost", "saint-sauveur", "saint-sauveur-des-monts", "piedmont", | |
| 140 | + "sainte-adele", "morin-heights", | |
| 141 | + "sainte-agathe-des-monts", "val-david", "val-morin", "saint-donat-laurentides", | |
| 142 | + "mont-tremblant", "labelle", "mont-laurier", "riviere-rouge", | |
| 143 | + "sainte-anne-des-plaines", "lachute", "brownsburg-chatham", "grenville", | |
| 144 | + )}, | |
| 145 | + # --- Outaouais -------------------------------------------------------------- | |
| 146 | + **{v: "Outaouais" for v in ( | |
| 147 | + "gatineau", "hull", "aylmer", "buckingham", "masson-angers", | |
| 148 | + "chelsea", "cantley", "wakefield", "la-peche", "val-des-monts", | |
| 149 | + "papineauville", "montebello", "thurso", "maniwaki", "fort-coulonge", | |
| 150 | + "shawville", "campbell-s-bay", | |
| 151 | + )}, | |
| 152 | + # --- Abitibi-Témiscamingue --------------------------------------------------- | |
| 153 | + **{v: "Abitibi-Témiscamingue" for v in ( | |
| 154 | + "rouyn-noranda", "val-d-or", "amos", "la-sarre", "malartic", | |
| 155 | + "riviere-heva", | |
| 156 | + "senneterre", "ville-marie-temiscamingue", "temiscaming", "macamic", | |
| 157 | + )}, | |
| 158 | + # --- Saguenay–Lac-Saint-Jean ------------------------------------------------- | |
| 159 | + **{v: "Saguenay–Lac-Saint-Jean" for v in ( | |
| 160 | + "saguenay", "chicoutimi", "jonquiere", "la-baie", "laterriere", | |
| 161 | + "alma", "roberval", "saint-felicien", "dolbeau-mistassini", | |
| 162 | + "normandin", "saint-prime", "metabetchouan", "hebertville", | |
| 163 | + "saint-ambroise", "saint-honore", "l-anse-saint-jean", "petit-saguenay", | |
| 164 | + )}, | |
| 165 | + # --- Bas-Saint-Laurent ------------------------------------------------------- | |
| 166 | + **{v: "Bas-Saint-Laurent" for v in ( | |
| 167 | + "rimouski", "riviere-du-loup", "matane", "mont-joli", "amqui", | |
| 168 | + "causapscal", "trois-pistoles", "temiscouata-sur-le-lac", "cabano", | |
| 169 | + "degelis", "pohenegamook", "la-pocatiere", "saint-pascal", | |
| 170 | + "kamouraska", "le-bic", "sainte-luce", "sainte-flavie", "saint-fabien", | |
| 171 | + )}, | |
| 172 | + # --- Gaspésie–Îles-de-la-Madeleine -------------------------------------------- | |
| 173 | + **{v: "Gaspésie–Îles-de-la-Madeleine" for v in ( | |
| 174 | + "gaspe", "perce", "chandler", "grande-riviere", "paspebiac", | |
| 175 | + "bonaventure", "new-richmond", "carleton-sur-mer", "maria", | |
| 176 | + "matapedia", "sainte-anne-des-monts", "cap-chat", "murdochville", | |
| 177 | + "les-iles-de-la-madeleine", "cap-aux-meules", "havre-aubert", | |
| 178 | + )}, | |
| 179 | + # --- Côte-Nord ---------------------------------------------------------------- | |
| 180 | + **{v: "Côte-Nord" for v in ( | |
| 181 | + "baie-comeau", "sept-iles", "port-cartier", "forestville", | |
| 182 | + "les-escoumins", "tadoussac", "bergeronnes", "havre-saint-pierre", | |
| 183 | + "fermont", "blanc-sablon", "natashquan", "chute-aux-outardes", | |
| 184 | + )}, | |
| 185 | + # --- Nord-du-Québec ------------------------------------------------------------- | |
| 186 | + **{v: "Nord-du-Québec" for v in ( | |
| 187 | + "chibougamau", "chapais", "lebel-sur-quevillon", "matagami", | |
| 188 | + "radisson", "kuujjuaq", "mistissini", "waskaganish", | |
| 189 | + )}, | |
| 190 | +} | |
| 191 | + | |
| 192 | +# Repli nº 1 : préfixe de code postal (3 caractères) → région. Couverture | |
| 193 | +# partielle mais fiable (les préfixes listés sont sans ambiguïté). | |
| 194 | +_POSTAL_REGION = { | |
| 195 | + "H7": "Laval", # Laval au complet | |
| 196 | + "G1": "Capitale-Nationale", "G2": "Capitale-Nationale", | |
| 197 | + "G3": "Capitale-Nationale", | |
| 198 | + "G6": "Chaudière-Appalaches", "G5V": "Chaudière-Appalaches", | |
| 199 | + "G5X": "Chaudière-Appalaches", | |
| 200 | + "G7": "Saguenay–Lac-Saint-Jean", "G8": "Saguenay–Lac-Saint-Jean", | |
| 201 | + "G5L": "Bas-Saint-Laurent", "G5R": "Bas-Saint-Laurent", | |
| 202 | + "G4X": "Gaspésie–Îles-de-la-Madeleine", "G4W": "Bas-Saint-Laurent", | |
| 203 | + "G4R": "Côte-Nord", "G4Z": "Côte-Nord", "G5C": "Côte-Nord", | |
| 204 | + "G9A": "Mauricie", "G8T": "Mauricie", "G9N": "Mauricie", "G9H": "Centre-du-Québec", | |
| 205 | + "J1": "Estrie", | |
| 206 | + "J2": "Montérégie", "J3": "Montérégie", "J4": "Montérégie", "J5R": "Montérégie", | |
| 207 | + "J6": "Montérégie", | |
| 208 | + "J7": "Laurentides", "J8": "Outaouais", "J9": "Abitibi-Témiscamingue", | |
| 209 | + "J9B": "Outaouais", | |
| 210 | + "J5W": "Lanaudière", "J6E": "Lanaudière", | |
| 211 | +} | |
| 212 | + | |
| 213 | +# Repli nº 2 : boîtes englobantes grossières (lat_min, lat_max, lng_min, lng_max) | |
| 214 | +# — utilisées seulement quand ni la ville ni le code postal ne tranchent. | |
| 215 | +_BBOX_REGION = [ | |
| 216 | + ("Montréal", 45.40, 45.71, -73.98, -73.47), | |
| 217 | + ("Laval", 45.51, 45.70, -73.90, -73.60), | |
| 218 | + ("Capitale-Nationale", 46.70, 47.60, -72.40, -70.30), | |
| 219 | + ("Estrie", 45.00, 45.90, -72.60, -70.80), | |
| 220 | + ("Outaouais", 45.35, 46.60, -77.50, -74.90), | |
| 221 | + ("Saguenay–Lac-Saint-Jean", 48.00, 49.10, -72.90, -70.50), | |
| 222 | +] | |
| 223 | + | |
| 224 | + | |
| 225 | +def attach_region(city: str, postal_code: str = "", | |
| 226 | + lat: float | None = None, lng: float | None = None) -> str: | |
| 227 | + """Région administrative d'un restaurant. Retourne "" si indéterminable | |
| 228 | + (jamais de région inventée).""" | |
| 229 | + region = VILLE_REGION.get(_key(city)) | |
| 230 | + if region: | |
| 231 | + return region | |
| 232 | + pc = (postal_code or "").replace(" ", "").upper() | |
| 233 | + if pc: | |
| 234 | + for prefix in (pc[:3], pc[:2]): | |
| 235 | + if prefix in _POSTAL_REGION: | |
| 236 | + return _POSTAL_REGION[prefix] | |
| 237 | + if lat is not None and lng is not None: | |
| 238 | + for name, la0, la1, lo0, lo1 in _BBOX_REGION: | |
| 239 | + if la0 <= lat <= la1 and lo0 <= lng <= lo1: | |
| 240 | + return name | |
| 241 | + return "" | |
added
restoka/schema.py
+154 −0
@@ -0,0 +1,154 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/schema.py | |
| 4 | +# Desc: Modèle de données standardisé Restaurant + Menu imbriqué | |
| 5 | +# (catégories → plats → prix → options) et normalisation centrale. | |
| 6 | +# Calqué sur louka/schema.py — le schéma est simplement plus profond. | |
| 7 | +# ============================================================================== | |
| 8 | +"""Schéma standard d'un restaurant (CLAUDE.md §5) et validation du menu. | |
| 9 | + | |
| 10 | +Chaque connecteur, peu importe la source, produit des objets `Restaurant` | |
| 11 | +conformes à ce schéma. `finalize()` applique la couche commune : nettoyage, | |
| 12 | +région (obligatoire), cuisines, type d'établissement, fourchette de prix, | |
| 13 | +diètes, validation stricte du menu (`price_context`/`price_source` requis). | |
| 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 ( | |
| 22 | + PRICE_CONTEXTS, | |
| 23 | + classify_cuisines, | |
| 24 | + classify_establishment, | |
| 25 | + clean_address, | |
| 26 | + clean_name, | |
| 27 | + infer_dietary, | |
| 28 | + price_range_from_menu, | |
| 29 | +) | |
| 30 | +from .regions import attach_region | |
| 31 | + | |
| 32 | +__all__ = ["Restaurant", "MenuValidationError"] | |
| 33 | + | |
| 34 | + | |
| 35 | +class MenuValidationError(ValueError): | |
| 36 | + """Menu rejeté (contexte/source de prix manquant, structure invalide).""" | |
| 37 | + | |
| 38 | + | |
| 39 | +@dataclass | |
| 40 | +class Restaurant: | |
| 41 | + """Restaurant standardisé Resto·Ka (une fiche par adresse/succursale).""" | |
| 42 | + | |
| 43 | + source: str # id de la source (data/sources.json) | |
| 44 | + external_id: str # identifiant chez la source | |
| 45 | + name: str # nom normalisé | |
| 46 | + url: str = "" # page du resto chez la source | |
| 47 | + chain: str | None = None # marque/chaîne si multi-succursales | |
| 48 | + cuisines: list[str] = field(default_factory=list) | |
| 49 | + establishment_type: str = "" # restaurant|fast-food|cafe|bar|... | |
| 50 | + price_range: str = "" # $ à $$$$ (estimé du menu) | |
| 51 | + address: str = "" | |
| 52 | + city: str = "" | |
| 53 | + region: str = "" # une des 17 régions (regions.py) | |
| 54 | + postal_code: str = "" | |
| 55 | + lat: float | None = None | |
| 56 | + lng: float | None = None | |
| 57 | + phone: str = "" | |
| 58 | + website: str = "" | |
| 59 | + hours: dict = field(default_factory=dict) # horaires par jour | |
| 60 | + services: list[str] = field(default_factory=list) # dine-in|takeout|delivery | |
| 61 | + dietary_options: list[str] = field(default_factory=list) | |
| 62 | + languages: list[str] = field(default_factory=list) | |
| 63 | + images: list[str] = field(default_factory=list) # logo + photos (URLs absolues) | |
| 64 | + menu: dict | None = None # voir CLAUDE.md §5.2 | |
| 65 | + status: str = "open" # open|temporarily_closed|closed | |
| 66 | + | |
| 67 | + @property | |
| 68 | + def uid(self) -> str: | |
| 69 | + return f"{self.source}:{self.external_id}" | |
| 70 | + | |
| 71 | + def content_hash(self) -> str: | |
| 72 | + """Hash du contenu pour la détection de changements. `captured_at` | |
| 73 | + est exclu : un menu identique recapturé ne doit pas compter comme | |
| 74 | + une mise à jour.""" | |
| 75 | + payload = asdict(self) | |
| 76 | + if payload.get("menu"): | |
| 77 | + payload["menu"] = dict(payload["menu"], captured_at="") | |
| 78 | + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) | |
| 79 | + return hashlib.sha256(blob.encode("utf-8")).hexdigest() | |
| 80 | + | |
| 81 | + # -- validation du menu ---------------------------------------------------- | |
| 82 | + def _validate_menu(self) -> None: | |
| 83 | + m = self.menu | |
| 84 | + if m is None: | |
| 85 | + return | |
| 86 | + if m.get("price_context") not in PRICE_CONTEXTS: | |
| 87 | + raise MenuValidationError( | |
| 88 | + f"{self.uid}: price_context invalide ou manquant " | |
| 89 | + f"({m.get('price_context')!r}) — un prix sans contexte est " | |
| 90 | + "inutilisable (livraison ≠ salle)") | |
| 91 | + if not m.get("price_source"): | |
| 92 | + raise MenuValidationError(f"{self.uid}: price_source manquant") | |
| 93 | + if not m.get("captured_at"): | |
| 94 | + raise MenuValidationError(f"{self.uid}: captured_at manquant") | |
| 95 | + m.setdefault("currency", "CAD") | |
| 96 | + for sec in m.get("sections") or []: | |
| 97 | + if not sec.get("name"): | |
| 98 | + raise MenuValidationError(f"{self.uid}: section sans nom") | |
| 99 | + for it in sec.get("items") or []: | |
| 100 | + if not it.get("name"): | |
| 101 | + raise MenuValidationError( | |
| 102 | + f"{self.uid}: item sans nom dans « {sec['name']} »") | |
| 103 | + price = it.get("price") | |
| 104 | + if price is not None and (not isinstance(price, (int, float)) | |
| 105 | + or price < 0 or price > 10000): | |
| 106 | + raise MenuValidationError( | |
| 107 | + f"{self.uid}: prix implausible {price!r} pour " | |
| 108 | + f"« {it['name']} »") | |
| 109 | + it.setdefault("currency", m["currency"]) | |
| 110 | + | |
| 111 | + def _menu_text(self) -> str: | |
| 112 | + if not self.menu: | |
| 113 | + return "" | |
| 114 | + parts = [] | |
| 115 | + for sec in self.menu.get("sections") or []: | |
| 116 | + parts.append(sec.get("name") or "") | |
| 117 | + parts.extend(it.get("name") or "" for it in sec.get("items") or []) | |
| 118 | + return " ".join(parts) | |
| 119 | + | |
| 120 | + def finalize(self) -> "Restaurant": | |
| 121 | + """Applique la normalisation commune. Appelé par le pipeline d'ingestion. | |
| 122 | + | |
| 123 | + Idempotent ; ne remplace jamais une valeur explicite du connecteur. | |
| 124 | + Lève MenuValidationError si le menu viole le schéma (le pipeline | |
| 125 | + rejette alors la fiche plutôt que de stocker un prix sans contexte). | |
| 126 | + """ | |
| 127 | + self.name = clean_name(self.name) | |
| 128 | + self.address = clean_address(self.address) | |
| 129 | + self.city = self.city.strip() | |
| 130 | + self.postal_code = (self.postal_code or "").strip().upper() | |
| 131 | + | |
| 132 | + self._validate_menu() | |
| 133 | + | |
| 134 | + # coordonnées fournies par la source : rejeter tout point hors de la | |
| 135 | + # province (lat/lng inversés, 0/0) — le géocodeur prendra le relais | |
| 136 | + if self.lat is not None and self.lng is not None: | |
| 137 | + if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0): | |
| 138 | + self.lat = self.lng = None | |
| 139 | + | |
| 140 | + if not self.region: | |
| 141 | + self.region = attach_region(self.city, self.postal_code, | |
| 142 | + self.lat, self.lng) | |
| 143 | + if not self.cuisines: | |
| 144 | + self.cuisines = classify_cuisines(self.name, self._menu_text()) | |
| 145 | + if not self.establishment_type: | |
| 146 | + self.establishment_type = classify_establishment(self.name, | |
| 147 | + self.cuisines) | |
| 148 | + if not self.price_range: | |
| 149 | + self.price_range = price_range_from_menu(self.menu) | |
| 150 | + if not self.dietary_options: | |
| 151 | + self.dietary_options = infer_dietary(self.menu) | |
| 152 | + if not self.languages: | |
| 153 | + self.languages = ["fr"] | |
| 154 | + return self | |
added
restoka/web.py
+409 −0
@@ -0,0 +1,409 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/web.py | |
| 4 | +# Desc: API FastAPI (JSON) + service du frontend React (frontend/dist). | |
| 5 | +# Recherche des restaurants (région, ville, cuisine, diète, prix, texte), | |
| 6 | +# fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout > | |
| 7 | +# delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py. | |
| 8 | +# ============================================================================== | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import json | |
| 12 | +import statistics | |
| 13 | +import threading | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query | |
| 17 | +from fastapi.middleware.cors import CORSMiddleware | |
| 18 | +from fastapi.middleware.gzip import GZipMiddleware | |
| 19 | +from fastapi.responses import FileResponse | |
| 20 | +from fastapi.staticfiles import StaticFiles | |
| 21 | + | |
| 22 | +from . import db, ingest | |
| 23 | +from .normalize import PRICE_CONTEXTS | |
| 24 | +from .regions import REGIONS | |
| 25 | + | |
| 26 | +ROOT = Path(__file__).resolve().parent.parent | |
| 27 | +SOURCES_PATH = ROOT / "data" / "sources.json" | |
| 28 | +FRONTEND_DIST = ROOT / "frontend" / "dist" | |
| 29 | + | |
| 30 | +# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2) | |
| 31 | +_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} | |
| 32 | + | |
| 33 | +app = FastAPI(title="Resto-Ka API", version="1.0", | |
| 34 | + description="Agrégateur des restaurants du Québec — menus & prix") | |
| 35 | +app.add_middleware(CORSMiddleware, allow_origins=["*"], | |
| 36 | + allow_methods=["*"], allow_headers=["*"]) | |
| 37 | +app.add_middleware(GZipMiddleware, minimum_size=1000) | |
| 38 | + | |
| 39 | +_sync_lock = threading.Lock() | |
| 40 | + | |
| 41 | + | |
| 42 | +def _row_to_dict(row) -> dict: | |
| 43 | + d = dict(row) | |
| 44 | + for col in ("cuisines", "services", "dietary_options", "languages", "images"): | |
| 45 | + d[col] = json.loads(d.get(col) or "[]") | |
| 46 | + d["hours"] = json.loads(d.get("hours") or "{}") | |
| 47 | + if d.get("dup_sources"): | |
| 48 | + try: | |
| 49 | + d["dup_sources"] = json.loads(d["dup_sources"]) | |
| 50 | + except (ValueError, TypeError): | |
| 51 | + d["dup_sources"] = [] | |
| 52 | + return d | |
| 53 | + | |
| 54 | + | |
| 55 | +def _menu_summaries(con, uids: list[str]) -> dict[str, dict]: | |
| 56 | + """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes.""" | |
| 57 | + if not uids: | |
| 58 | + return {} | |
| 59 | + qmarks = ",".join("?" * len(uids)) | |
| 60 | + best: dict[str, dict] = {} | |
| 61 | + for r in con.execute( | |
| 62 | + f"SELECT uid, price_context, price_source, captured_at, item_count," | |
| 63 | + f" sections FROM menus WHERE uid IN ({qmarks})", uids): | |
| 64 | + cur = best.get(r["uid"]) | |
| 65 | + if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \ | |
| 66 | + _CTX_ORDER.get(r["price_context"], 9): | |
| 67 | + continue | |
| 68 | + sections = json.loads(r["sections"] or "[]") | |
| 69 | + prices = [it.get("price") | |
| 70 | + for sec in sections | |
| 71 | + for it in sec.get("items") or [] | |
| 72 | + if isinstance(it.get("price"), (int, float)) and it["price"] > 0] | |
| 73 | + image = next((it["image"] for sec in sections | |
| 74 | + for it in sec.get("items") or [] if it.get("image")), | |
| 75 | + next((sec["image"] for sec in sections | |
| 76 | + if sec.get("image")), None)) | |
| 77 | + best[r["uid"]] = { | |
| 78 | + "image": image, | |
| 79 | + "price_context": r["price_context"], | |
| 80 | + "price_source": r["price_source"], | |
| 81 | + "captured_at": r["captured_at"], | |
| 82 | + "item_count": r["item_count"], | |
| 83 | + "price_min": min(prices) if prices else None, | |
| 84 | + "price_median": round(statistics.median(prices), 2) if prices else None, | |
| 85 | + } | |
| 86 | + return best | |
| 87 | + | |
| 88 | + | |
| 89 | +@app.get("/healthz") | |
| 90 | +def healthz(): | |
| 91 | + return {"status": "ok"} | |
| 92 | + | |
| 93 | + | |
| 94 | +@app.get("/api/restaurants") | |
| 95 | +def list_restaurants( | |
| 96 | + region: str | None = None, | |
| 97 | + city: str | None = None, | |
| 98 | + cuisine: str | None = None, | |
| 99 | + establishment_type: str | None = None, | |
| 100 | + diet: str | None = None, # vegan, sans-gluten… | |
| 101 | + service: str | None = None, # takeout, delivery, salle | |
| 102 | + price_range: str | None = None, # $ à $$$$ | |
| 103 | + chain: str | None = None, | |
| 104 | + source: str | None = None, | |
| 105 | + q: str | None = None, | |
| 106 | + active: int = 1, | |
| 107 | + sort: str = "name", # name | price | recent | |
| 108 | + limit: int = Query(60, le=500), | |
| 109 | + offset: int = 0, | |
| 110 | +): | |
| 111 | + con = db.connect() | |
| 112 | + sql = "SELECT * FROM restaurants WHERE dup_of IS NULL" # doublons masqués | |
| 113 | + args: list = [] | |
| 114 | + if active in (0, 1): | |
| 115 | + sql += " AND active=?"; args.append(active) | |
| 116 | + if region: | |
| 117 | + sql += " AND region=?"; args.append(region) | |
| 118 | + if city: | |
| 119 | + sql += " AND city=?"; args.append(city) | |
| 120 | + if cuisine: | |
| 121 | + sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%') | |
| 122 | + if establishment_type: | |
| 123 | + sql += " AND establishment_type=?"; args.append(establishment_type) | |
| 124 | + if diet: | |
| 125 | + sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%') | |
| 126 | + if service: | |
| 127 | + sql += " AND services LIKE ?"; args.append(f'%"{service}"%') | |
| 128 | + if price_range: | |
| 129 | + sql += " AND price_range=?"; args.append(price_range) | |
| 130 | + if chain: | |
| 131 | + sql += " AND chain=?"; args.append(chain) | |
| 132 | + if source: | |
| 133 | + sql += " AND source=?"; args.append(source) | |
| 134 | + if q: | |
| 135 | + sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)" | |
| 136 | + args += [f"%{q}%"] * 4 | |
| 137 | + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] | |
| 138 | + if sort == "recent": | |
| 139 | + sql += " ORDER BY updated_at DESC" | |
| 140 | + elif sort == "price": | |
| 141 | + sql += " ORDER BY price_range='' , price_range ASC, name ASC" | |
| 142 | + else: | |
| 143 | + sql += " ORDER BY name COLLATE NOCASE ASC" | |
| 144 | + sql += " LIMIT ? OFFSET ?" | |
| 145 | + args += [limit, offset] | |
| 146 | + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] | |
| 147 | + menus = _menu_summaries(con, [r["uid"] for r in rows]) | |
| 148 | + for r in rows: | |
| 149 | + r["menu_summary"] = menus.get(r["uid"]) | |
| 150 | + con.close() | |
| 151 | + return {"total": total, "count": len(rows), "restaurants": rows} | |
| 152 | + | |
| 153 | + | |
| 154 | +@app.get("/api/dishes") | |
| 155 | +def search_dishes( | |
| 156 | + q: str = Query(..., min_length=2), | |
| 157 | + region: str | None = None, | |
| 158 | + city: str | None = None, | |
| 159 | + cuisine: str | None = None, | |
| 160 | + price_max: float | None = None, | |
| 161 | + limit: int = Query(30, le=100), | |
| 162 | + offset: int = 0, | |
| 163 | +): | |
| 164 | + """Recherche PAR PLAT dans tous les menus (nom + description). | |
| 165 | + | |
| 166 | + Les succursales d'une même chaîne partagent le même menu : les résultats | |
| 167 | + sont regroupés par (marque, plat, prix, contexte) avec le nombre | |
| 168 | + d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques. | |
| 169 | + """ | |
| 170 | + con = db.connect() | |
| 171 | + needle = q.strip().lower() | |
| 172 | + sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at," | |
| 173 | + " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines" | |
| 174 | + " FROM menus m JOIN restaurants r ON r.uid = m.uid" | |
| 175 | + " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?") | |
| 176 | + args: list = [f"%{needle}%"] | |
| 177 | + if region: | |
| 178 | + sql += " AND r.region=?"; args.append(region) | |
| 179 | + if city: | |
| 180 | + sql += " AND r.city=?"; args.append(city) | |
| 181 | + if cuisine: | |
| 182 | + sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%') | |
| 183 | + | |
| 184 | + groups: dict[tuple, dict] = {} | |
| 185 | + for row in con.execute(sql, args): | |
| 186 | + try: | |
| 187 | + sections = json.loads(row["sections"] or "[]") | |
| 188 | + except ValueError: | |
| 189 | + continue | |
| 190 | + brand = row["chain"] or row["rname"] | |
| 191 | + for sec in sections: | |
| 192 | + for it in sec.get("items") or []: | |
| 193 | + hay = f"{it.get('name', '')} {it.get('description', '')}".lower() | |
| 194 | + if needle not in hay: | |
| 195 | + continue | |
| 196 | + price = it.get("price") | |
| 197 | + if price_max is not None and (price is None or price > price_max): | |
| 198 | + continue | |
| 199 | + key = (brand, it.get("name"), price, row["price_context"]) | |
| 200 | + g = groups.get(key) | |
| 201 | + if g is None: | |
| 202 | + groups[key] = { | |
| 203 | + "item": it.get("name"), | |
| 204 | + "description": it.get("description") or "", | |
| 205 | + "price": price, | |
| 206 | + "currency": it.get("currency", "CAD"), | |
| 207 | + "image": it.get("image"), | |
| 208 | + "tags": it.get("tags") or [], | |
| 209 | + "section": sec.get("name"), | |
| 210 | + "price_context": row["price_context"], | |
| 211 | + "price_source": row["price_source"], | |
| 212 | + "captured_at": row["captured_at"], | |
| 213 | + "brand": brand, | |
| 214 | + "restaurant": row["rname"], | |
| 215 | + "uid": row["uid"], | |
| 216 | + "city": row["city"], | |
| 217 | + "region": row["region"], | |
| 218 | + "locations": 1, | |
| 219 | + } | |
| 220 | + else: | |
| 221 | + g["locations"] += 1 | |
| 222 | + con.close() | |
| 223 | + results = sorted(groups.values(), | |
| 224 | + key=lambda g: (g["price"] is None, g["price"] or 0)) | |
| 225 | + total = len(results) | |
| 226 | + return {"total": total, "count": min(limit, max(0, total - offset)), | |
| 227 | + "dishes": results[offset:offset + limit]} | |
| 228 | + | |
| 229 | + | |
| 230 | +@app.get("/api/restaurants/{uid:path}/prices") | |
| 231 | +def price_history(uid: str): | |
| 232 | + """Historique des prix par item (série temporelle, détection des hausses).""" | |
| 233 | + con = db.connect() | |
| 234 | + rows = [dict(r) for r in con.execute( | |
| 235 | + "SELECT price_context, item_key, ts, price FROM item_price_log" | |
| 236 | + " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))] | |
| 237 | + con.close() | |
| 238 | + return {"uid": uid, "prices": rows} | |
| 239 | + | |
| 240 | + | |
| 241 | +@app.get("/api/restaurants/{uid:path}") | |
| 242 | +def get_restaurant(uid: str): | |
| 243 | + con = db.connect() | |
| 244 | + row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone() | |
| 245 | + if row is None: | |
| 246 | + con.close() | |
| 247 | + raise HTTPException(404, "Restaurant introuvable") | |
| 248 | + d = _row_to_dict(row) | |
| 249 | + # tous les contextes de prix disponibles, meilleur en premier — un menu | |
| 250 | + # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2) | |
| 251 | + menus = [] | |
| 252 | + for m in con.execute( | |
| 253 | + "SELECT price_context, price_source, currency, captured_at," | |
| 254 | + " item_count, sections FROM menus WHERE uid=?", (uid,)): | |
| 255 | + menus.append({ | |
| 256 | + "price_context": m["price_context"], | |
| 257 | + "price_source": m["price_source"], | |
| 258 | + "currency": m["currency"], | |
| 259 | + "captured_at": m["captured_at"], | |
| 260 | + "item_count": m["item_count"], | |
| 261 | + "sections": json.loads(m["sections"] or "[]"), | |
| 262 | + }) | |
| 263 | + menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9)) | |
| 264 | + d["menus"] = menus | |
| 265 | + # items dont le prix a changé récemment (crédibilité, §12.2) | |
| 266 | + d["recent_price_changes"] = [dict(r) for r in con.execute( | |
| 267 | + "SELECT price_context, item_key, ts, price FROM item_price_log" | |
| 268 | + " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)" | |
| 269 | + " GROUP BY item_key HAVING COUNT(*) > 1" | |
| 270 | + " ORDER BY ts DESC LIMIT 20", (uid, uid))] | |
| 271 | + con.close() | |
| 272 | + return d | |
| 273 | + | |
| 274 | + | |
| 275 | +@app.get("/api/facets") | |
| 276 | +def facets(region: str | None = None): | |
| 277 | + """Valeurs distinctes pour construire les filtres du frontend.""" | |
| 278 | + con = db.connect() | |
| 279 | + city_sql = ("SELECT DISTINCT city FROM restaurants" | |
| 280 | + " WHERE active=1 AND dup_of IS NULL AND city<>''") | |
| 281 | + city_args: list = [] | |
| 282 | + if region: | |
| 283 | + city_sql += " AND region=?" | |
| 284 | + city_args.append(region) | |
| 285 | + cuisines: dict[str, int] = {} | |
| 286 | + diets: dict[str, int] = {} | |
| 287 | + for r in con.execute("SELECT cuisines, dietary_options FROM restaurants" | |
| 288 | + " WHERE active=1 AND dup_of IS NULL"): | |
| 289 | + for c in json.loads(r["cuisines"] or "[]"): | |
| 290 | + cuisines[c] = cuisines.get(c, 0) + 1 | |
| 291 | + for dd in json.loads(r["dietary_options"] or "[]"): | |
| 292 | + diets[dd] = diets.get(dd, 0) + 1 | |
| 293 | + out = { | |
| 294 | + "regions": [dict(r) for r in con.execute( | |
| 295 | + "SELECT region, COUNT(*) n FROM restaurants WHERE active=1" | |
| 296 | + " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")], | |
| 297 | + "all_regions": REGIONS, | |
| 298 | + "cities": [r["city"] for r in con.execute( | |
| 299 | + city_sql + " ORDER BY city", city_args)], | |
| 300 | + "cuisines": [{"cuisine": c, "n": n} for c, n in | |
| 301 | + sorted(cuisines.items(), key=lambda kv: -kv[1])], | |
| 302 | + "diets": [{"diet": d, "n": n} for d, n in | |
| 303 | + sorted(diets.items(), key=lambda kv: -kv[1])], | |
| 304 | + "establishment_types": [dict(r) for r in con.execute( | |
| 305 | + "SELECT establishment_type t, COUNT(*) n FROM restaurants" | |
| 306 | + " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''" | |
| 307 | + " GROUP BY establishment_type ORDER BY n DESC")], | |
| 308 | + "chains": [r["chain"] for r in con.execute( | |
| 309 | + "SELECT DISTINCT chain FROM restaurants WHERE active=1" | |
| 310 | + " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")], | |
| 311 | + "sources": [dict(r) for r in con.execute( | |
| 312 | + "SELECT source, COUNT(*) n FROM restaurants WHERE active=1" | |
| 313 | + " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")], | |
| 314 | + } | |
| 315 | + con.close() | |
| 316 | + return out | |
| 317 | + | |
| 318 | + | |
| 319 | +@app.get("/api/sources") | |
| 320 | +def sources(): | |
| 321 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | |
| 322 | + con = db.connect() | |
| 323 | + counts = {r["source"]: r["n"] for r in con.execute( | |
| 324 | + "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")} | |
| 325 | + items = {r["source"]: r["n"] for r in con.execute( | |
| 326 | + "SELECT r.source, SUM(m.item_count) n FROM menus m" | |
| 327 | + " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")} | |
| 328 | + last = {r["source"]: r["ts"] for r in con.execute( | |
| 329 | + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} | |
| 330 | + con.close() | |
| 331 | + for s in registry: | |
| 332 | + s["active_restaurants"] = counts.get(s["id"], 0) | |
| 333 | + s["menu_items"] = items.get(s["id"], 0) | |
| 334 | + s["last_sync"] = last.get(s["id"]) | |
| 335 | + # ne pas exposer les clés d'intégration dans l'API publique | |
| 336 | + if "integrations" in s: | |
| 337 | + s["integrations"] = [{"label": i.get("label"), | |
| 338 | + "status": i.get("status", "actif")} | |
| 339 | + for i in s["integrations"]] | |
| 340 | + return {"sources": registry} | |
| 341 | + | |
| 342 | + | |
| 343 | +@app.get("/api/stats") | |
| 344 | +def stats(): | |
| 345 | + con = db.connect() | |
| 346 | + head = dict(con.execute( | |
| 347 | + """SELECT COUNT(*) restaurants, | |
| 348 | + COUNT(DISTINCT chain) chains, | |
| 349 | + COUNT(DISTINCT region) regions, | |
| 350 | + COUNT(DISTINCT source) sources | |
| 351 | + FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone()) | |
| 352 | + m = dict(con.execute( | |
| 353 | + """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items | |
| 354 | + FROM menus m JOIN restaurants r ON r.uid=m.uid | |
| 355 | + WHERE r.active=1 AND r.dup_of IS NULL""").fetchone()) | |
| 356 | + by_region = [dict(r) for r in con.execute( | |
| 357 | + "SELECT region, COUNT(*) n FROM restaurants WHERE active=1" | |
| 358 | + " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")] | |
| 359 | + by_context = [dict(r) for r in con.execute( | |
| 360 | + "SELECT m.price_context, COUNT(*) n FROM menus m" | |
| 361 | + " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1" | |
| 362 | + " GROUP BY m.price_context")] | |
| 363 | + log = [dict(r) for r in con.execute( | |
| 364 | + "SELECT source, ts, found, added, updated, removed, ok, message" | |
| 365 | + " FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 366 | + con.close() | |
| 367 | + return {**head, **m, "by_region": by_region, "by_context": by_context, | |
| 368 | + "recent_syncs": log} | |
| 369 | + | |
| 370 | + | |
| 371 | +@app.post("/api/sync") | |
| 372 | +def trigger_sync(background: BackgroundTasks, source: str | None = None): | |
| 373 | + """Déclenche une synchronisation (équivalent d'un webhook entrant).""" | |
| 374 | + def _job(): | |
| 375 | + with _sync_lock: | |
| 376 | + ingest.run([source] if source else None) | |
| 377 | + ingest.enrich() | |
| 378 | + background.add_task(_job) | |
| 379 | + return {"status": "démarré", "source": source or "toutes"} | |
| 380 | + | |
| 381 | + | |
| 382 | +# --- Frontend React (build Vite) -------------------------------------------- | |
| 383 | +if FRONTEND_DIST.exists(): | |
| 384 | + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), | |
| 385 | + name="assets") | |
| 386 | + | |
| 387 | + @app.middleware("http") | |
| 388 | + async def _cache_headers(request, call_next): | |
| 389 | + """Bundles hachés immuables ; index.html toujours revalidé.""" | |
| 390 | + resp = await call_next(request) | |
| 391 | + path = request.url.path | |
| 392 | + if path.startswith("/assets/"): | |
| 393 | + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" | |
| 394 | + elif "text/html" in (resp.headers.get("content-type") or ""): | |
| 395 | + resp.headers["Cache-Control"] = "no-cache" | |
| 396 | + return resp | |
| 397 | + | |
| 398 | + _CLIENT_PREFIXES = ("resto/", "sources", "stats") | |
| 399 | + | |
| 400 | + @app.get("/{full_path:path}") | |
| 401 | + def spa(full_path: str): | |
| 402 | + target = FRONTEND_DIST / full_path | |
| 403 | + if full_path and target.is_file(): | |
| 404 | + return FileResponse(target) | |
| 405 | + known = (full_path == "" or full_path in _CLIENT_PREFIXES | |
| 406 | + or any(full_path.startswith(p) for p in _CLIENT_PREFIXES)) | |
| 407 | + return FileResponse(FRONTEND_DIST / "index.html", | |
| 408 | + status_code=200 if known else 404, | |
| 409 | + headers={"Cache-Control": "no-cache"}) | |
added
run.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================== | |
| 3 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | +# File: run.py | |
| 5 | +# Desc: Resto·Ka — point d'entrée : `sync`, `watch`, `serve`, `geocode`, | |
| 6 | +# `dedup`. Même patron que le run.py de Lou·Ka. | |
| 7 | +# ============================================================================== | |
| 8 | +"""Utilisation : | |
| 9 | + python run.py sync [source ...] # synchronise les restos + menus | |
| 10 | + python run.py watch [heures] # synchronise en boucle (défaut 24 h) | |
| 11 | + python run.py serve [port] # démarre l'API + le frontend (défaut 8115) | |
| 12 | + python run.py geocode [n] # géocode les restos sans coordonnées | |
| 13 | + python run.py dedup # déduplication inter-sources | |
| 14 | +""" | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import os | |
| 18 | +import sys | |
| 19 | +from pathlib import Path | |
| 20 | + | |
| 21 | +# Charger .env (SCRAPFLY_KEY, FIRECRAWL_API_KEY…) sans dépendance externe | |
| 22 | +_env = Path(__file__).parent / ".env" | |
| 23 | +if _env.exists(): | |
| 24 | + for line in _env.read_text().splitlines(): | |
| 25 | + line = line.strip() | |
| 26 | + if line and not line.startswith("#") and "=" in line: | |
| 27 | + k, _, v = line.partition("=") | |
| 28 | + os.environ.setdefault(k.strip(), v.strip()) | |
| 29 | + | |
| 30 | + | |
| 31 | +def main() -> None: | |
| 32 | + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve" | |
| 33 | + args = sys.argv[2:] | |
| 34 | + | |
| 35 | + if cmd == "sync": | |
| 36 | + from restoka import ingest | |
| 37 | + ingest.run(args or None) | |
| 38 | + ingest.enrich() | |
| 39 | + elif cmd == "watch": | |
| 40 | + from restoka import ingest | |
| 41 | + hours = float(args[0]) if args else 24.0 | |
| 42 | + ingest.watch(int(hours * 3600)) | |
| 43 | + elif cmd == "serve": | |
| 44 | + import uvicorn | |
| 45 | + port = int(args[0]) if args else 8115 | |
| 46 | + uvicorn.run("restoka.web:app", host="0.0.0.0", port=port) | |
| 47 | + elif cmd == "geocode": | |
| 48 | + from restoka import geocode | |
| 49 | + geocode.run_batch(int(args[0]) if args else 200) | |
| 50 | + elif cmd == "dedup": | |
| 51 | + from restoka import dedup | |
| 52 | + print(dedup.run()) | |
| 53 | + else: | |
| 54 | + print(__doc__) | |
| 55 | + sys.exit(1) | |
| 56 | + | |
| 57 | + | |
| 58 | +if __name__ == "__main__": | |
| 59 | + main() | |
added
tests/conftest.py
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/conftest.py | |
| 4 | +# Desc: Fixtures pytest communes — base SQLite éphémère par test. | |
| 5 | +# ============================================================================== | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +import sys | |
| 9 | +from pathlib import Path | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | + | |
| 13 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| 14 | + | |
| 15 | +from restoka import db # noqa: E402 | |
| 16 | + | |
| 17 | + | |
| 18 | +@pytest.fixture() | |
| 19 | +def con(tmp_path, monkeypatch): | |
| 20 | + """Connexion SQLite isolée (jamais la base réelle data/restoka.db).""" | |
| 21 | + path = tmp_path / "test.db" | |
| 22 | + monkeypatch.setattr(db, "DB_PATH", path) | |
| 23 | + connection = db.connect(path) | |
| 24 | + yield connection | |
| 25 | + connection.close() | |
added
tests/fixtures/ueat/poulet_rouge.json
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Author: Simon-Pierre Boucher <contact@spboucher.ai> — Fixture réelle (extrait) captée le 2026-08-17 sur api.ueat.io/graphql (intégration Poulet Rouge, tronquée à 2 succursales et 2 catégories pour le test de régression du parsing).", | |
| 3 | + "getFranchises": { | |
| 4 | + "restaurant": { "name": "Poulet Rouge", "url": "https://poulet-rouge.ca/", "logo": null }, | |
| 5 | + "franchises": [ | |
| 6 | + { | |
| 7 | + "companyProfileId": 22944, "code": "Beauport", "name": "Poulet Rouge - Beauport", | |
| 8 | + "currency": "CAD", "countryCode": "CA", | |
| 9 | + "address": { "civicNumber": "898", "streetName": "Boulevard Raymond", "postalCode": "G1C 5P4", "city": "Québec", "province": "Québec" } | |
| 10 | + }, | |
| 11 | + { | |
| 12 | + "companyProfileId": 21131, "code": "BankSt", "name": "Poulet Rouge - Bank St", | |
| 13 | + "currency": "CAD", "countryCode": "CA", | |
| 14 | + "address": { "civicNumber": "900", "streetName": "Bank Street", "postalCode": "K1S 3W6", "city": "Ottawa", "province": "Ontario" } | |
| 15 | + }, | |
| 16 | + { | |
| 17 | + "companyProfileId": 10636, "code": "boulBourque", "name": "Poulet Rouge - Boul. Bourque", | |
| 18 | + "currency": "CAD", "countryCode": "CA", | |
| 19 | + "address": { "civicNumber": "4796", "streetName": "Boulevard Bourque", "postalCode": "J1N 2A7", "city": "Sherbrooke", "province": "Québec" } | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + }, | |
| 23 | + "menuCategories": { | |
| 24 | + "menu": { | |
| 25 | + "categories": [ | |
| 26 | + { "id": 65413, "name": "Rouge Bol", "description": "", "numberOfMenuItems": 3, "hasSubCategory": false }, | |
| 27 | + { "id": 65417, "name": "À-Côtés & Desserts", "description": "", "numberOfMenuItems": 5, "hasSubCategory": false } | |
| 28 | + ] | |
| 29 | + } | |
| 30 | + }, | |
| 31 | + "items": { | |
| 32 | + "65413": [ | |
| 33 | + { | |
| 34 | + "id": 1428587, "name": "Créez Votre Rouge Bol (1 Saveur)", | |
| 35 | + "description": "Votre choix de saveur de poulet grillé servi sur une base de votre choix (36g Protéine)", | |
| 36 | + "subtitle": "", "regularPrice": 14.29, "unitPrice": 14.29, | |
| 37 | + "hasQuestions": true, "isOutOfStock": false, "isTemporaryDisabled": false, | |
| 38 | + "labels": [], "variations": [] | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "id": 1428596, "name": "Rouge Bol (Végé)", | |
| 42 | + "description": "Base de votre choix et garnissez le bol végé à votre goût", | |
| 43 | + "subtitle": "", "regularPrice": 11.29, "unitPrice": 11.29, | |
| 44 | + "hasQuestions": false, "isOutOfStock": false, "isTemporaryDisabled": false, | |
| 45 | + "labels": [{ "name": "Végétarien" }], "variations": [] | |
| 46 | + } | |
| 47 | + ], | |
| 48 | + "65417": [ | |
| 49 | + { | |
| 50 | + "id": 1428646, "name": "Patates Frites", "description": "", "subtitle": "", | |
| 51 | + "regularPrice": 4.99, "unitPrice": 4.99, | |
| 52 | + "hasQuestions": false, "isOutOfStock": false, "isTemporaryDisabled": false, | |
| 53 | + "labels": [], | |
| 54 | + "variations": [ | |
| 55 | + { "id": 1, "name": "Régulière", "description": "", "unitPrice": 4.99, "regularPrice": 4.99, "variations": [] }, | |
| 56 | + { "id": 2, "name": "Grande", "description": "", "unitPrice": 6.99, "regularPrice": 6.99, "variations": [] } | |
| 57 | + ] | |
| 58 | + } | |
| 59 | + ] | |
| 60 | + }, | |
| 61 | + "questions": { | |
| 62 | + "1428587": { | |
| 63 | + "item": { | |
| 64 | + "id": 1428587, "name": "Créez Votre Rouge Bol (1 Saveur)", | |
| 65 | + "question": { | |
| 66 | + "questionText": "Choisissez votre base", "selectionType": "Single", | |
| 67 | + "minimum": 1, "maximum": 1, | |
| 68 | + "choices": [ | |
| 69 | + { "name": "Riz basmati", "unitPrice": 0, "isNotAdded": false }, | |
| 70 | + { "name": "Salade César", "unitPrice": 1.5, "isNotAdded": false } | |
| 71 | + ] | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | +} | |
added
tests/test_normalize.py
+107 −0
@@ -0,0 +1,107 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_normalize.py | |
| 4 | +# Desc: Normalisation : parsing des prix (l'actif le plus critique), | |
| 5 | +# cuisines, type d'établissement, fourchette de prix, régions. | |
| 6 | +# ============================================================================== | |
| 7 | +from restoka.normalize import ( | |
| 8 | + classify_cuisines, | |
| 9 | + classify_establishment, | |
| 10 | + clean_name, | |
| 11 | + parse_price, | |
| 12 | + price_range_from_menu, | |
| 13 | +) | |
| 14 | +from restoka.regions import attach_region | |
| 15 | + | |
| 16 | + | |
| 17 | +class TestParsePrice: | |
| 18 | + def test_nombre(self): | |
| 19 | + assert parse_price(14.29) == 14.29 | |
| 20 | + | |
| 21 | + def test_texte_virgule(self): | |
| 22 | + assert parse_price("16,50 $") == 16.50 | |
| 23 | + | |
| 24 | + def test_texte_point(self): | |
| 25 | + assert parse_price("à partir de 12.75$") == 12.75 | |
| 26 | + | |
| 27 | + def test_zero_et_negatif(self): | |
| 28 | + assert parse_price(0) is None | |
| 29 | + assert parse_price(-3) is None | |
| 30 | + | |
| 31 | + def test_vide(self): | |
| 32 | + assert parse_price(None) is None | |
| 33 | + assert parse_price("gratuit") is None | |
| 34 | + | |
| 35 | + | |
| 36 | +class TestCuisines: | |
| 37 | + def test_pizza(self): | |
| 38 | + assert "pizza" in classify_cuisines("Pizza Mia") | |
| 39 | + | |
| 40 | + def test_sushi(self): | |
| 41 | + assert "sushi-japonais" in classify_cuisines("Ryu Sushi") | |
| 42 | + | |
| 43 | + def test_menu_texte(self): | |
| 44 | + cuisines = classify_cuisines("Chez Georges", "Poutine Tourtière Smoked meat") | |
| 45 | + assert "quebecois" in cuisines | |
| 46 | + | |
| 47 | + def test_inconnu(self): | |
| 48 | + assert classify_cuisines("Xyzzy") == ["autre"] | |
| 49 | + | |
| 50 | + | |
| 51 | +class TestEstablishment: | |
| 52 | + def test_cafe(self): | |
| 53 | + assert classify_establishment("Café Station W", []) == "cafe" | |
| 54 | + | |
| 55 | + def test_boba(self): | |
| 56 | + assert classify_establishment("Panda Boba-T", []) == "cafe" | |
| 57 | + | |
| 58 | + def test_defaut(self): | |
| 59 | + assert classify_establishment("Chez Georges", ["italien"]) == "restaurant" | |
| 60 | + | |
| 61 | + | |
| 62 | +class TestPriceRange: | |
| 63 | + def _menu(self, prices): | |
| 64 | + return {"sections": [{"name": "Plats", | |
| 65 | + "items": [{"name": f"p{i}", "price": p} | |
| 66 | + for i, p in enumerate(prices)]}]} | |
| 67 | + | |
| 68 | + def test_dollar(self): | |
| 69 | + assert price_range_from_menu(self._menu([8, 10, 12])) == "$" | |
| 70 | + | |
| 71 | + def test_deux_dollars(self): | |
| 72 | + assert price_range_from_menu(self._menu([16, 22, 28])) == "$$" | |
| 73 | + | |
| 74 | + def test_quatre_dollars(self): | |
| 75 | + assert price_range_from_menu(self._menu([70, 95, 120])) == "$$$$" | |
| 76 | + | |
| 77 | + def test_vide(self): | |
| 78 | + assert price_range_from_menu(None) == "" | |
| 79 | + | |
| 80 | + | |
| 81 | +class TestRegions: | |
| 82 | + def test_villes(self): | |
| 83 | + assert attach_region("Montréal") == "Montréal" | |
| 84 | + assert attach_region("Québec") == "Capitale-Nationale" | |
| 85 | + assert attach_region("Lévis") == "Chaudière-Appalaches" | |
| 86 | + assert attach_region("Sherbrooke") == "Estrie" | |
| 87 | + assert attach_region("Saguenay") == "Saguenay–Lac-Saint-Jean" | |
| 88 | + assert attach_region("Gaspé") == "Gaspésie–Îles-de-la-Madeleine" | |
| 89 | + assert attach_region("Laval") == "Laval" | |
| 90 | + assert attach_region("Brossard") == "Montérégie" | |
| 91 | + | |
| 92 | + def test_accents_et_casse(self): | |
| 93 | + assert attach_region("MONTREAL") == "Montréal" | |
| 94 | + assert attach_region("Trois-Rivieres") == "Mauricie" | |
| 95 | + | |
| 96 | + def test_repli_postal(self): | |
| 97 | + assert attach_region("Village-inconnu", "G1R 4P5") == "Capitale-Nationale" | |
| 98 | + assert attach_region("Village-inconnu", "H7E 0H3") == "Laval" | |
| 99 | + assert attach_region("Village-inconnu", "J1N 2A7") == "Estrie" | |
| 100 | + | |
| 101 | + def test_jamais_inventee(self): | |
| 102 | + assert attach_region("Ottawa") == "" | |
| 103 | + | |
| 104 | + | |
| 105 | +def test_clean_name(): | |
| 106 | + assert clean_name(" CRÈME BRÛLÉE ") == "Crème Brûlée" | |
| 107 | + assert clean_name("Pizza <b>Mia</b>") == "Pizza Mia" | |
added
tests/test_schema_db.py
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_schema_db.py | |
| 4 | +# Desc: Validation du schéma (menu sans price_context REJETÉ), cycle de vie | |
| 5 | +# en base (upsert, délai de grâce, historique des prix, garde-fou de | |
| 6 | +# chute d'items) et déduplication inter-sources. | |
| 7 | +# ============================================================================== | |
| 8 | +import json | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | + | |
| 12 | +from restoka import db, dedup | |
| 13 | +from restoka.schema import MenuValidationError, Restaurant | |
| 14 | + | |
| 15 | + | |
| 16 | +def _resto(source="ueat", eid="g:1", name="Chez Test", menu=True, price=12.0, | |
| 17 | + **kw) -> Restaurant: | |
| 18 | + m = None | |
| 19 | + if menu: | |
| 20 | + m = { | |
| 21 | + "currency": "CAD", | |
| 22 | + "price_context": "takeout", | |
| 23 | + "price_source": source, | |
| 24 | + "captured_at": "2026-08-17T12:00:00Z", | |
| 25 | + "sections": [{"name": "Plats", "items": [ | |
| 26 | + {"name": "Poutine", "price": price}, | |
| 27 | + {"name": "Burger", "price": 14.5}, | |
| 28 | + ]}], | |
| 29 | + } | |
| 30 | + defaults = dict(source=source, external_id=eid, name=name, | |
| 31 | + address="123 Rue Principale", city="Québec", | |
| 32 | + postal_code="G1R 4P5", menu=m) | |
| 33 | + defaults.update(kw) | |
| 34 | + return Restaurant(**defaults) | |
| 35 | + | |
| 36 | + | |
| 37 | +class TestValidation: | |
| 38 | + def test_menu_sans_contexte_rejete(self): | |
| 39 | + r = _resto() | |
| 40 | + r.menu.pop("price_context") | |
| 41 | + with pytest.raises(MenuValidationError): | |
| 42 | + r.finalize() | |
| 43 | + | |
| 44 | + def test_menu_sans_source_rejete(self): | |
| 45 | + r = _resto() | |
| 46 | + r.menu.pop("price_source") | |
| 47 | + with pytest.raises(MenuValidationError): | |
| 48 | + r.finalize() | |
| 49 | + | |
| 50 | + def test_contexte_invalide_rejete(self): | |
| 51 | + r = _resto() | |
| 52 | + r.menu["price_context"] = "menu" | |
| 53 | + with pytest.raises(MenuValidationError): | |
| 54 | + r.finalize() | |
| 55 | + | |
| 56 | + def test_prix_implausible_rejete(self): | |
| 57 | + with pytest.raises(MenuValidationError): | |
| 58 | + _resto(price=99999).finalize() | |
| 59 | + | |
| 60 | + def test_finalize_enrichit(self): | |
| 61 | + r = _resto().finalize() | |
| 62 | + assert r.region == "Capitale-Nationale" | |
| 63 | + assert r.cuisines # jamais vide | |
| 64 | + assert r.price_range == "$" | |
| 65 | + assert r.uid == "ueat:g:1" | |
| 66 | + | |
| 67 | + def test_coordonnees_hors_province_rejetees(self): | |
| 68 | + r = _resto(lat=48.85, lng=2.35).finalize() # Paris -> aberrant | |
| 69 | + assert r.lat is None and r.lng is None | |
| 70 | + | |
| 71 | + | |
| 72 | +class TestSync: | |
| 73 | + def test_ajout_puis_stable(self, con): | |
| 74 | + stats = db.sync_source(con, "ueat", [_resto().finalize()]) | |
| 75 | + assert stats["added"] == 1 | |
| 76 | + stats = db.sync_source(con, "ueat", [_resto().finalize()]) | |
| 77 | + assert stats["added"] == 0 and stats["updated"] == 0 | |
| 78 | + | |
| 79 | + def test_changement_de_prix_historise(self, con): | |
| 80 | + db.sync_source(con, "ueat", [_resto(price=12.0).finalize()]) | |
| 81 | + db.sync_source(con, "ueat", [_resto(price=13.5).finalize()]) | |
| 82 | + hist = con.execute( | |
| 83 | + "SELECT price FROM item_price_log WHERE item_key LIKE '%poutine%'" | |
| 84 | + " ORDER BY ts").fetchall() | |
| 85 | + assert [h["price"] for h in hist] == [12.0, 13.5] | |
| 86 | + | |
| 87 | + def test_delai_de_grace_puis_fermeture(self, con): | |
| 88 | + db.sync_source(con, "ueat", [_resto().finalize()]) | |
| 89 | + db.sync_source(con, "ueat", []) # raté 1 : encore actif | |
| 90 | + row = con.execute("SELECT active FROM restaurants").fetchone() | |
| 91 | + assert row["active"] == 1 | |
| 92 | + db.sync_source(con, "ueat", []) # raté 2 : fermé temporairement | |
| 93 | + row = con.execute("SELECT active, status FROM restaurants").fetchone() | |
| 94 | + assert row["active"] == 0 and row["status"] == "temporarily_closed" | |
| 95 | + | |
| 96 | + def test_chute_items_conserve_ancien_menu(self, con): | |
| 97 | + gros = _resto() | |
| 98 | + gros.menu["sections"] = [{"name": "Plats", "items": [ | |
| 99 | + {"name": f"Plat {i}", "price": 10.0 + i} for i in range(12)]}] | |
| 100 | + db.sync_source(con, "ueat", [gros.finalize()]) | |
| 101 | + petit = _resto(price=9.0) | |
| 102 | + petit.menu["sections"] = [{"name": "Plats", | |
| 103 | + "items": [{"name": "Seul", "price": 9.0}]}] | |
| 104 | + stats = db.sync_source(con, "ueat", [petit.finalize()]) | |
| 105 | + assert stats.get("menu_alerts") == 1 | |
| 106 | + row = con.execute("SELECT item_count FROM menus").fetchone() | |
| 107 | + assert row["item_count"] == 12 # l'ancien menu est conservé | |
| 108 | + | |
| 109 | + def test_contextes_jamais_ecrases(self, con): | |
| 110 | + salle = _resto() | |
| 111 | + salle.menu["price_context"] = "dine-in" | |
| 112 | + db.sync_source(con, "site-resto" if False else "ueat", [salle.finalize()]) | |
| 113 | + livraison = _resto() | |
| 114 | + livraison.menu["price_context"] = "delivery" | |
| 115 | + livraison.menu["sections"][0]["items"][0]["price"] = 16.0 # majoré | |
| 116 | + db.sync_source(con, "ueat", [livraison.finalize()]) | |
| 117 | + rows = con.execute( | |
| 118 | + "SELECT price_context FROM menus ORDER BY price_context").fetchall() | |
| 119 | + assert [r["price_context"] for r in rows] == ["delivery", "dine-in"] | |
| 120 | + | |
| 121 | + | |
| 122 | +class TestDedup: | |
| 123 | + def test_meme_resto_deux_sources(self, con): | |
| 124 | + a = _resto(source="ueat", eid="1", lat=46.81, lng=-71.21).finalize() | |
| 125 | + b = _resto(source="ubereats", eid="x9", lat=46.8101, lng=-71.2101, | |
| 126 | + menu=False).finalize() | |
| 127 | + db.sync_source(con, "ueat", [a]) | |
| 128 | + db.sync_source(con, "ubereats", [b]) | |
| 129 | + stats = dedup.run() | |
| 130 | + assert stats["merged"] == 1 | |
| 131 | + canon = con.execute( | |
| 132 | + "SELECT uid, dup_sources FROM restaurants WHERE dup_of IS NULL" | |
| 133 | + " AND dup_sources IS NOT NULL").fetchone() | |
| 134 | + # la source de première partie (ueat) gagne la fiche canonique | |
| 135 | + assert canon["uid"] == "ueat:1" | |
| 136 | + assert json.loads(canon["dup_sources"]) == ["ubereats"] | |
| 137 | + | |
| 138 | + def test_succursales_jamais_fusionnees(self, con): | |
| 139 | + s1 = _resto(eid="1", name="Poulet Rouge — Beauport", | |
| 140 | + address="898 Boulevard Raymond", postal_code="G1C 5P4").finalize() | |
| 141 | + s2 = _resto(eid="2", name="Poulet Rouge — Sainte-Foy", | |
| 142 | + address="2600 Boulevard Laurier", postal_code="G1V 4T3").finalize() | |
| 143 | + db.sync_source(con, "ueat", [s1, s2]) | |
| 144 | + stats = dedup.run() | |
| 145 | + assert stats["merged"] == 0 | |
added
tests/test_ueat.py
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_ueat.py | |
| 4 | +# Desc: Régression du connecteur UEAT — rejeu HORS LIGNE d'une fixture réelle | |
| 5 | +# (extrait Poulet Rouge capté sur api.ueat.io). Vérifie : une fiche par | |
| 6 | +# succursale QC, filtrage hors-province, prix exacts, options | |
| 7 | +# (variations + questions), contexte takeout, tags diète. | |
| 8 | +# ============================================================================== | |
| 9 | +import json | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +import pytest | |
| 13 | + | |
| 14 | +from restoka.connectors.ueat import UeatConnector | |
| 15 | + | |
| 16 | +FIXTURE = json.loads( | |
| 17 | + (Path(__file__).parent / "fixtures" / "ueat" / "poulet_rouge.json") | |
| 18 | + .read_text(encoding="utf-8")) | |
| 19 | + | |
| 20 | + | |
| 21 | +class OfflineUeat(UeatConnector): | |
| 22 | + """Connecteur rejoué depuis la fixture (aucun appel réseau).""" | |
| 23 | + use_detail_cache = False | |
| 24 | + | |
| 25 | + def _integrations(self): | |
| 26 | + return [{"key": "fixture-guid", "label": "Poulet Rouge (fixture)"}] | |
| 27 | + | |
| 28 | + def _gql(self, api_key, query, variables=None, operation=None): | |
| 29 | + if operation == "getFranchises": | |
| 30 | + return FIXTURE["getFranchises"] | |
| 31 | + if operation == "menuCategories" or "menuCategories" in query: | |
| 32 | + return FIXTURE["menuCategories"] | |
| 33 | + if operation == "items": | |
| 34 | + return {"items": FIXTURE["items"][str(variables["categoryId"])]} | |
| 35 | + if operation == "questions": | |
| 36 | + return FIXTURE["questions"][str(variables["menuItemId"])] | |
| 37 | + raise AssertionError(f"requête inattendue : {operation}") | |
| 38 | + | |
| 39 | + | |
| 40 | +@pytest.fixture(scope="module") | |
| 41 | +def restos(): | |
| 42 | + return [r.finalize() for r in OfflineUeat().fetch()] | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_une_fiche_par_succursale_quebec(restos): | |
| 46 | + # 3 succursales dans la fixture, mais Ottawa (Ontario) est hors périmètre | |
| 47 | + assert len(restos) == 2 | |
| 48 | + assert {r.city for r in restos} == {"Québec", "Sherbrooke"} | |
| 49 | + assert all(r.chain == "Poulet Rouge" for r in restos) | |
| 50 | + assert len({r.uid for r in restos}) == 2 | |
| 51 | + | |
| 52 | + | |
| 53 | +def test_regions_rattachees(restos): | |
| 54 | + regions = {r.city: r.region for r in restos} | |
| 55 | + assert regions["Québec"] == "Capitale-Nationale" | |
| 56 | + assert regions["Sherbrooke"] == "Estrie" | |
| 57 | + | |
| 58 | + | |
| 59 | +def test_prix_exacts(restos): | |
| 60 | + """Les prix sont l'actif le plus critique : ils doivent être EXACTS.""" | |
| 61 | + menu = restos[0].menu | |
| 62 | + assert menu["price_context"] == "takeout" | |
| 63 | + assert menu["price_source"] == "ueat" | |
| 64 | + prix = {it["name"]: it["price"] | |
| 65 | + for sec in menu["sections"] for it in sec["items"]} | |
| 66 | + assert prix["Créez Votre Rouge Bol (1 Saveur)"] == 14.29 | |
| 67 | + assert prix["Rouge Bol (Végé)"] == 11.29 | |
| 68 | + assert prix["Patates Frites"] == 4.99 | |
| 69 | + | |
| 70 | + | |
| 71 | +def test_sections(restos): | |
| 72 | + noms = [s["name"] for s in restos[0].menu["sections"]] | |
| 73 | + assert noms == ["Rouge Bol", "À-Côtés & Desserts"] | |
| 74 | + | |
| 75 | + | |
| 76 | +def test_options_variations(restos): | |
| 77 | + frites = next(it for sec in restos[0].menu["sections"] | |
| 78 | + for it in sec["items"] if it["name"] == "Patates Frites") | |
| 79 | + fmt = next(op for op in frites["options"] if op["group"] == "Format") | |
| 80 | + assert fmt["required"] is True | |
| 81 | + deltas = {c["name"]: c["price_delta"] for c in fmt["choices"]} | |
| 82 | + assert deltas["Régulière"] == 0.0 | |
| 83 | + assert deltas["Grande"] == 2.0 # 6,99 − 4,99 | |
| 84 | + | |
| 85 | + | |
| 86 | +def test_options_questions(restos): | |
| 87 | + bol = next(it for sec in restos[0].menu["sections"] | |
| 88 | + for it in sec["items"] | |
| 89 | + if it["name"] == "Créez Votre Rouge Bol (1 Saveur)") | |
| 90 | + base = next(op for op in bol["options"] | |
| 91 | + if op["group"] == "Choisissez votre base") | |
| 92 | + assert base["required"] is True | |
| 93 | + deltas = {c["name"]: c["price_delta"] for c in base["choices"]} | |
| 94 | + assert deltas["Riz basmati"] == 0.0 | |
| 95 | + assert deltas["Salade César"] == 1.5 | |
| 96 | + | |
| 97 | + | |
| 98 | +def test_tags_diete(restos): | |
| 99 | + vege = next(it for sec in restos[0].menu["sections"] | |
| 100 | + for it in sec["items"] if it["name"] == "Rouge Bol (Végé)") | |
| 101 | + assert "vegetarien" in vege["tags"] | |
| 102 | + assert "vegetarien" in restos[0].dietary_options | |
| 103 | ||