WIP laptop non committé, sauvegardé lors de la réorganisation du 2026-10-02
18 changed files +1,641 −6
modified
data/sources.json
+13 −0
@@ -59,6 +59,19 @@ | |||||
| 59 | "cadence": "mensuel", | 59 | "cadence": "mensuel", | ||
| 60 | "status": "actif" | 60 | "status": "actif" | ||
| 61 | }, | 61 | }, | ||
| 62 | + { | ||||
| 63 | + "id": "site-resto", | ||||
| 64 | + "name": "Sites web des restos (menus maison)", | ||||
| 65 | + "url": "https://www.resto-ka.com/sources", | ||||
| 66 | + "platform": "sites-web", | ||||
| 67 | + "connector": "site-resto", | ||||
| 68 | + "tier": 3, | ||||
| 69 | + "price_context": "dine-in", | ||||
| 70 | + "extraction": "Crawl des sites web publiés par les restos découverts via OSM : détection de plateforme (UEAT → GUID consigné dans ueat-discovered.json), sinon page menu HTML (requests, Firecrawl en secours pour le JS) ou PDF → structuration par Claude Haiku 4.5 (sorties structurées JSON) → validation stricte des prix (≥5 plats avec prix, ≥40 % des items, 0,50–500 $). Budget de 250 sites/passage, re-crawl mensuel.", | ||||
| 71 | + "acces_legal": "Sites publics de première partie (le menu affiché par le resto lui-même — la voie la plus légitime, CLAUDE.md §15). UA identifiable RestoKaBot, 1 req/s, aucun contournement.", | ||||
| 72 | + "cadence": "hebdomadaire (batch incrémental), re-crawl mensuel", | ||||
| 73 | + "status": "actif" | ||||
| 74 | + }, | ||||
| 62 | { | 75 | { | ||
| 63 | "id": "sthubert", | 76 | "id": "sthubert", | ||
| 64 | "name": "St-Hubert (commande en ligne)", | 77 | "name": "St-Hubert (commande en ligne)", | ||
added
data/ueat-discovered.json
+320 −0
@@ -0,0 +1,320 @@ | |||||
| 1 | +{ | ||||
| 2 | + "integrations": [ | ||||
| 3 | + { | ||||
| 4 | + "key": "e4c3af4f-c5ff-4fe7-a8e9-a8750ae532a8", | ||||
| 5 | + "label": "Morena Epicerie Traiteur (découvert via morena-food.com)" | ||||
| 6 | + }, | ||||
| 7 | + { | ||||
| 8 | + "key": "66ee81af-c10a-49b6-9faa-3e3b485a548e", | ||||
| 9 | + "label": "India Rosa (découvert via indiarosa.com)" | ||||
| 10 | + }, | ||||
| 11 | + { | ||||
| 12 | + "key": "b46660d2-389f-42d5-b161-d31b48ad80bf", | ||||
| 13 | + "label": "Restaurant Au Vieux Duluth (découvert via auvieuxduluth.com)" | ||||
| 14 | + }, | ||||
| 15 | + { | ||||
| 16 | + "key": "d6843f2a-5809-4d43-be8e-392e65a4a91c", | ||||
| 17 | + "label": "Aux Vivres (découvert via auxvivres.com)" | ||||
| 18 | + }, | ||||
| 19 | + { | ||||
| 20 | + "key": "c164671b-95fe-4f6f-8944-67eac68ad8c0", | ||||
| 21 | + "label": "Casa Grecque (découvert via casagrecque.ca)" | ||||
| 22 | + }, | ||||
| 23 | + { | ||||
| 24 | + "key": "36be39f4-3bfb-4d76-ab9b-bbb1985883ea", | ||||
| 25 | + "label": "Pizza Welldun (découvert via welldunpizza.ca)" | ||||
| 26 | + }, | ||||
| 27 | + { | ||||
| 28 | + "key": "e39d77ff-8183-4d75-9211-ed356dc857ea", | ||||
| 29 | + "label": "Bottega (découvert via bottega.ca)" | ||||
| 30 | + }, | ||||
| 31 | + { | ||||
| 32 | + "key": "55a3f719-d59a-4487-a0e2-b7aad293b247", | ||||
| 33 | + "label": "Sandhu (découvert via restaurantsandhu.ca)" | ||||
| 34 | + }, | ||||
| 35 | + { | ||||
| 36 | + "key": "627749b8-894b-4f13-a68b-c90b2ffd1702", | ||||
| 37 | + "label": "Rockaberry (découvert via rockaberry.ca)" | ||||
| 38 | + }, | ||||
| 39 | + { | ||||
| 40 | + "key": "4442c3e2-66b3-41ac-8cbf-a00a2a420320", | ||||
| 41 | + "label": "Maiko Sushi (découvert via maiko-sushi.com)" | ||||
| 42 | + }, | ||||
| 43 | + { | ||||
| 44 | + "key": "fc3bf368-dfcc-4a0b-8eb7-93aaeef67bcf", | ||||
| 45 | + "label": "Juliette & Chocolat (découvert via julietteetchocolat.com)" | ||||
| 46 | + }, | ||||
| 47 | + { | ||||
| 48 | + "key": "52f4a11d-75c9-4710-8f9c-15997aabf7bd", | ||||
| 49 | + "label": "Poivre Noir (découvert via poivrenoir.com)" | ||||
| 50 | + }, | ||||
| 51 | + { | ||||
| 52 | + "key": "1d596d77-a96f-46da-8192-785cd3766c58", | ||||
| 53 | + "label": "Pizzeria 67 (découvert via pizzeria67.com)" | ||||
| 54 | + }, | ||||
| 55 | + { | ||||
| 56 | + "key": "b0cbbf13-6f88-46a3-ad14-0c3bc85069f7", | ||||
| 57 | + "label": "Bistro-Bar le Brise-Bise (découvert via brisebise.ca)" | ||||
| 58 | + }, | ||||
| 59 | + { | ||||
| 60 | + "key": "77df4865-59bb-435e-818c-e743a4ca2751", | ||||
| 61 | + "label": "Pizza Fina (découvert via pizzafina.ca)" | ||||
| 62 | + }, | ||||
| 63 | + { | ||||
| 64 | + "key": "94652e5f-d4cc-47ac-81a3-ad9b1e31b826", | ||||
| 65 | + "label": "Sesame (découvert via sesamerestaurant.ca)" | ||||
| 66 | + }, | ||||
| 67 | + { | ||||
| 68 | + "key": "45c52d27-bf8c-4995-9c4e-a0a2754101fb", | ||||
| 69 | + "label": "Pizzeria n°900 (découvert via no900.com)" | ||||
| 70 | + }, | ||||
| 71 | + { | ||||
| 72 | + "key": "5f664929-18f2-4b2f-955d-b9f542312180", | ||||
| 73 | + "label": "Venice (découvert via venicemtl.com)" | ||||
| 74 | + }, | ||||
| 75 | + { | ||||
| 76 | + "key": "d25f3505-1acc-4422-a5c0-b83940b3f576", | ||||
| 77 | + "label": "Onigiri Shop (découvert via onigirishop.com)" | ||||
| 78 | + }, | ||||
| 79 | + { | ||||
| 80 | + "key": "ac77a7cf-e62b-40be-964b-497666c07c06", | ||||
| 81 | + "label": "Escondite (découvert via escondite.ca)" | ||||
| 82 | + }, | ||||
| 83 | + { | ||||
| 84 | + "key": "b7282ce3-95cc-43ff-80b5-0cca8a7e4778", | ||||
| 85 | + "label": "Allô! Mon Coco (découvert via allomoncoco.com)" | ||||
| 86 | + }, | ||||
| 87 | + { | ||||
| 88 | + "key": "e4de92c1-12d0-49db-bb59-7baf0202faf8", | ||||
| 89 | + "label": "Nora Gray (découvert via noragray.com)" | ||||
| 90 | + }, | ||||
| 91 | + { | ||||
| 92 | + "key": "5f4f560b-830d-4af0-892d-b15be0ed653c", | ||||
| 93 | + "label": "Lucille’s Oyster Dive (découvert via lucillesoyster.com)" | ||||
| 94 | + }, | ||||
| 95 | + { | ||||
| 96 | + "key": "12fe0fc4-f986-40e3-abbc-670c01f37247", | ||||
| 97 | + "label": "Jun I (découvert via juni.ca)" | ||||
| 98 | + }, | ||||
| 99 | + { | ||||
| 100 | + "key": "484faa4f-19f5-435b-9428-d5c8fd439f75", | ||||
| 101 | + "label": "La Boîte À Huîtres (découvert via laboiteauxhuitres.com)" | ||||
| 102 | + }, | ||||
| 103 | + { | ||||
| 104 | + "key": "791c777b-4e38-4fa6-850d-4e9f322a90d8", | ||||
| 105 | + "label": "Sushi Sakura (découvert via sushisakura.ca)" | ||||
| 106 | + }, | ||||
| 107 | + { | ||||
| 108 | + "key": "23c2c440-9ad3-4d3f-b198-3e2d565f7999", | ||||
| 109 | + "label": "Boîte Geisha (découvert via boitegeisha.com)" | ||||
| 110 | + }, | ||||
| 111 | + { | ||||
| 112 | + "key": "d94f44b9-67ac-42f1-99e5-a56040210f87", | ||||
| 113 | + "label": "Monza (découvert via restaurantmonza.com)" | ||||
| 114 | + }, | ||||
| 115 | + { | ||||
| 116 | + "key": "c147a584-441d-4db2-9ab5-384c250ab925", | ||||
| 117 | + "label": "Barranco (découvert via barrancorestaurant.com)" | ||||
| 118 | + }, | ||||
| 119 | + { | ||||
| 120 | + "key": "403f3002-0bae-4f8c-9ebb-391a2e69caeb", | ||||
| 121 | + "label": "Seau de Crabe (découvert via seaudecrabe.ca)" | ||||
| 122 | + }, | ||||
| 123 | + { | ||||
| 124 | + "key": "2881e6e6-58fd-4b26-b8b9-8cfc4569fa14", | ||||
| 125 | + "label": "Shūshū (découvert via shushumtl.com)" | ||||
| 126 | + }, | ||||
| 127 | + { | ||||
| 128 | + "key": "073dc1c3-04eb-49f7-8c06-7c71c572ddf7", | ||||
| 129 | + "label": "L'Œil du dragon (découvert via oeildudragon.com)" | ||||
| 130 | + }, | ||||
| 131 | + { | ||||
| 132 | + "key": "3ad538c1-56c1-43e4-a308-fb0ef963cf07", | ||||
| 133 | + "label": "Ristorante Giorgio (découvert via giorgio.ca)" | ||||
| 134 | + }, | ||||
| 135 | + { | ||||
| 136 | + "key": "89422724-84db-4302-ac12-ebf532434604", | ||||
| 137 | + "label": "Pizzeria Zac (découvert via chezzacpizza.com)" | ||||
| 138 | + }, | ||||
| 139 | + { | ||||
| 140 | + "key": "ab96cf7a-cf9c-4178-8d52-9b3729709aec", | ||||
| 141 | + "label": "La galette libanaise (découvert via lagalettelibanaise.com)" | ||||
| 142 | + }, | ||||
| 143 | + { | ||||
| 144 | + "key": "4d9578ba-f905-472e-90fa-e39e184b0597", | ||||
| 145 | + "label": "Stratos Pizzeria (découvert via stratos-pizzeria.com)" | ||||
| 146 | + }, | ||||
| 147 | + { | ||||
| 148 | + "key": "51236cf5-13f5-4fb4-a08c-d30b31519709", | ||||
| 149 | + "label": "Umai Soo She (découvert via umaisooshe.ca)" | ||||
| 150 | + }, | ||||
| 151 | + { | ||||
| 152 | + "key": "388d78df-d9c9-4aaa-a974-7b8d696312eb", | ||||
| 153 | + "label": "Torii Izakaya (découvert via toriiizakaya.ca)" | ||||
| 154 | + }, | ||||
| 155 | + { | ||||
| 156 | + "key": "7ec72df5-e520-4335-9935-e2be36db5761", | ||||
| 157 | + "label": "Küto (découvert via kuto.ca)" | ||||
| 158 | + }, | ||||
| 159 | + { | ||||
| 160 | + "key": "d42e5dd2-815c-4803-930d-ae0ee95b1e4b", | ||||
| 161 | + "label": "Bol de Vie (découvert via boldevie.ca)" | ||||
| 162 | + }, | ||||
| 163 | + { | ||||
| 164 | + "key": "841868c1-02a9-4a1e-bb9d-c01d33587ee3", | ||||
| 165 | + "label": "Piri Piri Rôtisserie Portugaise (découvert via piri-piri.ca)" | ||||
| 166 | + }, | ||||
| 167 | + { | ||||
| 168 | + "key": "5be5aa7d-f64c-4aaa-82a9-d6f764696de8", | ||||
| 169 | + "label": "Sushi Time (découvert via sushitime-mtl.com)" | ||||
| 170 | + }, | ||||
| 171 | + { | ||||
| 172 | + "key": "1d25d02b-8346-4aee-8170-9bbe489e4aea", | ||||
| 173 | + "label": "Atlas Pizza (découvert via atlaspizza.ca)" | ||||
| 174 | + }, | ||||
| 175 | + { | ||||
| 176 | + "key": "2331b263-c983-4482-9826-37c6ded2a447", | ||||
| 177 | + "label": "Ubi Sushi (découvert via ubisushi.ca)" | ||||
| 178 | + }, | ||||
| 179 | + { | ||||
| 180 | + "key": "be6444ec-a9ac-4ecc-bd50-798d883b423a", | ||||
| 181 | + "label": "O Fuzion (découvert via ofuzionmontreal.com)" | ||||
| 182 | + }, | ||||
| 183 | + { | ||||
| 184 | + "key": "65ade9ac-96d0-43e4-a95f-d43abef9b28c", | ||||
| 185 | + "label": "Jacques Cartier Pizza (découvert via jacquescartierpizza.com)" | ||||
| 186 | + }, | ||||
| 187 | + { | ||||
| 188 | + "key": "691dd353-f1e7-4cb9-ab45-9a26395ada43", | ||||
| 189 | + "label": "Le Jardin de Panos (découvert via lejardindepanos.com)" | ||||
| 190 | + }, | ||||
| 191 | + { | ||||
| 192 | + "key": "f8770d29-c21e-4a52-a563-927d11d8b555", | ||||
| 193 | + "label": "Mama Khan (découvert via mamakhan.ca)" | ||||
| 194 | + }, | ||||
| 195 | + { | ||||
| 196 | + "key": "20490e01-9803-44a4-8f0b-afc79fb78957", | ||||
| 197 | + "label": "F+F Pizza (découvert via ffpizza.com)" | ||||
| 198 | + }, | ||||
| 199 | + { | ||||
| 200 | + "key": "46f4245a-0868-490a-882e-cde290c69ef4", | ||||
| 201 | + "label": "Souvlaki Bar (découvert via souvlakibar.ca)" | ||||
| 202 | + }, | ||||
| 203 | + { | ||||
| 204 | + "key": "815ae848-a8ad-4a15-8f1a-7ad7998f8bf8", | ||||
| 205 | + "label": "Les deux fours (découvert via lesdeuxfours.ca)" | ||||
| 206 | + }, | ||||
| 207 | + { | ||||
| 208 | + "key": "e22c23d8-e195-415b-a2ba-21bb7398d421", | ||||
| 209 | + "label": "Poke Monster (découvert via pokemonster.com)" | ||||
| 210 | + }, | ||||
| 211 | + { | ||||
| 212 | + "key": "4268100d-6470-40b9-a755-e11342c91d6d", | ||||
| 213 | + "label": "Poulet Rouge (découvert via poulet-rouge.ca)" | ||||
| 214 | + }, | ||||
| 215 | + { | ||||
| 216 | + "key": "3a2610a1-1549-48e5-a08d-74a46bab56e9", | ||||
| 217 | + "label": "Edward Smoked Meat (découvert via edwardsmokedmeat.com)" | ||||
| 218 | + }, | ||||
| 219 | + { | ||||
| 220 | + "key": "ee4856e5-33db-441a-bdbe-60da473a9f8e", | ||||
| 221 | + "label": "Samon Sushi (découvert via samonsushi.com)" | ||||
| 222 | + }, | ||||
| 223 | + { | ||||
| 224 | + "key": "41c9d5fc-eb0a-43b9-87eb-789211d9e64e", | ||||
| 225 | + "label": "Blaxton Centre Vidéotron (découvert via blaxton.com)" | ||||
| 226 | + }, | ||||
| 227 | + { | ||||
| 228 | + "key": "365c4087-4be8-4c90-b6d3-f21fd02fd8f0", | ||||
| 229 | + "label": "Becco Pizza (découvert via beccopizza.com)" | ||||
| 230 | + }, | ||||
| 231 | + { | ||||
| 232 | + "key": "e473d88d-b405-41ee-9ee8-2aa628eee604", | ||||
| 233 | + "label": "ThaïZone (découvert via thaizone.ca)" | ||||
| 234 | + }, | ||||
| 235 | + { | ||||
| 236 | + "key": "970d8e90-55ca-4655-9631-a8f9af08f4b7", | ||||
| 237 | + "label": "Ashton (découvert via ashtonrestaurants.ca)" | ||||
| 238 | + }, | ||||
| 239 | + { | ||||
| 240 | + "key": "25aeb2a7-9693-434f-8c60-3d390b13ec0d", | ||||
| 241 | + "label": "Planète Poutine (découvert via planetepoutine.com)" | ||||
| 242 | + }, | ||||
| 243 | + { | ||||
| 244 | + "key": "1696ecd3-d4ea-4224-9f98-9299ebcb339c", | ||||
| 245 | + "label": "Mandy’s Salades Gourmandes (découvert via mandys.ca)" | ||||
| 246 | + }, | ||||
| 247 | + { | ||||
| 248 | + "key": "f2ed2c82-8a8a-4335-ade5-2c667d4f5d6e", | ||||
| 249 | + "label": "Ogari San Sushi (découvert via ogarisan.com)" | ||||
| 250 | + }, | ||||
| 251 | + { | ||||
| 252 | + "key": "48841e4f-9109-4c54-9c64-325f8546ad55", | ||||
| 253 | + "label": "QDC Burger (découvert via qdcburger.com)" | ||||
| 254 | + }, | ||||
| 255 | + { | ||||
| 256 | + "key": "5d5542c1-e9d4-4d7d-91b4-73fe4c60081d", | ||||
| 257 | + "label": "Mucho Burrito (découvert via muchoburrito.com)" | ||||
| 258 | + }, | ||||
| 259 | + { | ||||
| 260 | + "key": "e9f4d78d-feb0-42cc-85a3-8573b252c56d", | ||||
| 261 | + "label": "Le Crêpe Chignon - Café Crêperie (découvert via crepechignon.com)" | ||||
| 262 | + }, | ||||
| 263 | + { | ||||
| 264 | + "key": "a2b6f390-ff59-4ba1-a3c4-de5b9c50ffe9", | ||||
| 265 | + "label": "Café Dépôt (découvert via cafedepot.com)" | ||||
| 266 | + }, | ||||
| 267 | + { | ||||
| 268 | + "key": "60f28074-a159-45eb-ae45-754bc1a56f3d", | ||||
| 269 | + "label": "Vegâteau (découvert via vegateau.com)" | ||||
| 270 | + }, | ||||
| 271 | + { | ||||
| 272 | + "key": "cab213d8-b49c-4f9e-b85d-32b7f81a7003", | ||||
| 273 | + "label": "Kouzina Niata (découvert via kouzinaniata.com)" | ||||
| 274 | + }, | ||||
| 275 | + { | ||||
| 276 | + "key": "ad7a243e-420b-444b-bf45-5f442f00cd72", | ||||
| 277 | + "label": "Sandwicherie Sue (découvert via suefoods.com)" | ||||
| 278 | + }, | ||||
| 279 | + { | ||||
| 280 | + "key": "8951891f-62cd-4b90-8f1e-9fe0340eb9f7", | ||||
| 281 | + "label": "Zébulon (découvert via patisseriezebulon.com)" | ||||
| 282 | + }, | ||||
| 283 | + { | ||||
| 284 | + "key": "6f18635d-a96e-4119-bfa8-3be46e284bd4", | ||||
| 285 | + "label": "De Froment et de Sève (découvert via chezfroment.com)" | ||||
| 286 | + }, | ||||
| 287 | + { | ||||
| 288 | + "key": "aac748c8-cc23-4552-a24d-e54880c76fe7", | ||||
| 289 | + "label": "O'Gateries (découvert via ogateries.com)" | ||||
| 290 | + }, | ||||
| 291 | + { | ||||
| 292 | + "key": "8e2d6b3e-63cc-4419-89c4-746d23f63b77", | ||||
| 293 | + "label": "Beiko - La Boite A Beignes (découvert via beiko.ca)" | ||||
| 294 | + }, | ||||
| 295 | + { | ||||
| 296 | + "key": "dbce54d9-1b99-4561-8eda-18af3d5a8bfe", | ||||
| 297 | + "label": "Trattoria di Mikes (découvert via toujoursmikes.ca)" | ||||
| 298 | + }, | ||||
| 299 | + { | ||||
| 300 | + "key": "7889bc0c-acc8-46f1-ba24-e45becbe32a9", | ||||
| 301 | + "label": "Le Normandie Sabreur (découvert via normandiesabreur.com)" | ||||
| 302 | + }, | ||||
| 303 | + { | ||||
| 304 | + "key": "96a31b7f-b642-4ea9-b243-00b05b8d5ceb", | ||||
| 305 | + "label": "Pho MC (découvert via phomcbrossard.com)" | ||||
| 306 | + }, | ||||
| 307 | + { | ||||
| 308 | + "key": "e6fa0b22-5592-4dea-884d-9eb118cf2d94", | ||||
| 309 | + "label": "Dagwoods (découvert via dagwoods.ca)" | ||||
| 310 | + }, | ||||
| 311 | + { | ||||
| 312 | + "key": "338c0e77-9094-40c3-815b-47826ef27b1e", | ||||
| 313 | + "label": "Vertigo (découvert via restaurantsvertigo.com)" | ||||
| 314 | + }, | ||||
| 315 | + { | ||||
| 316 | + "key": "108015e1-a8e8-492e-a756-0ffe8b112017", | ||||
| 317 | + "label": "Pizza Avenue (découvert via pizzaavenue.ca)" | ||||
| 318 | + } | ||||
| 319 | + ] | ||||
| 320 | +} | ||||
| \ No newline at end of file | |||||
modified
frontend/src/App.tsx
+71 −3
@@ -6,16 +6,82 @@ | |||||
| 6 | // ============================================================================== | 6 | // ============================================================================== | ||
| 7 | import { useEffect, useState } from "react"; | 7 | import { useEffect, useState } from "react"; | ||
| 8 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | 8 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | ||
| 9 | −import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | 9 | +import { AccountProvider, useAccount } from "./account"; | ||
| 10 | +import { | ||||
| 11 | + fetchAuthConfig, fetchFacets, fetchSources, fetchStats, logout, | ||||
| 12 | + registerSourceNames, sourceName, | ||||
| 13 | +} from "./api"; | ||||
| 10 | import { | 14 | import { | ||
| 11 | IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch, | 15 | IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch, | ||
| 12 | } from "./components/Icons"; | 16 | } from "./components/Icons"; | ||
| 13 | import { LogoIcon } from "./components/Logo"; | 17 | import { LogoIcon } from "./components/Logo"; | ||
| 18 | +import FavorisPage from "./pages/Favoris"; | ||||
| 14 | import Home from "./pages/Home"; | 19 | import Home from "./pages/Home"; | ||
| 15 | import RestoPage from "./pages/Resto"; | 20 | import RestoPage from "./pages/Resto"; | ||
| 16 | import SourcesPage from "./pages/Sources"; | 21 | import SourcesPage from "./pages/Sources"; | ||
| 17 | import StatsPage from "./pages/Stats"; | 22 | import StatsPage from "./pages/Stats"; | ||
| 18 | 23 | ||||
| 24 | +/** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */ | ||||
| 25 | +function AccountMenu() { | ||||
| 26 | + const [enabled, setEnabled] = useState(false); | ||||
| 27 | + const { me, refresh } = useAccount(); | ||||
| 28 | + const [menuOpen, setMenuOpen] = useState(false); | ||||
| 29 | + | ||||
| 30 | + useEffect(() => { | ||||
| 31 | + fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {}); | ||||
| 32 | + }, []); | ||||
| 33 | + | ||||
| 34 | + if (!enabled) return null; | ||||
| 35 | + if (!me) { | ||||
| 36 | + return ( | ||||
| 37 | + <a className="login-btn" href="/api/auth/ka/login"> | ||||
| 38 | + <span className="ka-badge" aria-hidden="true">KA</span> | ||||
| 39 | + Connexion | ||||
| 40 | + </a> | ||||
| 41 | + ); | ||||
| 42 | + } | ||||
| 43 | + return ( | ||||
| 44 | + <div className="account"> | ||||
| 45 | + <button | ||||
| 46 | + className="account-btn" | ||||
| 47 | + onClick={() => setMenuOpen(!menuOpen)} | ||||
| 48 | + aria-expanded={menuOpen} | ||||
| 49 | + aria-label={`Compte : ${me.name || me.email}`} | ||||
| 50 | + > | ||||
| 51 | + {me.picture | ||||
| 52 | + ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> | ||||
| 53 | + : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>} | ||||
| 54 | + </button> | ||||
| 55 | + {menuOpen && ( | ||||
| 56 | + <> | ||||
| 57 | + <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" /> | ||||
| 58 | + <div className="account-menu" role="menu"> | ||||
| 59 | + <div className="account-id"> | ||||
| 60 | + <b>{me.name || "Mon compte"}</b> | ||||
| 61 | + <span>{me.email}</span> | ||||
| 62 | + {me.ka_id && <span className="account-kaid">{me.ka_id}</span>} | ||||
| 63 | + </div> | ||||
| 64 | + <NavLink to="/favoris" role="menuitem" className="account-link" | ||||
| 65 | + onClick={() => setMenuOpen(false)}> | ||||
| 66 | + Mes favoris | ||||
| 67 | + </NavLink> | ||||
| 68 | + <a href="https://www.groupe-ka.com/compte" target="_blank" | ||||
| 69 | + rel="noopener noreferrer" role="menuitem" className="account-link"> | ||||
| 70 | + Mon profil Groupe KA ↗ | ||||
| 71 | + </a> | ||||
| 72 | + <button | ||||
| 73 | + role="menuitem" | ||||
| 74 | + onClick={async () => { await logout(); refresh(); setMenuOpen(false); }} | ||||
| 75 | + > | ||||
| 76 | + Se déconnecter | ||||
| 77 | + </button> | ||||
| 78 | + </div> | ||||
| 79 | + </> | ||||
| 80 | + )} | ||||
| 81 | + </div> | ||||
| 82 | + ); | ||||
| 83 | +} | ||||
| 84 | + | ||||
| 19 | function Ticker() { | 85 | function Ticker() { | ||
| 20 | const [items, setItems] = useState<string[]>([]); | 86 | const [items, setItems] = useState<string[]>([]); | ||
| 21 | 87 | ||||
@@ -86,6 +152,7 @@ function Header() { | |||||
| 86 | </NavLink> | 152 | </NavLink> | ||
| 87 | ))} | 153 | ))} | ||
| 88 | </nav> | 154 | </nav> | ||
| 155 | + <AccountMenu /> | ||||
| 89 | <button | 156 | <button | ||
| 90 | className={`menu-btn ${open ? "open" : ""}`} | 157 | className={`menu-btn ${open ? "open" : ""}`} | ||
| 91 | aria-expanded={open} | 158 | aria-expanded={open} | ||
@@ -186,12 +253,13 @@ function MobileTabBar() { | |||||
| 186 | 253 | ||||
| 187 | export default function App() { | 254 | export default function App() { | ||
| 188 | return ( | 255 | return ( | ||
| 189 | − <> | 256 | + <AccountProvider> | ||
| 190 | <Header /> | 257 | <Header /> | ||
| 191 | <main> | 258 | <main> | ||
| 192 | <Routes> | 259 | <Routes> | ||
| 193 | <Route path="/" element={<Home />} /> | 260 | <Route path="/" element={<Home />} /> | ||
| 194 | <Route path="/resto/:uid" element={<RestoPage />} /> | 261 | <Route path="/resto/:uid" element={<RestoPage />} /> | ||
| 262 | + <Route path="/favoris" element={<FavorisPage />} /> | ||||
| 195 | <Route path="/stats" element={<StatsPage />} /> | 263 | <Route path="/stats" element={<StatsPage />} /> | ||
| 196 | <Route path="/sources" element={<SourcesPage />} /> | 264 | <Route path="/sources" element={<SourcesPage />} /> | ||
| 197 | <Route | 265 | <Route | ||
@@ -208,6 +276,6 @@ export default function App() { | |||||
| 208 | </main> | 276 | </main> | ||
| 209 | <Footer /> | 277 | <Footer /> | ||
| 210 | <MobileTabBar /> | 278 | <MobileTabBar /> | ||
| 211 | − </> | 279 | + </AccountProvider> | ||
| 212 | ); | 280 | ); | ||
| 213 | } | 281 | } | ||
added
frontend/src/account.tsx
+52 −0
@@ -0,0 +1,52 @@ | |||||
| 1 | +// ============================================================================== | ||||
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +// File: account.tsx | ||||
| 4 | +// Desc: Contexte de compte membre (« Se connecter avec KA » — hub Groupe KA) : | ||||
| 5 | +// profil de session + favoris partagés, comme le account.tsx de Lou·Ka. | ||||
| 6 | +// ============================================================================== | ||||
| 7 | +import { | ||||
| 8 | + createContext, useCallback, useContext, useEffect, useMemo, useState, | ||||
| 9 | +} from "react"; | ||||
| 10 | +import { Me, fetchFavoris, fetchMe, toggleFavori } from "./api"; | ||||
| 11 | + | ||||
| 12 | +interface AccountCtx { | ||||
| 13 | + me: Me | null; | ||||
| 14 | + favs: Set<string>; | ||||
| 15 | + refresh: () => void; | ||||
| 16 | + toggleFav: (uid: string) => Promise<void>; | ||||
| 17 | +} | ||||
| 18 | + | ||||
| 19 | +const Ctx = createContext<AccountCtx>({ | ||||
| 20 | + me: null, favs: new Set(), refresh: () => {}, toggleFav: async () => {}, | ||||
| 21 | +}); | ||||
| 22 | + | ||||
| 23 | +export const useAccount = () => useContext(Ctx); | ||||
| 24 | + | ||||
| 25 | +export function AccountProvider({ children }: { children: React.ReactNode }) { | ||||
| 26 | + const [me, setMe] = useState<Me | null>(null); | ||||
| 27 | + const [favs, setFavs] = useState<Set<string>>(new Set()); | ||||
| 28 | + | ||||
| 29 | + const refresh = useCallback(() => { | ||||
| 30 | + fetchMe().then((m) => { | ||||
| 31 | + setMe(m); | ||||
| 32 | + if (m) fetchFavoris().then((f) => setFavs(new Set(f.uids))).catch(() => {}); | ||||
| 33 | + else setFavs(new Set()); | ||||
| 34 | + }); | ||||
| 35 | + }, []); | ||||
| 36 | + | ||||
| 37 | + useEffect(() => { refresh(); }, [refresh]); | ||||
| 38 | + | ||||
| 39 | + const toggleFav = useCallback(async (uid: string) => { | ||||
| 40 | + const on = await toggleFavori(uid); | ||||
| 41 | + setFavs((prev) => { | ||||
| 42 | + const next = new Set(prev); | ||||
| 43 | + if (on) next.add(uid); | ||||
| 44 | + else next.delete(uid); | ||||
| 45 | + return next; | ||||
| 46 | + }); | ||||
| 47 | + }, []); | ||||
| 48 | + | ||||
| 49 | + const value = useMemo(() => ({ me, favs, refresh, toggleFav }), | ||||
| 50 | + [me, favs, refresh, toggleFav]); | ||||
| 51 | + return <Ctx.Provider value={value}>{children}</Ctx.Provider>; | ||||
| 52 | +} | ||||
modified
frontend/src/api.ts
+37 −0
@@ -247,3 +247,40 @@ export const fetchFacets = (region?: string) => | |||||
| 247 | export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); | 247 | export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); | ||
| 248 | 248 | ||||
| 249 | export const fetchStats = () => get<Stats>("/api/stats"); | 249 | export const fetchStats = () => get<Stats>("/api/stats"); | ||
| 250 | + | ||||
| 251 | +// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------ | ||||
| 252 | + | ||||
| 253 | +export interface Me { | ||||
| 254 | + uid: number; | ||||
| 255 | + ka_id: string; | ||||
| 256 | + email: string; | ||||
| 257 | + name: string; | ||||
| 258 | + picture: string; | ||||
| 259 | + bio?: string; | ||||
| 260 | + city?: string; | ||||
| 261 | + public_url?: string; | ||||
| 262 | + profile_source: string; | ||||
| 263 | +} | ||||
| 264 | + | ||||
| 265 | +export const fetchAuthConfig = () => get<{ ka: boolean }>("/api/auth/config"); | ||||
| 266 | + | ||||
| 267 | +export async function fetchMe(): Promise<Me | null> { | ||||
| 268 | + try { | ||||
| 269 | + return await get<Me>("/api/me"); | ||||
| 270 | + } catch { | ||||
| 271 | + return null; | ||||
| 272 | + } | ||||
| 273 | +} | ||||
| 274 | + | ||||
| 275 | +export async function logout(): Promise<void> { | ||||
| 276 | + await fetch("/api/auth/logout", { method: "POST" }); | ||||
| 277 | +} | ||||
| 278 | + | ||||
| 279 | +export const fetchFavoris = () => | ||||
| 280 | + get<{ count: number; restaurants: Restaurant[]; uids: string[] }>("/api/favoris"); | ||||
| 281 | + | ||||
| 282 | +export async function toggleFavori(uid: string): Promise<boolean> { | ||||
| 283 | + const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: "POST" }); | ||||
| 284 | + if (!resp.ok) throw new Error(`API ${resp.status}`); | ||||
| 285 | + return (await resp.json()).favori as boolean; | ||||
| 286 | +} | ||||
modified
frontend/src/components/RestoCard.tsx
+13 −0
@@ -6,6 +6,7 @@ | |||||
| 6 | // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte. | 6 | // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte. | ||
| 7 | // ============================================================================== | 7 | // ============================================================================== | ||
| 8 | import { Link } from "react-router-dom"; | 8 | import { Link } from "react-router-dom"; | ||
| 9 | +import { useAccount } from "../account"; | ||||
| 9 | import { | 10 | import { | ||
| 10 | CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel, | 11 | CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel, | ||
| 11 | } from "../api"; | 12 | } from "../api"; | ||
@@ -15,6 +16,8 @@ export default function RestoCard({ r }: { r: Restaurant }) { | |||||
| 15 | const ms = r.menu_summary; | 16 | const ms = r.menu_summary; | ||
| 16 | const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord | 17 | const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord | ||
| 17 | const logo = (r.images ?? [])[0] || null; | 18 | const logo = (r.images ?? [])[0] || null; | ||
| 19 | + const { me, favs, toggleFav } = useAccount(); | ||||
| 20 | + const fav = favs.has(r.uid); | ||||
| 18 | return ( | 21 | return ( | ||
| 19 | <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card"> | 22 | <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card"> | ||
| 20 | <div className="card-img"> | 23 | <div className="card-img"> | ||
@@ -31,6 +34,16 @@ export default function RestoCard({ r }: { r: Restaurant }) { | |||||
| 31 | {CONTEXT_SHORT[ms.price_context] ?? ms.price_context} | 34 | {CONTEXT_SHORT[ms.price_context] ?? ms.price_context} | ||
| 32 | </span> | 35 | </span> | ||
| 33 | )} | 36 | )} | ||
| 37 | + {me && ( | ||||
| 38 | + <button | ||||
| 39 | + className={`fav-btn ${fav ? "on" : ""}`} | ||||
| 40 | + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} | ||||
| 41 | + aria-pressed={fav} | ||||
| 42 | + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }} | ||||
| 43 | + > | ||||
| 44 | + {fav ? "♥" : "♡"} | ||||
| 45 | + </button> | ||||
| 46 | + )} | ||||
| 34 | </div> | 47 | </div> | ||
| 35 | <div className="card-body"> | 48 | <div className="card-body"> | ||
| 36 | <div className="card-price"> | 49 | <div className="card-price"> | ||
added
frontend/src/pages/Favoris.tsx
+58 −0
@@ -0,0 +1,58 @@ | |||||
| 1 | +// ============================================================================== | ||||
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +// File: pages/Favoris.tsx | ||||
| 4 | +// Desc: Restos favoris du membre connecté (« Se connecter avec KA »). | ||||
| 5 | +// ============================================================================== | ||||
| 6 | +import { useEffect, useState } from "react"; | ||||
| 7 | +import { useAccount } from "../account"; | ||||
| 8 | +import { Restaurant, fetchFavoris } from "../api"; | ||||
| 9 | +import RestoCard from "../components/RestoCard"; | ||||
| 10 | + | ||||
| 11 | +export default function FavorisPage() { | ||||
| 12 | + const { me, favs } = useAccount(); | ||||
| 13 | + const [restos, setRestos] = useState<Restaurant[]>([]); | ||||
| 14 | + const [loading, setLoading] = useState(true); | ||||
| 15 | + | ||||
| 16 | + useEffect(() => { | ||||
| 17 | + if (!me) { setLoading(false); return; } | ||||
| 18 | + fetchFavoris() | ||||
| 19 | + .then((f) => setRestos(f.restaurants)) | ||||
| 20 | + .catch(() => {}) | ||||
| 21 | + .finally(() => setLoading(false)); | ||||
| 22 | + }, [me, favs.size]); | ||||
| 23 | + | ||||
| 24 | + if (!me) { | ||||
| 25 | + return ( | ||||
| 26 | + <div className="notice container"> | ||||
| 27 | + <h2>Connectez-vous pour vos favoris</h2> | ||||
| 28 | + <p> | ||||
| 29 | + <a className="login-btn" href="/api/auth/ka/login" style={{ display: "inline-flex" }}> | ||||
| 30 | + <span className="ka-badge" aria-hidden="true">KA</span> | ||||
| 31 | + Se connecter avec KA | ||||
| 32 | + </a> | ||||
| 33 | + </p> | ||||
| 34 | + <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p> | ||||
| 35 | + </div> | ||||
| 36 | + ); | ||||
| 37 | + } | ||||
| 38 | + | ||||
| 39 | + return ( | ||||
| 40 | + <div className="container sources"> | ||||
| 41 | + <span className="kicker">Mon compte — {me.ka_id}</span> | ||||
| 42 | + <h1>Mes restos favoris</h1> | ||||
| 43 | + <p className="sub">Les restos que vous avez épinglés, sur tous vos appareils.</p> | ||||
| 44 | + {loading ? ( | ||||
| 45 | + <p>Chargement…</p> | ||||
| 46 | + ) : restos.length === 0 ? ( | ||||
| 47 | + <div className="notice"> | ||||
| 48 | + <h2>Aucun favori pour l'instant</h2> | ||||
| 49 | + <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p> | ||||
| 50 | + </div> | ||||
| 51 | + ) : ( | ||||
| 52 | + <div className="grid"> | ||||
| 53 | + {restos.map((r) => <RestoCard key={r.uid} r={r} />)} | ||||
| 54 | + </div> | ||||
| 55 | + )} | ||||
| 56 | + </div> | ||||
| 57 | + ); | ||||
| 58 | +} | ||||
modified
frontend/src/styles.css
+65 −0
@@ -754,6 +754,71 @@ h1 .hl, h2 .hl, .fbaseline .hl { | |||||
| 754 | .footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; } | 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; } | 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 | 756 | ||||
| 757 | +/* --- Compte membre (« Se connecter avec KA ») ------------------------------- */ | ||||
| 758 | +.login-btn { | ||||
| 759 | + display: inline-flex; align-items: center; gap: 8px; | ||||
| 760 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | ||||
| 761 | + background: var(--surface); color: var(--ink); | ||||
| 762 | + padding: 8px 14px; font-family: var(--font-display); | ||||
| 763 | + font-weight: 700; font-size: 13.5px; min-height: 38px; | ||||
| 764 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); | ||||
| 765 | + transition: background 0.13s ease, transform 0.1s ease, box-shadow 0.1s ease; | ||||
| 766 | +} | ||||
| 767 | +.login-btn:hover { background: var(--lime-soft); } | ||||
| 768 | +.login-btn:active { transform: translate(2px, 2px); box-shadow: none; } | ||||
| 769 | +.ka-badge { | ||||
| 770 | + display: inline-flex; align-items: center; justify-content: center; | ||||
| 771 | + background: var(--lime); color: var(--ink); border: 1px solid var(--ink); | ||||
| 772 | + padding: 1px 6px 2px; border-radius: 4px; | ||||
| 773 | + font-family: var(--font-display); font-weight: 700; font-size: 11px; | ||||
| 774 | + transform: rotate(-2deg); | ||||
| 775 | +} | ||||
| 776 | +.account { position: relative; } | ||||
| 777 | +.account-btn { | ||||
| 778 | + width: 40px; height: 40px; border-radius: 50%; overflow: hidden; | ||||
| 779 | + border: 1.5px solid var(--ink); background: var(--lime); | ||||
| 780 | + cursor: pointer; padding: 0; | ||||
| 781 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); | ||||
| 782 | +} | ||||
| 783 | +.account-btn img { width: 100%; height: 100%; object-fit: cover; } | ||||
| 784 | +.account-initial { | ||||
| 785 | + display: flex; align-items: center; justify-content: center; | ||||
| 786 | + width: 100%; height: 100%; | ||||
| 787 | + font-family: var(--font-display); font-weight: 700; font-size: 17px; color: var(--ink); | ||||
| 788 | +} | ||||
| 789 | +.account-backdrop { position: fixed; inset: 0; z-index: 55; } | ||||
| 790 | +.account-menu { | ||||
| 791 | + position: absolute; right: 0; top: calc(100% + 10px); z-index: 60; | ||||
| 792 | + min-width: 230px; background: var(--surface); | ||||
| 793 | + border: 1.5px solid var(--ink); border-radius: var(--r-card); | ||||
| 794 | + box-shadow: var(--shadow-off); padding: 8px; display: flex; flex-direction: column; | ||||
| 795 | +} | ||||
| 796 | +.account-id { padding: 8px 10px 10px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; } | ||||
| 797 | +.account-id b { font-family: var(--font-display); font-size: 14.5px; } | ||||
| 798 | +.account-id span { font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; } | ||||
| 799 | +.account-kaid { | ||||
| 800 | + font-family: var(--font-mono) !important; font-size: 11px !important; | ||||
| 801 | + color: var(--green) !important; letter-spacing: 0.06em; | ||||
| 802 | +} | ||||
| 803 | +.account-link, .account-menu button { | ||||
| 804 | + text-align: left; border: none; background: none; cursor: pointer; | ||||
| 805 | + padding: 10px; border-radius: var(--r-ctl); | ||||
| 806 | + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink); | ||||
| 807 | +} | ||||
| 808 | +.account-link:hover, .account-menu button:hover { background: var(--lime-soft); } | ||||
| 809 | + | ||||
| 810 | +/* cœur favori sur les cartes */ | ||||
| 811 | +.fav-btn { | ||||
| 812 | + position: absolute; bottom: 10px; right: 10px; | ||||
| 813 | + width: 34px; height: 34px; border-radius: 50%; | ||||
| 814 | + border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.95); | ||||
| 815 | + color: var(--ink-2); font-size: 16px; line-height: 1; cursor: pointer; | ||||
| 816 | + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2); | ||||
| 817 | + transition: transform 0.12s ease, background 0.12s ease; | ||||
| 818 | +} | ||||
| 819 | +.fav-btn:hover { transform: scale(1.08); } | ||||
| 820 | +.fav-btn.on { background: var(--lime); color: var(--ink); } | ||||
| 821 | + | ||||
| 757 | /* --- Menu déroulant mobile ------------------------------------------------- */ | 822 | /* --- Menu déroulant mobile ------------------------------------------------- */ | ||
| 758 | .menu-btn { | 823 | .menu-btn { | ||
| 759 | display: none; position: relative; z-index: 60; | 824 | display: none; position: relative; z-index: 60; | ||
modified
requirements.txt
+1 −0
@@ -7,3 +7,4 @@ fastapi>=0.110 | |||||
| 7 | uvicorn>=0.29 | 7 | uvicorn>=0.29 | ||
| 8 | requests>=2.31 | 8 | requests>=2.31 | ||
| 9 | beautifulsoup4>=4.12 | 9 | beautifulsoup4>=4.12 | ||
| 10 | +anthropic>=0.116 | ||||
modified
restoka/__init__.py
+11 −0
@@ -3,4 +3,15 @@ | |||||
| 3 | # File: restoka/__init__.py | 3 | # File: restoka/__init__.py | ||
| 4 | # Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix). | 4 | # Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix). | ||
| 5 | # Paquet principal ; architecture calquée sur Lou·Ka (famille ·Ka). | 5 | # Paquet principal ; architecture calquée sur Lou·Ka (famille ·Ka). | ||
| 6 | +# Charge .env (clés API) quel que soit le point d'entrée. | ||||
| 6 | # ============================================================================== | 7 | # ============================================================================== | ||
| 8 | +import os as _os | ||||
| 9 | +from pathlib import Path as _Path | ||||
| 10 | + | ||||
| 11 | +_env = _Path(__file__).resolve().parent.parent / ".env" | ||||
| 12 | +if _env.exists(): | ||||
| 13 | + for _line in _env.read_text().splitlines(): | ||||
| 14 | + _line = _line.strip() | ||||
| 15 | + if _line and not _line.startswith("#") and "=" in _line: | ||||
| 16 | + _k, _, _v = _line.partition("=") | ||||
| 17 | + _os.environ.setdefault(_k.strip(), _v.strip()) | ||||
added
restoka/auth.py
+296 −0
@@ -0,0 +1,296 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/auth.py | ||||
| 4 | +# Desc: Comptes utilisateurs — « Se connecter avec KA » (KA ID, hub | ||||
| 5 | +# d'identité du Groupe KA sur groupe-ka.com). Flux SSO calqué sur | ||||
| 6 | +# louka/auth.py : | ||||
| 7 | +# GET /api/auth/ka/login -> redirige vers le hub (state signé) | ||||
| 8 | +# GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub | ||||
| 9 | +# (secret partagé KA_SSO_SECRET, | ||||
| 10 | +# aud=resto-ka), upsert l'utilisateur, | ||||
| 11 | +# pose le cookie de session | ||||
| 12 | +# GET /api/me -> profil (fusionné avec le hub) | ||||
| 13 | +# POST /api/auth/logout -> efface le cookie | ||||
| 14 | +# GET /api/auth/config -> {"ka": bool} | ||||
| 15 | +# + FAVORIS : GET /api/favoris, POST /api/favoris/{uid} (toggle). | ||||
| 16 | +# Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly. | ||||
| 17 | +# Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL. | ||||
| 18 | +# ============================================================================== | ||||
| 19 | +from __future__ import annotations | ||||
| 20 | + | ||||
| 21 | +import base64 | ||||
| 22 | +import hashlib | ||||
| 23 | +import hmac | ||||
| 24 | +import json | ||||
| 25 | +import os | ||||
| 26 | +import secrets | ||||
| 27 | +import time | ||||
| 28 | +from urllib.parse import urlencode | ||||
| 29 | + | ||||
| 30 | +from fastapi import APIRouter, HTTPException, Request | ||||
| 31 | +from fastapi.responses import JSONResponse, RedirectResponse | ||||
| 32 | + | ||||
| 33 | +from . import db | ||||
| 34 | +from .hubprofile import fetch_hub_profile | ||||
| 35 | + | ||||
| 36 | +router = APIRouter(prefix="/api") | ||||
| 37 | + | ||||
| 38 | +COOKIE = "restoka_session" | ||||
| 39 | +SESSION_DAYS = 30 | ||||
| 40 | +CLIENT_ID = "resto-ka" | ||||
| 41 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | ||||
| 42 | + | ||||
| 43 | + | ||||
| 44 | +def _secret() -> bytes: | ||||
| 45 | + s = os.environ.get("SESSION_SECRET") | ||||
| 46 | + if not s: | ||||
| 47 | + raise HTTPException(503, "SESSION_SECRET manquant (voir .env)") | ||||
| 48 | + return s.encode() | ||||
| 49 | + | ||||
| 50 | + | ||||
| 51 | +def _ka_secret() -> bytes | None: | ||||
| 52 | + s = os.environ.get("KA_SSO_SECRET") | ||||
| 53 | + return s.encode() if s else None | ||||
| 54 | + | ||||
| 55 | + | ||||
| 56 | +def _base_url(request: Request) -> str: | ||||
| 57 | + """URL publique de l'app — forcée par RESTOKA_BASE_URL en prod (ngrok).""" | ||||
| 58 | + return (os.environ.get("RESTOKA_BASE_URL") | ||||
| 59 | + or str(request.base_url)).rstrip("/") | ||||
| 60 | + | ||||
| 61 | + | ||||
| 62 | +# -- jeton signé (payload JSON -> base64url.signature) -------------------------- | ||||
| 63 | + | ||||
| 64 | +def _sign(payload: dict) -> str: | ||||
| 65 | + raw = base64.urlsafe_b64encode( | ||||
| 66 | + json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=") | ||||
| 67 | + sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() | ||||
| 68 | + return f"{raw}.{sig}" | ||||
| 69 | + | ||||
| 70 | + | ||||
| 71 | +def _verify(token: str) -> dict | None: | ||||
| 72 | + try: | ||||
| 73 | + raw, sig = token.rsplit(".", 1) | ||||
| 74 | + expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() | ||||
| 75 | + if not hmac.compare_digest(sig, expected): | ||||
| 76 | + return None | ||||
| 77 | + payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))) | ||||
| 78 | + if payload.get("exp", 0) < time.time(): | ||||
| 79 | + return None | ||||
| 80 | + return payload | ||||
| 81 | + except Exception: | ||||
| 82 | + return None | ||||
| 83 | + | ||||
| 84 | + | ||||
| 85 | +def current_user(request: Request) -> dict | None: | ||||
| 86 | + """Payload de session ({uid, email, name, picture}) ou None.""" | ||||
| 87 | + token = request.cookies.get(COOKIE) | ||||
| 88 | + return _verify(token) if token else None | ||||
| 89 | + | ||||
| 90 | + | ||||
| 91 | +# -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------ | ||||
| 92 | + | ||||
| 93 | +def _b64url(s: str) -> bytes: | ||||
| 94 | + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) | ||||
| 95 | + | ||||
| 96 | + | ||||
| 97 | +def _verify_ka_token(token: str) -> dict | None: | ||||
| 98 | + secret = _ka_secret() | ||||
| 99 | + if not secret: | ||||
| 100 | + return None | ||||
| 101 | + try: | ||||
| 102 | + h_b64, p_b64, s_b64 = token.split(".") | ||||
| 103 | + expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), | ||||
| 104 | + hashlib.sha256).digest() | ||||
| 105 | + if not hmac.compare_digest(_b64url(s_b64), expected): | ||||
| 106 | + return None | ||||
| 107 | + if json.loads(_b64url(h_b64)).get("alg") != "HS256": | ||||
| 108 | + return None | ||||
| 109 | + claims = json.loads(_b64url(p_b64)) | ||||
| 110 | + if claims.get("iss") != KA_HUB_URL: | ||||
| 111 | + return None | ||||
| 112 | + aud = claims.get("aud") | ||||
| 113 | + if (aud if isinstance(aud, str) else "") != CLIENT_ID \ | ||||
| 114 | + and CLIENT_ID not in (aud if isinstance(aud, list) else []): | ||||
| 115 | + return None | ||||
| 116 | + if claims.get("exp", 0) < time.time(): | ||||
| 117 | + return None | ||||
| 118 | + return claims | ||||
| 119 | + except Exception: | ||||
| 120 | + return None | ||||
| 121 | + | ||||
| 122 | + | ||||
| 123 | +# -- routes SSO ------------------------------------------------------------------- | ||||
| 124 | + | ||||
| 125 | +@router.get("/auth/config") | ||||
| 126 | +def auth_config(): | ||||
| 127 | + return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))} | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +@router.get("/auth/ka/login") | ||||
| 131 | +def ka_login(request: Request): | ||||
| 132 | + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" | ||||
| 133 | + if not _ka_secret(): | ||||
| 134 | + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") | ||||
| 135 | + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) | ||||
| 136 | + params = { | ||||
| 137 | + "client_id": CLIENT_ID, | ||||
| 138 | + "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", | ||||
| 139 | + "state": state, | ||||
| 140 | + } | ||||
| 141 | + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}") | ||||
| 142 | + | ||||
| 143 | + | ||||
| 144 | +@router.get("/auth/ka/callback") | ||||
| 145 | +def ka_callback(request: Request, ka_token: str = "", state: str = ""): | ||||
| 146 | + """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local, | ||||
| 147 | + pose le cookie de session Resto·Ka.""" | ||||
| 148 | + if not ka_token or _verify(state) is None: | ||||
| 149 | + raise HTTPException(400, "state invalide ou expiré") | ||||
| 150 | + claims = _verify_ka_token(ka_token) | ||||
| 151 | + if claims is None: | ||||
| 152 | + raise HTTPException(401, "jeton KA invalide ou expiré") | ||||
| 153 | + | ||||
| 154 | + email = (claims.get("email") or "").lower() | ||||
| 155 | + name = claims.get("name") or (email.split("@")[0] if email else "membre") | ||||
| 156 | + picture = claims.get("picture") or "" | ||||
| 157 | + hub_key = f"ka:{claims.get('sub')}" | ||||
| 158 | + ka_id = (claims.get("ka_id") or "").strip() | ||||
| 159 | + | ||||
| 160 | + con = db.connect() | ||||
| 161 | + now = time.time() | ||||
| 162 | + row = con.execute("SELECT id FROM users WHERE hub_sub=?", | ||||
| 163 | + (hub_key,)).fetchone() | ||||
| 164 | + if row is None and email: | ||||
| 165 | + row = con.execute( | ||||
| 166 | + "SELECT id FROM users WHERE email=? ORDER BY id LIMIT 1", | ||||
| 167 | + (email,)).fetchone() | ||||
| 168 | + if row is not None: | ||||
| 169 | + con.execute( | ||||
| 170 | + "UPDATE users SET hub_sub=?, email=?, name=?, picture=?," | ||||
| 171 | + " last_login=? WHERE id=?", | ||||
| 172 | + (hub_key, email, name, picture, now, row["id"])) | ||||
| 173 | + uid = row["id"] | ||||
| 174 | + else: | ||||
| 175 | + cur = con.execute( | ||||
| 176 | + "INSERT INTO users (hub_sub, email, name, picture, created_at," | ||||
| 177 | + " last_login) VALUES (?,?,?,?,?,?)", | ||||
| 178 | + (hub_key, email, name, picture, now, now)) | ||||
| 179 | + uid = cur.lastrowid | ||||
| 180 | + # le KA ID est créé À LA SOURCE par le hub et transporté dans le jeton | ||||
| 181 | + if ka_id: | ||||
| 182 | + import sqlite3 | ||||
| 183 | + try: | ||||
| 184 | + con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid)) | ||||
| 185 | + except sqlite3.IntegrityError: | ||||
| 186 | + pass | ||||
| 187 | + con.commit() | ||||
| 188 | + con.close() | ||||
| 189 | + | ||||
| 190 | + session = _sign({ | ||||
| 191 | + "uid": uid, "email": email, "name": name, "picture": picture, | ||||
| 192 | + "ka_id": ka_id, | ||||
| 193 | + "exp": time.time() + SESSION_DAYS * 86400, | ||||
| 194 | + }) | ||||
| 195 | + resp = RedirectResponse("/?login=ok") | ||||
| 196 | + resp.set_cookie( | ||||
| 197 | + COOKIE, session, | ||||
| 198 | + max_age=SESSION_DAYS * 86400, | ||||
| 199 | + httponly=True, | ||||
| 200 | + secure=_base_url(request).startswith("https"), | ||||
| 201 | + samesite="lax", | ||||
| 202 | + path="/", | ||||
| 203 | + ) | ||||
| 204 | + return resp | ||||
| 205 | + | ||||
| 206 | + | ||||
| 207 | +@router.post("/auth/logout") | ||||
| 208 | +def logout(): | ||||
| 209 | + resp = JSONResponse({"ok": True}) | ||||
| 210 | + resp.delete_cookie(COOKIE, path="/") | ||||
| 211 | + return resp | ||||
| 212 | + | ||||
| 213 | + | ||||
| 214 | +@router.get("/me") | ||||
| 215 | +def me(request: Request): | ||||
| 216 | + user = current_user(request) | ||||
| 217 | + if user is None: | ||||
| 218 | + raise HTTPException(401, "non connecté") | ||||
| 219 | + con = db.connect() | ||||
| 220 | + row = con.execute("SELECT * FROM users WHERE id=?", | ||||
| 221 | + (user["uid"],)).fetchone() | ||||
| 222 | + con.close() | ||||
| 223 | + if row is None: | ||||
| 224 | + raise HTTPException(401, "compte introuvable") | ||||
| 225 | + out = { | ||||
| 226 | + "uid": row["id"], | ||||
| 227 | + "ka_id": row["ka_id"] or "", | ||||
| 228 | + "email": row["email"], | ||||
| 229 | + "name": row["name"], | ||||
| 230 | + "picture": row["picture"] or "", | ||||
| 231 | + "created_at": row["created_at"], | ||||
| 232 | + "profile_source": "local", | ||||
| 233 | + } | ||||
| 234 | + # le HUB Groupe KA est la source de vérité du profil (édition sur | ||||
| 235 | + # groupe-ka.com/compte) — ses champs remplacent les champs locaux | ||||
| 236 | + hub = fetch_hub_profile(out["ka_id"]) | ||||
| 237 | + if hub is not None: | ||||
| 238 | + hub_name = (hub.get("name") or "").strip() | ||||
| 239 | + out.update({ | ||||
| 240 | + "name": hub_name or out["name"], | ||||
| 241 | + "bio": hub.get("bio") or "", | ||||
| 242 | + "city": hub.get("city") or "", | ||||
| 243 | + "public_url": hub.get("public_url") or "", | ||||
| 244 | + "profile_source": "groupe-ka", | ||||
| 245 | + }) | ||||
| 246 | + if hub.get("picture"): | ||||
| 247 | + out["picture"] = hub["picture"] | ||||
| 248 | + return out | ||||
| 249 | + | ||||
| 250 | + | ||||
| 251 | +# -- favoris ------------------------------------------------------------------------ | ||||
| 252 | + | ||||
| 253 | +@router.get("/favoris") | ||||
| 254 | +def favoris(request: Request): | ||||
| 255 | + """Fiches complètes des restos favoris du membre connecté.""" | ||||
| 256 | + user = current_user(request) | ||||
| 257 | + if user is None: | ||||
| 258 | + raise HTTPException(401, "non connecté") | ||||
| 259 | + from .web import _menu_summaries, _row_to_dict | ||||
| 260 | + con = db.connect() | ||||
| 261 | + rows = con.execute( | ||||
| 262 | + """SELECT r.* FROM favorites f JOIN restaurants r ON r.uid=f.uid | ||||
| 263 | + WHERE f.user_id=? ORDER BY f.ts DESC""", (user["uid"],)).fetchall() | ||||
| 264 | + out = [_row_to_dict(r) for r in rows] | ||||
| 265 | + menus = _menu_summaries(con, [r["uid"] for r in out]) | ||||
| 266 | + for r in out: | ||||
| 267 | + r["menu_summary"] = menus.get(r["uid"]) | ||||
| 268 | + con.close() | ||||
| 269 | + return {"count": len(out), "restaurants": out, | ||||
| 270 | + "uids": [r["uid"] for r in out]} | ||||
| 271 | + | ||||
| 272 | + | ||||
| 273 | +@router.post("/favoris/{uid:path}") | ||||
| 274 | +def toggle_favori(uid: str, request: Request): | ||||
| 275 | + """Ajoute/retire un resto des favoris. Retourne {favori: bool}.""" | ||||
| 276 | + user = current_user(request) | ||||
| 277 | + if user is None: | ||||
| 278 | + raise HTTPException(401, "non connecté") | ||||
| 279 | + con = db.connect() | ||||
| 280 | + if con.execute("SELECT 1 FROM restaurants WHERE uid=?", (uid,)).fetchone() is None: | ||||
| 281 | + con.close() | ||||
| 282 | + raise HTTPException(404, "resto introuvable") | ||||
| 283 | + existing = con.execute( | ||||
| 284 | + "SELECT 1 FROM favorites WHERE user_id=? AND uid=?", | ||||
| 285 | + (user["uid"], uid)).fetchone() | ||||
| 286 | + if existing: | ||||
| 287 | + con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?", | ||||
| 288 | + (user["uid"], uid)) | ||||
| 289 | + fav = False | ||||
| 290 | + else: | ||||
| 291 | + con.execute("INSERT INTO favorites (user_id, uid, ts) VALUES (?,?,?)", | ||||
| 292 | + (user["uid"], uid, time.time())) | ||||
| 293 | + fav = True | ||||
| 294 | + con.commit() | ||||
| 295 | + con.close() | ||||
| 296 | + return {"favori": fav} | ||||
added
restoka/connectors/siteresto.py
+362 −0
@@ -0,0 +1,362 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/connectors/siteresto.py | ||||
| 4 | +# Desc: Connecteur « sites web des restos » (Palier 3) — capte les MENUS | ||||
| 5 | +# MAISON des restaurants découverts par OSM qui publient un site web. | ||||
| 6 | +# | ||||
| 7 | +# Pipeline par site (budget par passage, incrémental via detail_cache) : | ||||
| 8 | +# 1. GET la page d'accueil (requests direct, UA RestoKaBot). | ||||
| 9 | +# 2. DÉTECTION DE PLATEFORME (CLAUDE.md §9 P3) : si le site embarque | ||||
| 10 | +# UEAT (order.ueat.io/integration/<GUID> ou *.order-online.ai), | ||||
| 11 | +# le GUID est consigné dans data/ueat-discovered.json — le | ||||
| 12 | +# connecteur UEAT (Palier 1, menu structuré parfait) le couvrira | ||||
| 13 | +# au prochain passage. Aucune fiche émise ici. | ||||
| 14 | +# 3. Sinon : trouver le lien « menu/carte », récupérer la page | ||||
| 15 | +# (Firecrawl en secours pour les sites JS, budget limité) ou le | ||||
| 16 | +# PDF du menu. | ||||
| 17 | +# 4. Structuration par Claude Haiku 4.5 (restoka/menullm.py) — | ||||
| 18 | +# sorties structurées JSON + validation STRICTE des prix. | ||||
| 19 | +# 5. Émettre une fiche `site-resto` clonée de la fiche OSM avec le | ||||
| 20 | +# menu ; la déduplication masque la fiche OSM derrière elle. | ||||
| 21 | +# | ||||
| 22 | +# Contexte de prix produit : DINE-IN (menu affiché par le resto | ||||
| 23 | +# lui-même — la référence idéale, §6.2). price_source: site-resto. | ||||
| 24 | +# Les fiches déjà captées sont RÉÉMISES depuis la base à chaque | ||||
| 25 | +# passage (délai de grâce) et re-crawlées après REFRESH_DAYS. | ||||
| 26 | +# ============================================================================== | ||||
| 27 | +from __future__ import annotations | ||||
| 28 | + | ||||
| 29 | +import datetime | ||||
| 30 | +import json | ||||
| 31 | +import os | ||||
| 32 | +import re | ||||
| 33 | +import sys | ||||
| 34 | +import urllib.parse | ||||
| 35 | +from pathlib import Path | ||||
| 36 | + | ||||
| 37 | +from ..schema import Restaurant | ||||
| 38 | +from .base import BaseConnector | ||||
| 39 | + | ||||
| 40 | +DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" | ||||
| 41 | +UEAT_DISCOVERED = DATA_DIR / "ueat-discovered.json" | ||||
| 42 | + | ||||
| 43 | +# Budgets par passage (incrémental : la couverture grandit à chaque sync) | ||||
| 44 | +MAX_SITES_PER_RUN = int(os.environ.get("SITE_RESTO_BUDGET", "250")) | ||||
| 45 | +MAX_FIRECRAWL_PER_RUN = int(os.environ.get("SITE_RESTO_FIRECRAWL", "40")) | ||||
| 46 | +WORKERS = int(os.environ.get("SITE_RESTO_WORKERS", "6")) # crawls parallèles | ||||
| 47 | +REFRESH_DAYS = 30 # re-crawl des menus captés (cadence §14) | ||||
| 48 | + | ||||
| 49 | +# verrou d'écriture du fichier des intégrations UEAT découvertes (workers //) | ||||
| 50 | +_UEAT_FILE_LOCK = __import__("threading").Lock() | ||||
| 51 | + | ||||
| 52 | +_UEAT_RE = re.compile( | ||||
| 53 | + r"(?:order\.ueat\.io/integration/|order-online\.ai.{0,200}?integration/)" | ||||
| 54 | + r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.I) | ||||
| 55 | +_ORDER_ONLINE_RE = re.compile(r"https?://([a-z0-9-]+)\.order-online\.ai", re.I) | ||||
| 56 | +_MENU_WORD_RE = re.compile(r"menu|carte|nos-plats|nos_plats|food", re.I) | ||||
| 57 | +_PRICE_HINT_RE = re.compile(r"\d{1,3}[.,]\d{2}\s*\$|\$\s*\d{1,3}[.,]\d{2}|\d{1,3}\s*\$") | ||||
| 58 | + | ||||
| 59 | + | ||||
| 60 | +def _domain(url: str) -> str: | ||||
| 61 | + try: | ||||
| 62 | + return urllib.parse.urlparse(url).netloc.lower().removeprefix("www.") | ||||
| 63 | + except ValueError: | ||||
| 64 | + return "" | ||||
| 65 | + | ||||
| 66 | + | ||||
| 67 | +class SiteRestoConnector(BaseConnector): | ||||
| 68 | + source_id = "site-resto" | ||||
| 69 | + request_delay = 1.0 | ||||
| 70 | + timeout = 20 | ||||
| 71 | + use_detail_cache = False # cache géré à la main (statuts par domaine) | ||||
| 72 | + | ||||
| 73 | + # -- accès base ------------------------------------------------------------- | ||||
| 74 | + def _con(self): | ||||
| 75 | + from .. import db | ||||
| 76 | + if self._detail_con is None: | ||||
| 77 | + self._detail_con = db.connect() | ||||
| 78 | + return self._detail_con | ||||
| 79 | + | ||||
| 80 | + def _get_status(self, domain: str) -> tuple[dict | None, float]: | ||||
| 81 | + row = self._con().execute( | ||||
| 82 | + "SELECT payload, fetched_at FROM detail_cache" | ||||
| 83 | + " WHERE source=? AND external_id=?", | ||||
| 84 | + (self.source_id, domain)).fetchone() | ||||
| 85 | + if row is None: | ||||
| 86 | + return None, 0.0 | ||||
| 87 | + try: | ||||
| 88 | + return json.loads(row["payload"] or "{}"), row["fetched_at"] or 0.0 | ||||
| 89 | + except ValueError: | ||||
| 90 | + return None, 0.0 | ||||
| 91 | + | ||||
| 92 | + def _set_status(self, domain: str, payload: dict) -> None: | ||||
| 93 | + from .. import db | ||||
| 94 | + db.put_cached_detail(self._con(), self.source_id, domain, "v1", payload) | ||||
| 95 | + | ||||
| 96 | + # -- fetch HTML --------------------------------------------------------------- | ||||
| 97 | + def _get_html(self, url: str) -> str: | ||||
| 98 | + try: | ||||
| 99 | + resp = self.get(url, allow_redirects=True) | ||||
| 100 | + except Exception: | ||||
| 101 | + return "" | ||||
| 102 | + ctype = resp.headers.get("content-type", "") | ||||
| 103 | + if "pdf" in ctype: | ||||
| 104 | + return "" | ||||
| 105 | + try: | ||||
| 106 | + return resp.text[:800_000] | ||||
| 107 | + except Exception: | ||||
| 108 | + return "" | ||||
| 109 | + | ||||
| 110 | + def _get_pdf(self, url: str) -> bytes: | ||||
| 111 | + try: | ||||
| 112 | + resp = self.get(url, allow_redirects=True) | ||||
| 113 | + if len(resp.content) <= 8_000_000: | ||||
| 114 | + return resp.content | ||||
| 115 | + except Exception: | ||||
| 116 | + pass | ||||
| 117 | + return b"" | ||||
| 118 | + | ||||
| 119 | + @staticmethod | ||||
| 120 | + def _html_text(html: str) -> str: | ||||
| 121 | + from bs4 import BeautifulSoup | ||||
| 122 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 123 | + for tag in soup(["script", "style", "nav", "footer", "noscript", | ||||
| 124 | + "header", "svg", "form"]): | ||||
| 125 | + tag.decompose() | ||||
| 126 | + return re.sub(r"\n{3,}", "\n\n", soup.get_text("\n", strip=True)) | ||||
| 127 | + | ||||
| 128 | + def _menu_links(self, html: str, base_url: str) -> tuple[list[str], list[str]]: | ||||
| 129 | + """(pages menu HTML, PDF de menu) trouvés sur la page d'accueil.""" | ||||
| 130 | + from bs4 import BeautifulSoup | ||||
| 131 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 132 | + pages, pdfs = [], [] | ||||
| 133 | + base_dom = _domain(base_url) | ||||
| 134 | + for a in soup.find_all("a", href=True): | ||||
| 135 | + href = a["href"].strip() | ||||
| 136 | + label = f"{href} {a.get_text(' ', strip=True)}" | ||||
| 137 | + if not _MENU_WORD_RE.search(label): | ||||
| 138 | + continue | ||||
| 139 | + full = urllib.parse.urljoin(base_url, href) | ||||
| 140 | + if full.lower().split("?")[0].endswith(".pdf"): | ||||
| 141 | + if full not in pdfs: | ||||
| 142 | + pdfs.append(full) | ||||
| 143 | + elif _domain(full) == base_dom and full not in pages: | ||||
| 144 | + pages.append(full) | ||||
| 145 | + return pages[:2], pdfs[:2] | ||||
| 146 | + | ||||
| 147 | + # -- traitement d'un resto ------------------------------------------------------- | ||||
| 148 | + def _discover_ueat(self, html: str, final_url: str) -> str | None: | ||||
| 149 | + """GUID d'intégration UEAT si le site embarque la plateforme.""" | ||||
| 150 | + m = _UEAT_RE.search(html) | ||||
| 151 | + if m: | ||||
| 152 | + return m.group(1) | ||||
| 153 | + m = _ORDER_ONLINE_RE.search(html) | ||||
| 154 | + if m: # lien vers la page de commande marque blanche : suivre pour le GUID | ||||
| 155 | + sub = self._get_html(f"https://{m.group(1)}.order-online.ai/") | ||||
| 156 | + m2 = _UEAT_RE.search(sub) | ||||
| 157 | + if m2: | ||||
| 158 | + return m2.group(1) | ||||
| 159 | + return None | ||||
| 160 | + | ||||
| 161 | + def _record_ueat(self, guid: str, name: str, domain: str) -> None: | ||||
| 162 | + with _UEAT_FILE_LOCK: | ||||
| 163 | + self._record_ueat_locked(guid, name, domain) | ||||
| 164 | + | ||||
| 165 | + def _record_ueat_locked(self, guid: str, name: str, domain: str) -> None: | ||||
| 166 | + data = {"integrations": []} | ||||
| 167 | + if UEAT_DISCOVERED.exists(): | ||||
| 168 | + try: | ||||
| 169 | + data = json.loads(UEAT_DISCOVERED.read_text(encoding="utf-8")) | ||||
| 170 | + except ValueError: | ||||
| 171 | + pass | ||||
| 172 | + if any(i.get("key") == guid for i in data["integrations"]): | ||||
| 173 | + return | ||||
| 174 | + data["integrations"].append( | ||||
| 175 | + {"key": guid, "label": f"{name} (découvert via {domain})"}) | ||||
| 176 | + UEAT_DISCOVERED.write_text( | ||||
| 177 | + json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") | ||||
| 178 | + print(f"[resto-ka] site-resto: plateforme UEAT découverte chez {name}" | ||||
| 179 | + f" ({guid}) -> ueat-discovered.json") | ||||
| 180 | + | ||||
| 181 | + def _crawl_menu(self, website: str, firecrawl_budget: list[int]) -> dict | None: | ||||
| 182 | + """Retourne {"sections": [...], "url": <page>} ou None.""" | ||||
| 183 | + from .. import menullm | ||||
| 184 | + home = self._get_html(website) | ||||
| 185 | + if not home: | ||||
| 186 | + return None | ||||
| 187 | + guid = self._discover_ueat(home, website) | ||||
| 188 | + if guid: | ||||
| 189 | + return {"ueat": guid} | ||||
| 190 | + pages, pdfs = self._menu_links(home, website) | ||||
| 191 | + # 1) pages HTML de menu | ||||
| 192 | + for page_url in pages: | ||||
| 193 | + html = self._get_html(page_url) | ||||
| 194 | + text = self._html_text(html) if html else "" | ||||
| 195 | + if len(_PRICE_HINT_RE.findall(text)) < 3 and firecrawl_budget[0] > 0: | ||||
| 196 | + try: # site JS : rendu via Firecrawl (budget limité) | ||||
| 197 | + firecrawl_budget[0] -= 1 | ||||
| 198 | + rendered = self.get_rendered(page_url) | ||||
| 199 | + if rendered: | ||||
| 200 | + text = self._html_text(rendered) | ||||
| 201 | + except Exception: | ||||
| 202 | + pass | ||||
| 203 | + if len(_PRICE_HINT_RE.findall(text)) >= 3: | ||||
| 204 | + menu = menullm.menu_from_text(text) | ||||
| 205 | + if menu: | ||||
| 206 | + return {"sections": menu["sections"], "url": page_url} | ||||
| 207 | + # 2) PDF de menu | ||||
| 208 | + for pdf_url in pdfs: | ||||
| 209 | + pdf = self._get_pdf(pdf_url) | ||||
| 210 | + if pdf[:5] == b"%PDF-": | ||||
| 211 | + menu = menullm.menu_from_pdf(pdf) | ||||
| 212 | + if menu: | ||||
| 213 | + return {"sections": menu["sections"], "url": pdf_url} | ||||
| 214 | + # 3) la page d'accueil elle-même contient parfois le menu | ||||
| 215 | + text = self._html_text(home) | ||||
| 216 | + if len(_PRICE_HINT_RE.findall(text)) >= 8: | ||||
| 217 | + menu = menullm.menu_from_text(text) | ||||
| 218 | + if menu: | ||||
| 219 | + return {"sections": menu["sections"], "url": website} | ||||
| 220 | + return None | ||||
| 221 | + | ||||
| 222 | + # -- reconstruction des fiches déjà captées ------------------------------------------ | ||||
| 223 | + def _row_to_restaurant(self, row, menus_by_uid: dict) -> Restaurant: | ||||
| 224 | + menu = menus_by_uid.get(row["uid"]) | ||||
| 225 | + return Restaurant( | ||||
| 226 | + source=self.source_id, external_id=row["external_id"], | ||||
| 227 | + name=row["name"], url=row["url"], chain=row["chain"], | ||||
| 228 | + cuisines=json.loads(row["cuisines"] or "[]"), | ||||
| 229 | + establishment_type=row["establishment_type"] or "", | ||||
| 230 | + price_range=row["price_range"] or "", | ||||
| 231 | + address=row["address"] or "", city=row["city"] or "", | ||||
| 232 | + region=row["region"] or "", postal_code=row["postal_code"] or "", | ||||
| 233 | + lat=row["lat"], lng=row["lng"], phone=row["phone"] or "", | ||||
| 234 | + website=row["website"] or "", | ||||
| 235 | + hours=json.loads(row["hours"] or "{}"), | ||||
| 236 | + services=json.loads(row["services"] or "[]"), | ||||
| 237 | + dietary_options=json.loads(row["dietary_options"] or "[]"), | ||||
| 238 | + languages=json.loads(row["languages"] or '["fr"]'), | ||||
| 239 | + images=json.loads(row["images"] or "[]"), | ||||
| 240 | + menu=menu, | ||||
| 241 | + ) | ||||
| 242 | + | ||||
| 243 | + def _existing(self) -> list[Restaurant]: | ||||
| 244 | + con = self._con() | ||||
| 245 | + menus_by_uid = {} | ||||
| 246 | + for m in con.execute( | ||||
| 247 | + "SELECT uid, price_context, price_source, currency, captured_at," | ||||
| 248 | + " sections FROM menus WHERE uid LIKE 'site-resto:%'"): | ||||
| 249 | + menus_by_uid[m["uid"]] = { | ||||
| 250 | + "currency": m["currency"] or "CAD", | ||||
| 251 | + "price_context": m["price_context"], | ||||
| 252 | + "price_source": m["price_source"], | ||||
| 253 | + "captured_at": m["captured_at"], | ||||
| 254 | + "sections": json.loads(m["sections"] or "[]"), | ||||
| 255 | + } | ||||
| 256 | + return [self._row_to_restaurant(r, menus_by_uid) for r in con.execute( | ||||
| 257 | + "SELECT * FROM restaurants WHERE source='site-resto'")] | ||||
| 258 | + | ||||
| 259 | + # -- fetch -------------------------------------------------------------------------- | ||||
| 260 | + def fetch(self) -> list[Restaurant]: | ||||
| 261 | + import time | ||||
| 262 | + con = self._con() | ||||
| 263 | + out: dict[str, Restaurant] = {r.uid: r for r in self._existing()} | ||||
| 264 | + | ||||
| 265 | + candidates = con.execute( | ||||
| 266 | + "SELECT * FROM restaurants WHERE source='osm' AND active=1" | ||||
| 267 | + " AND dup_of IS NULL AND website<>''" | ||||
| 268 | + " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" | ||||
| 269 | + " ORDER BY region<>'' DESC, city").fetchall() | ||||
| 270 | + | ||||
| 271 | + firecrawl_budget = [MAX_FIRECRAWL_PER_RUN | ||||
| 272 | + if os.environ.get("FIRECRAWL_API_KEY") else 0] | ||||
| 273 | + stats = {"crawled": 0, "menus": 0, "ueat": 0, "vides": 0} | ||||
| 274 | + now = time.time() | ||||
| 275 | + | ||||
| 276 | + # 1re passe (fil principal) : réémission depuis le cache + sélection | ||||
| 277 | + # des sites à crawler (budget) — aucune E/S réseau ici. | ||||
| 278 | + to_crawl: list[tuple] = [] | ||||
| 279 | + for row in candidates: | ||||
| 280 | + website = row["website"] | ||||
| 281 | + if not website.startswith("http"): | ||||
| 282 | + website = "https://" + website | ||||
| 283 | + domain = _domain(website) | ||||
| 284 | + if not domain: | ||||
| 285 | + continue | ||||
| 286 | + status, fetched_at = self._get_status(domain) | ||||
| 287 | + if status is not None and now - fetched_at < REFRESH_DAYS * 86400: | ||||
| 288 | + # menu déjà capté mais pas encore en base (run interrompu) : | ||||
| 289 | + # réémettre depuis le cache — le connecteur est idempotent | ||||
| 290 | + uid = f"{self.source_id}:{row['external_id']}" | ||||
| 291 | + if (status.get("status") == "menu" and status.get("sections") | ||||
| 292 | + and uid not in out): | ||||
| 293 | + resto = self._row_to_restaurant(row, {}) | ||||
| 294 | + resto.source = self.source_id | ||||
| 295 | + resto.url = status.get("url") or website | ||||
| 296 | + resto.menu = { | ||||
| 297 | + "currency": "CAD", "price_context": "dine-in", | ||||
| 298 | + "price_source": self.source_id, | ||||
| 299 | + "captured_at": status.get("captured_at") or | ||||
| 300 | + datetime.datetime.now(datetime.timezone.utc) | ||||
| 301 | + .strftime("%Y-%m-%dT%H:%M:%SZ"), | ||||
| 302 | + "sections": status["sections"], | ||||
| 303 | + } | ||||
| 304 | + resto.cuisines, resto.price_range, resto.dietary_options = [], "", [] | ||||
| 305 | + out[uid] = resto | ||||
| 306 | + continue # déjà tenté récemment | ||||
| 307 | + if len(to_crawl) < MAX_SITES_PER_RUN: | ||||
| 308 | + to_crawl.append((row, website, domain)) | ||||
| 309 | + | ||||
| 310 | + # 2e passe : crawl + structuration Claude en PARALLÈLE (WORKERS fils). | ||||
| 311 | + # Chaque fil a son propre connecteur (session requests dédiée) et ne | ||||
| 312 | + # touche JAMAIS la base — les écritures restent dans le fil principal. | ||||
| 313 | + from concurrent.futures import ThreadPoolExecutor | ||||
| 314 | + | ||||
| 315 | + def _crawl_one(args): | ||||
| 316 | + row, website, domain = args | ||||
| 317 | + worker = SiteRestoConnector() | ||||
| 318 | + try: | ||||
| 319 | + return row, domain, website, worker._crawl_menu(website, | ||||
| 320 | + firecrawl_budget) | ||||
| 321 | + except Exception as exc: # un site cassé ne bloque pas le lot | ||||
| 322 | + print(f"[resto-ka] site-resto: {domain} erreur: {exc}", | ||||
| 323 | + file=sys.stderr) | ||||
| 324 | + return row, domain, website, None | ||||
| 325 | + | ||||
| 326 | + with ThreadPoolExecutor(max_workers=max(1, WORKERS)) as pool: | ||||
| 327 | + results = pool.map(_crawl_one, to_crawl) | ||||
| 328 | + for row, domain, website, result in results: | ||||
| 329 | + stats["crawled"] += 1 | ||||
| 330 | + if result and result.get("ueat"): | ||||
| 331 | + self._record_ueat(result["ueat"], row["name"], domain) | ||||
| 332 | + self._set_status(domain, {"status": "ueat", | ||||
| 333 | + "guid": result["ueat"]}) | ||||
| 334 | + stats["ueat"] += 1 | ||||
| 335 | + continue | ||||
| 336 | + if not result: | ||||
| 337 | + self._set_status(domain, {"status": "no_menu"}) | ||||
| 338 | + stats["vides"] += 1 | ||||
| 339 | + continue | ||||
| 340 | + captured_at = datetime.datetime.now(datetime.timezone.utc) \ | ||||
| 341 | + .strftime("%Y-%m-%dT%H:%M:%SZ") | ||||
| 342 | + resto = self._row_to_restaurant(row, {}) | ||||
| 343 | + resto.source = self.source_id | ||||
| 344 | + resto.url = result["url"] | ||||
| 345 | + resto.menu = { | ||||
| 346 | + "currency": "CAD", | ||||
| 347 | + "price_context": "dine-in", # menu maison : la référence (§6.2) | ||||
| 348 | + "price_source": self.source_id, | ||||
| 349 | + "captured_at": captured_at, | ||||
| 350 | + "sections": result["sections"], | ||||
| 351 | + } | ||||
| 352 | + resto.cuisines = [] # reclassées avec le texte du menu | ||||
| 353 | + resto.price_range = "" | ||||
| 354 | + resto.dietary_options = [] | ||||
| 355 | + out[resto.uid] = resto | ||||
| 356 | + self._set_status(domain, {"status": "menu", "url": result["url"], | ||||
| 357 | + "captured_at": captured_at, | ||||
| 358 | + "sections": result["sections"]}) | ||||
| 359 | + stats["menus"] += 1 | ||||
| 360 | + | ||||
| 361 | + print(f"[resto-ka] site-resto: {stats} (workers={WORKERS})") | ||||
| 362 | + return list(out.values()) | ||||
modified
restoka/connectors/ueat.py
+14 −2
@@ -262,10 +262,22 @@ class UeatConnector(BaseConnector): | |||||
| 262 | # -- fetch ---------------------------------------------------------------------- | 262 | # -- fetch ---------------------------------------------------------------------- | ||
| 263 | def _integrations(self) -> list[dict]: | 263 | def _integrations(self) -> list[dict]: | ||
| 264 | registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) | 264 | registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) | ||
| 265 | + out: list[dict] = [] | ||||
| 265 | for src in registry.get("sources", []): | 266 | for src in registry.get("sources", []): | ||
| 266 | if src.get("id") == self.source_id: | 267 | if src.get("id") == self.source_id: | ||
| 267 | − return src.get("integrations") or [] | 268 | + out.extend(src.get("integrations") or []) | ||
| 268 | − return [] | 269 | + # intégrations découvertes automatiquement par le connecteur site-resto | ||
| 270 | + # (sites de restos qui embarquent le widget UEAT) — voir siteresto.py | ||||
| 271 | + discovered = SOURCES_PATH.parent / "ueat-discovered.json" | ||||
| 272 | + if discovered.exists(): | ||||
| 273 | + try: | ||||
| 274 | + extra = json.loads(discovered.read_text(encoding="utf-8")) | ||||
| 275 | + known = {i.get("key") for i in out} | ||||
| 276 | + out.extend(i for i in extra.get("integrations", []) | ||||
| 277 | + if i.get("key") not in known) | ||||
| 278 | + except ValueError: | ||||
| 279 | + pass | ||||
| 280 | + return out | ||||
| 269 | 281 | ||||
| 270 | def fetch(self) -> list[Restaurant]: | 282 | def fetch(self) -> list[Restaurant]: | ||
| 271 | out: dict[str, Restaurant] = {} | 283 | out: dict[str, Restaurant] = {} | ||
modified
restoka/db.py
+19 −0
@@ -130,6 +130,25 @@ CREATE TABLE IF NOT EXISTS geocode_cache ( | |||||
| 130 | failed INTEGER DEFAULT 0, | 130 | failed INTEGER DEFAULT 0, | ||
| 131 | ts REAL | 131 | ts REAL | ||
| 132 | ); | 132 | ); | ||
| 133 | + | ||||
| 134 | +-- Comptes membres — connexion « Se connecter avec KA » (hub groupe-ka.com). | ||||
| 135 | +CREATE TABLE IF NOT EXISTS users ( | ||||
| 136 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||
| 137 | + hub_sub TEXT UNIQUE, -- identifiant stable du hub (« ka:<sub> ») | ||||
| 138 | + ka_id TEXT UNIQUE, -- identifiant membre « ka-0123456789 » | ||||
| 139 | + email TEXT, | ||||
| 140 | + name TEXT, | ||||
| 141 | + picture TEXT, | ||||
| 142 | + created_at REAL, | ||||
| 143 | + last_login REAL | ||||
| 144 | +); | ||||
| 145 | + | ||||
| 146 | +CREATE TABLE IF NOT EXISTS favorites ( | ||||
| 147 | + user_id INTEGER NOT NULL, | ||||
| 148 | + uid TEXT NOT NULL, -- restaurants.uid | ||||
| 149 | + ts REAL, | ||||
| 150 | + PRIMARY KEY (user_id, uid) | ||||
| 151 | +); | ||||
| 133 | """ | 152 | """ | ||
| 134 | 153 | ||||
| 135 | 154 | ||||
added
restoka/hubprofile.py
+76 −0
@@ -0,0 +1,76 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/hubprofile.py | ||||
| 4 | +# Desc: Profil membre lu depuis le HUB Groupe KA (groupe-ka.com) — le hub est | ||||
| 5 | +# LA source de vérité du profil (l'édition se fait sur | ||||
| 6 | +# groupe-ka.com/compte, Resto·Ka ne fait qu'afficher). | ||||
| 7 | +# GET {hub}/api/sso/profile?client_id=resto-ka&ka_id=…&ts=…&sig=… | ||||
| 8 | +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "resto-ka.<ka_id>.<ts>") en hex. | ||||
| 9 | +# Cache mémoire 60 s ; None sur toute erreur. Calqué louka/hubprofile.py. | ||||
| 10 | +# ============================================================================== | ||||
| 11 | +from __future__ import annotations | ||||
| 12 | + | ||||
| 13 | +import hashlib | ||||
| 14 | +import hmac | ||||
| 15 | +import os | ||||
| 16 | +import threading | ||||
| 17 | +import time | ||||
| 18 | +from datetime import datetime, timezone | ||||
| 19 | + | ||||
| 20 | +import requests | ||||
| 21 | + | ||||
| 22 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | ||||
| 23 | +CLIENT_ID = "resto-ka" | ||||
| 24 | +CACHE_TTL = 60 | ||||
| 25 | +TIMEOUT = 5 | ||||
| 26 | + | ||||
| 27 | +_cache: dict[str, tuple[float, dict | None]] = {} | ||||
| 28 | +_lock = threading.Lock() | ||||
| 29 | + | ||||
| 30 | + | ||||
| 31 | +def fetch_hub_profile(ka_id: str) -> dict | None: | ||||
| 32 | + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable).""" | ||||
| 33 | + secret = os.environ.get("KA_SSO_SECRET") | ||||
| 34 | + if not secret or not ka_id: | ||||
| 35 | + return None | ||||
| 36 | + now = time.time() | ||||
| 37 | + with _lock: | ||||
| 38 | + hit = _cache.get(ka_id) | ||||
| 39 | + if hit and now - hit[0] < CACHE_TTL: | ||||
| 40 | + return hit[1] | ||||
| 41 | + data: dict | None = None | ||||
| 42 | + try: | ||||
| 43 | + ts = int(now) | ||||
| 44 | + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | ||||
| 45 | + hashlib.sha256).hexdigest() | ||||
| 46 | + r = requests.get( | ||||
| 47 | + f"{KA_HUB_URL}/api/sso/profile", | ||||
| 48 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | ||||
| 49 | + "ts": ts, "sig": sig}, | ||||
| 50 | + timeout=TIMEOUT) | ||||
| 51 | + if r.status_code == 200: | ||||
| 52 | + data = r.json() | ||||
| 53 | + if not isinstance(data, dict): | ||||
| 54 | + return None | ||||
| 55 | + elif r.status_code != 404: | ||||
| 56 | + return None # erreur transitoire : pas de cache | ||||
| 57 | + except Exception: | ||||
| 58 | + return None | ||||
| 59 | + with _lock: | ||||
| 60 | + _cache[ka_id] = (now, data) | ||||
| 61 | + return data | ||||
| 62 | + | ||||
| 63 | + | ||||
| 64 | +def to_epoch(v) -> float | None: | ||||
| 65 | + """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None.""" | ||||
| 66 | + if isinstance(v, (int, float)): | ||||
| 67 | + return float(v) | ||||
| 68 | + if isinstance(v, str) and v: | ||||
| 69 | + try: | ||||
| 70 | + dt = datetime.fromisoformat(v.replace("Z", "+00:00")) | ||||
| 71 | + if dt.tzinfo is None: | ||||
| 72 | + dt = dt.replace(tzinfo=timezone.utc) | ||||
| 73 | + return dt.timestamp() | ||||
| 74 | + except ValueError: | ||||
| 75 | + return None | ||||
| 76 | + return None | ||||
added
restoka/menullm.py
+175 −0
Large diff (175 lines) is not loaded by default to keep the page fast.Load diff
modified
restoka/web.py
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_menullm.py
+53 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff