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>
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 | ||