SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

WIP laptop non committé, sauvegardé lors de la réorganisation du 2026-10-02

laptop committed (Oct 2, 2026) parent ab264a9 Browse files

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
Menu

Theme