SPB Git forge

spb/resto-ka

Public

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

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%

WIP v2 committé depuis le nœud — connexion KA ID (restoka/auth.py, hubprofile), favoris (Favoris.tsx, account.tsx), connecteur siteresto, menullm + tests, MAJ sources/ueat/db/web/frontend

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

18 changed files +1,413 −6

modified data/sources.json +13 −0
@@ -59,6 +59,19 @@
59 59 "cadence": "mensuel",
60 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 76 "id": "sthubert",
64 77 "name": "St-Hubert (commande en ligne)",
added data/ueat-discovered.json +92 −0
@@ -0,0 +1,92 @@
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 +}
\ No newline at end of file
modified frontend/src/App.tsx +71 −3
@@ -6,16 +6,82 @@
6 6 // ==============================================================================
7 7 import { useEffect, useState } from "react";
8 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 14 import {
11 15 IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch,
12 16 } from "./components/Icons";
13 17 import { LogoIcon } from "./components/Logo";
18 +import FavorisPage from "./pages/Favoris";
14 19 import Home from "./pages/Home";
15 20 import RestoPage from "./pages/Resto";
16 21 import SourcesPage from "./pages/Sources";
17 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 85 function Ticker() {
20 86 const [items, setItems] = useState<string[]>([]);
21 87
@@ -86,6 +152,7 @@ function Header() {
86 152 </NavLink>
87 153 ))}
88 154 </nav>
155 + <AccountMenu />
89 156 <button
90 157 className={`menu-btn ${open ? "open" : ""}`}
91 158 aria-expanded={open}
@@ -186,12 +253,13 @@ function MobileTabBar() {
186 253
187 254 export default function App() {
188 255 return (
189 − <>
256 + <AccountProvider>
190 257 <Header />
191 258 <main>
192 259 <Routes>
193 260 <Route path="/" element={<Home />} />
194 261 <Route path="/resto/:uid" element={<RestoPage />} />
262 + <Route path="/favoris" element={<FavorisPage />} />
195 263 <Route path="/stats" element={<StatsPage />} />
196 264 <Route path="/sources" element={<SourcesPage />} />
197 265 <Route
@@ -208,6 +276,6 @@ export default function App() {
208 276 </main>
209 277 <Footer />
210 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 247 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
248 248
249 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 6 // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.
7 7 // ==============================================================================
8 8 import { Link } from "react-router-dom";
9 +import { useAccount } from "../account";
9 10 import {
10 11 CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,
11 12 } from "../api";
@@ -15,6 +16,8 @@ export default function RestoCard({ r }: { r: Restaurant }) {
15 16 const ms = r.menu_summary;
16 17 const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord
17 18 const logo = (r.images ?? [])[0] || null;
19 + const { me, favs, toggleFav } = useAccount();
20 + const fav = favs.has(r.uid);
18 21 return (
19 22 <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card">
20 23 <div className="card-img">
@@ -31,6 +34,16 @@ export default function RestoCard({ r }: { r: Restaurant }) {
31 34 {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}
32 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 47 </div>
35 48 <div className="card-body">
36 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 754 .footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
755 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 822 /* --- Menu déroulant mobile ------------------------------------------------- */
758 823 .menu-btn {
759 824 display: none; position: relative; z-index: 60;
modified requirements.txt +1 −0
@@ -7,3 +7,4 @@ fastapi>=0.110
7 7 uvicorn>=0.29
8 8 requests>=2.31
9 9 beautifulsoup4>=4.12
10 +anthropic>=0.116
modified restoka/__init__.py +11 −0
@@ -3,4 +3,15 @@
3 3 # File: restoka/__init__.py
4 4 # Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix).
5 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 262 # -- fetch ----------------------------------------------------------------------
263 263 def _integrations(self) -> list[dict]:
264 264 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
265 + out: list[dict] = []
265 266 for src in registry.get("sources", []):
266 267 if src.get("id") == self.source_id:
267 − return src.get("integrations") or []
268 − return []
268 + out.extend(src.get("integrations") or [])
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 282 def fetch(self) -> list[Restaurant]:
271 283 out: dict[str, Restaurant] = {}
modified restoka/db.py +19 −0
@@ -130,6 +130,25 @@ CREATE TABLE IF NOT EXISTS geocode_cache (
130 130 failed INTEGER DEFAULT 0,
131 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
@@ -0,0 +1,175 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/menullm.py
4 +# Desc: Structuration des menus « maison » (HTML texte ou PDF) en schéma
5 +# sections → items → prix via Claude Haiku 4.5 (claude-haiku-4-5,
6 +# le modèle le plus économique) avec SORTIES STRUCTURÉES
7 +# (output_config.format = json_schema : JSON garanti valide) et
8 +# VALIDATION STRICTE en aval — un prix mal parsé est pire que pas de
9 +# prix (CLAUDE.md §10). PDF envoyé nativement en bloc `document`.
10 +# ==============================================================================
11 +from __future__ import annotations
12 +
13 +import base64
14 +import json
15 +import os
16 +
17 +MODEL = "claude-haiku-4-5"
18 +MAX_TEXT_CHARS = 14000 # ~4k tokens de texte de menu
19 +MAX_PDF_BYTES = 8_000_000 # PDF > 8 Mo : ignoré
20 +MAX_OUTPUT_TOKENS = 8000
21 +
22 +# Garde-fous qualité (validation aval) — un menu qui ne les passe pas est rejeté
23 +MIN_ITEMS_PRICED = 5 # au moins 5 plats avec prix
24 +MIN_PRICED_RATIO = 0.4 # au moins 40 % des items ont un prix
25 +PRICE_MIN, PRICE_MAX = 0.5, 500.0
26 +
27 +MENU_SCHEMA = {
28 + "type": "object",
29 + "properties": {
30 + "is_menu": {
31 + "type": "boolean",
32 + "description": "true seulement si le contenu est bien un menu de "
33 + "restaurant avec des plats",
34 + },
35 + "sections": {
36 + "type": "array",
37 + "items": {
38 + "type": "object",
39 + "properties": {
40 + "name": {"type": "string"},
41 + "items": {
42 + "type": "array",
43 + "items": {
44 + "type": "object",
45 + "properties": {
46 + "name": {"type": "string"},
47 + "description": {"type": "string"},
48 + "price": {"type": ["number", "null"]},
49 + },
50 + "required": ["name", "price"],
51 + "additionalProperties": False,
52 + },
53 + },
54 + },
55 + "required": ["name", "items"],
56 + "additionalProperties": False,
57 + },
58 + },
59 + },
60 + "required": ["is_menu", "sections"],
61 + "additionalProperties": False,
62 +}
63 +
64 +_SYSTEM = """Tu extrais le menu d'un restaurant québécois vers un JSON structuré.
65 +
66 +Règles STRICTES :
67 +- N'INVENTE JAMAIS un prix. Si le prix d'un plat n'est pas affiché, mets null.
68 +- Les prix sont en dollars canadiens ; convertis « 12,95 » en 12.95.
69 +- Si un plat a plusieurs formats (petit/grand), prends le prix le PLUS BAS.
70 +- Ignore la navigation, les heures d'ouverture, les avis, les textes marketing.
71 +- Regroupe les plats par section du menu (Entrées, Pâtes, Desserts…).
72 +- Si le contenu N'EST PAS un menu de restaurant (page d'accueil, blogue,
73 + page de contact), mets is_menu=false et sections=[].
74 +- Ne traduis pas les noms de plats ; garde-les tels quels."""
75 +
76 +
77 +def _client():
78 + import anthropic
79 + key = os.environ.get("ANTHROPIC_API_KEY")
80 + if not key:
81 + raise RuntimeError("ANTHROPIC_API_KEY manquant (voir .env)")
82 + return anthropic.Anthropic(api_key=key)
83 +
84 +
85 +def _extract(content_blocks: list) -> dict | None:
86 + """Appel Haiku avec sortie structurée ; retourne le dict validé ou None."""
87 + import anthropic
88 + try:
89 + response = _client().messages.create(
90 + model=MODEL,
91 + max_tokens=MAX_OUTPUT_TOKENS,
92 + system=_SYSTEM,
93 + messages=[{"role": "user", "content": content_blocks}],
94 + output_config={"format": {"type": "json_schema",
95 + "schema": MENU_SCHEMA}},
96 + )
97 + except anthropic.APIStatusError as exc:
98 + print(f"[resto-ka] menullm: erreur API {exc.status_code}")
99 + return None
100 + except anthropic.APIConnectionError:
101 + return None
102 + if response.stop_reason == "refusal":
103 + return None
104 + if response.stop_reason == "max_tokens":
105 + return None # menu tronqué : mieux vaut rien
106 + text = next((b.text for b in response.content if b.type == "text"), "")
107 + try:
108 + return json.loads(text)
109 + except ValueError:
110 + return None
111 +
112 +
113 +def menu_from_text(text: str) -> dict | None:
114 + """Structure le TEXTE brut d'une page menu. None si pas de menu fiable."""
115 + text = (text or "").strip()
116 + if len(text) < 200:
117 + return None
118 + blocks = [{"type": "text",
119 + "text": f"Voici le contenu textuel d'une page web de restaurant."
120 + f" Extrais le menu.\n\n<page>\n{text[:MAX_TEXT_CHARS]}\n</page>"}]
121 + return validate(_extract(blocks))
122 +
123 +
124 +def menu_from_pdf(pdf_bytes: bytes) -> dict | None:
125 + """Structure un menu PDF (envoyé nativement à Claude). None si rejeté."""
126 + if not pdf_bytes or len(pdf_bytes) > MAX_PDF_BYTES:
127 + return None
128 + data = base64.standard_b64encode(pdf_bytes).decode()
129 + blocks = [
130 + {"type": "document",
131 + "source": {"type": "base64", "media_type": "application/pdf",
132 + "data": data}},
133 + {"type": "text", "text": "Ce PDF est le menu d'un restaurant. Extrais-le."},
134 + ]
135 + return validate(_extract(blocks))
136 +
137 +
138 +def validate(raw: dict | None) -> dict | None:
139 + """Validation aval STRICTE : structure, volume, plausibilité des prix.
140 +
141 + Retourne {"sections": [...]} prêt pour le schéma Resto·Ka, ou None si le
142 + menu ne passe pas la barre de qualité (jamais de menu douteux en base).
143 + """
144 + if not raw or not raw.get("is_menu"):
145 + return None
146 + sections_out = []
147 + total = priced = 0
148 + for sec in raw.get("sections") or []:
149 + name = (sec.get("name") or "").strip()
150 + items_out = []
151 + for it in sec.get("items") or []:
152 + iname = (it.get("name") or "").strip()
153 + if not iname or len(iname) > 120:
154 + continue
155 + price = it.get("price")
156 + if isinstance(price, (int, float)):
157 + if not (PRICE_MIN <= price <= PRICE_MAX):
158 + price = None # prix implausible -> pas de prix
159 + else:
160 + price = round(float(price), 2)
161 + else:
162 + price = None
163 + total += 1
164 + if price is not None:
165 + priced += 1
166 + item = {"name": iname, "price": price, "currency": "CAD", "tags": []}
167 + desc = (it.get("description") or "").strip()
168 + if desc:
169 + item["description"] = desc[:400]
170 + items_out.append(item)
171 + if name and items_out:
172 + sections_out.append({"name": name[:80], "items": items_out})
173 + if priced < MIN_ITEMS_PRICED or total == 0 or priced / total < MIN_PRICED_RATIO:
174 + return None
175 + return {"sections": sections_out}
modified restoka/web.py +5 −1
@@ -38,6 +38,10 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
38 38
39 39 _sync_lock = threading.Lock()
40 40
41 +# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py
42 +from . import auth # noqa: E402 (import tardif : évite le cycle web<->auth)
43 +app.include_router(auth.router)
44 +
41 45
42 46 def _row_to_dict(row) -> dict:
43 47 d = dict(row)
@@ -405,7 +409,7 @@ if FRONTEND_DIST.exists():
405 409 resp.headers["Cache-Control"] = "no-cache"
406 410 return resp
407 411
408 − _CLIENT_PREFIXES = ("resto/", "sources", "stats")
412 + _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris")
409 413
410 414 @app.get("/{full_path:path}")
411 415 def spa(full_path: str):
added tests/test_menullm.py +53 −0
@@ -0,0 +1,53 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_menullm.py
4 +# Desc: Validation aval des menus structurés par LLM — la barre de qualité
5 +# est STRICTE : jamais de menu douteux en base (prix implausibles
6 +# neutralisés, volume minimal, ratio de prix minimal).
7 +# ==============================================================================
8 +from restoka.menullm import validate
9 +
10 +
11 +def _raw(items, is_menu=True):
12 + return {"is_menu": is_menu,
13 + "sections": [{"name": "Plats", "items": items}]}
14 +
15 +
16 +def test_menu_valide():
17 + out = validate(_raw([{"name": f"Plat {i}", "price": 10.0 + i}
18 + for i in range(6)]))
19 + assert out and len(out["sections"][0]["items"]) == 6
20 + assert out["sections"][0]["items"][0]["price"] == 10.0
21 + assert out["sections"][0]["items"][0]["currency"] == "CAD"
22 +
23 +
24 +def test_pas_un_menu():
25 + assert validate(_raw([{"name": "Plat", "price": 10.0}], is_menu=False)) is None
26 + assert validate(None) is None
27 +
28 +
29 +def test_trop_peu_de_prix():
30 + # 4 plats avec prix < minimum de 5 -> rejeté
31 + assert validate(_raw([{"name": f"P{i}", "price": 12.0} for i in range(4)])) is None
32 +
33 +
34 +def test_ratio_de_prix_insuffisant():
35 + items = ([{"name": f"P{i}", "price": 12.0} for i in range(5)]
36 + + [{"name": f"S{i}", "price": None} for i in range(10)])
37 + assert validate(_raw(items)) is None # 5/15 = 33 % < 40 %
38 +
39 +
40 +def test_prix_implausible_neutralise():
41 + items = ([{"name": f"P{i}", "price": 12.0} for i in range(5)]
42 + + [{"name": "Erreur OCR", "price": 12345.0}])
43 + out = validate(_raw(items))
44 + assert out is not None
45 + bad = next(i for i in out["sections"][0]["items"] if i["name"] == "Erreur OCR")
46 + assert bad["price"] is None # jamais un prix aberrant en base
47 +
48 +
49 +def test_nom_trop_long_ecarte():
50 + items = ([{"name": f"P{i}", "price": 12.0} for i in range(5)]
51 + + [{"name": "x" * 300, "price": 9.0}])
52 + out = validate(_raw(items))
53 + assert all(len(i["name"]) <= 120 for i in out["sections"][0]["items"])
54