SPB Git

spb/resto-ka

Public

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

17.5 KB · 424 lines python
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/web.py4# Desc:   API FastAPI (JSON) + service du frontend React (frontend/dist).5#         Recherche des restaurants (région, ville, cuisine, diète, prix, texte),6#         fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >7#         delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.8# ==============================================================================9from __future__ import annotations1011import json12import statistics13import threading14from pathlib import Path1516from fastapi import BackgroundTasks, FastAPI, HTTPException, Query17from fastapi.middleware.cors import CORSMiddleware18from fastapi.middleware.gzip import GZipMiddleware19from fastapi.responses import FileResponse20from fastapi.staticfiles import StaticFiles2122from . import db, ingest23from .normalize import PRICE_CONTEXTS24from .regions import REGIONS2526ROOT = Path(__file__).resolve().parent.parent27SOURCES_PATH = ROOT / "data" / "sources.json"28FRONTEND_DIST = ROOT / "frontend" / "dist"2930# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)31_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}3233app = FastAPI(title="Resto-Ka API", version="1.0",34              description="Agrégateur des restaurants du Québec — menus & prix")35app.add_middleware(CORSMiddleware, allow_origins=["*"],36                   allow_methods=["*"], allow_headers=["*"])37app.add_middleware(GZipMiddleware, minimum_size=1000)3839_sync_lock = threading.Lock()4041# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py42from . import auth  # noqa: E402  (import tardif : évite le cycle web<->auth)43app.include_router(auth.router)444546def _row_to_dict(row) -> dict:47    d = dict(row)48    for col in ("cuisines", "services", "dietary_options", "languages", "images"):49        d[col] = json.loads(d.get(col) or "[]")50    d["hours"] = json.loads(d.get("hours") or "{}")51    if d.get("dup_sources"):52        try:53            d["dup_sources"] = json.loads(d["dup_sources"])54        except (ValueError, TypeError):55            d["dup_sources"] = []56    return d575859def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:60    """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes."""61    if not uids:62        return {}63    qmarks = ",".join("?" * len(uids))64    best: dict[str, dict] = {}65    for r in con.execute(66            f"SELECT uid, price_context, price_source, captured_at, item_count,"67            f" sections FROM menus WHERE uid IN ({qmarks})", uids):68        cur = best.get(r["uid"])69        if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \70                _CTX_ORDER.get(r["price_context"], 9):71            continue72        sections = json.loads(r["sections"] or "[]")73        prices = [it.get("price")74                  for sec in sections75                  for it in sec.get("items") or []76                  if isinstance(it.get("price"), (int, float)) and it["price"] > 0]77        image = next((it["image"] for sec in sections78                      for it in sec.get("items") or [] if it.get("image")),79                     next((sec["image"] for sec in sections80                           if sec.get("image")), None))81        best[r["uid"]] = {82            "image": image,83            "price_context": r["price_context"],84            "price_source": r["price_source"],85            "captured_at": r["captured_at"],86            "item_count": r["item_count"],87            "price_min": min(prices) if prices else None,88            "price_median": round(statistics.median(prices), 2) if prices else None,89        }90    return best919293@app.get("/healthz")94def healthz():95    return {"status": "ok"}969798@app.get("/api/restaurants")99def list_restaurants(100    region: str | None = None,101    city: str | None = None,102    cuisine: str | None = None,103    establishment_type: str | None = None,104    diet: str | None = None,             # vegan, sans-gluten…105    service: str | None = None,          # takeout, delivery, salle106    price_range: str | None = None,      # $ à $$$$107    chain: str | None = None,108    source: str | None = None,109    q: str | None = None,110    has_menu: int | None = None,         # 1 = seulement les restos avec menu111    active: int = 1,112    sort: str = "menu",                  # menu | name | price | recent113    limit: int = Query(60, le=500),114    offset: int = 0,115):116    con = db.connect()117    sql = "SELECT * FROM restaurants WHERE dup_of IS NULL"   # doublons masqués118    args: list = []119    if active in (0, 1):120        sql += " AND active=?"; args.append(active)121    if region:122        sql += " AND region=?"; args.append(region)123    if city:124        sql += " AND city=?"; args.append(city)125    if cuisine:126        sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%')127    if establishment_type:128        sql += " AND establishment_type=?"; args.append(establishment_type)129    if diet:130        sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%')131    if service:132        sql += " AND services LIKE ?"; args.append(f'%"{service}"%')133    if price_range:134        sql += " AND price_range=?"; args.append(price_range)135    if chain:136        sql += " AND chain=?"; args.append(chain)137    if source:138        sql += " AND source=?"; args.append(source)139    if q:140        sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"141        args += [f"%{q}%"] * 4142    if has_menu == 1:143        sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"144    elif has_menu == 0:145        sql += " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"146    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]147    if sort == "recent":148        sql += " ORDER BY updated_at DESC"149    elif sort == "price":150        sql += " ORDER BY price_range='' , price_range ASC, name ASC"151    elif sort == "name":152        sql += " ORDER BY name COLLATE NOCASE ASC"153    else:   # défaut « menu » : les restos avec menu complet d'abord154        sql += (" ORDER BY NOT EXISTS (SELECT 1 FROM menus m"155                " WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC")156    sql += " LIMIT ? OFFSET ?"157    args += [limit, offset]158    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]159    menus = _menu_summaries(con, [r["uid"] for r in rows])160    for r in rows:161        r["menu_summary"] = menus.get(r["uid"])162    con.close()163    return {"total": total, "count": len(rows), "restaurants": rows}164165166@app.get("/api/dishes")167def search_dishes(168    q: str = Query(..., min_length=2),169    region: str | None = None,170    city: str | None = None,171    cuisine: str | None = None,172    price_max: float | None = None,173    limit: int = Query(30, le=100),174    offset: int = 0,175):176    """Recherche PAR PLAT dans tous les menus (nom + description).177178    Les succursales d'une même chaîne partagent le même menu : les résultats179    sont regroupés par (marque, plat, prix, contexte) avec le nombre180    d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.181    """182    con = db.connect()183    needle = q.strip().lower()184    sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at,"185           " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines"186           " FROM menus m JOIN restaurants r ON r.uid = m.uid"187           " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?")188    args: list = [f"%{needle}%"]189    if region:190        sql += " AND r.region=?"; args.append(region)191    if city:192        sql += " AND r.city=?"; args.append(city)193    if cuisine:194        sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%')195196    groups: dict[tuple, dict] = {}197    for row in con.execute(sql, args):198        try:199            sections = json.loads(row["sections"] or "[]")200        except ValueError:201            continue202        brand = row["chain"] or row["rname"]203        for sec in sections:204            for it in sec.get("items") or []:205                hay = f"{it.get('name', '')} {it.get('description', '')}".lower()206                if needle not in hay:207                    continue208                price = it.get("price")209                if price_max is not None and (price is None or price > price_max):210                    continue211                key = (brand, it.get("name"), price, row["price_context"])212                g = groups.get(key)213                if g is None:214                    groups[key] = {215                        "item": it.get("name"),216                        "description": it.get("description") or "",217                        "price": price,218                        "currency": it.get("currency", "CAD"),219                        "image": it.get("image"),220                        "tags": it.get("tags") or [],221                        "section": sec.get("name"),222                        "price_context": row["price_context"],223                        "price_source": row["price_source"],224                        "captured_at": row["captured_at"],225                        "brand": brand,226                        "restaurant": row["rname"],227                        "uid": row["uid"],228                        "city": row["city"],229                        "region": row["region"],230                        "locations": 1,231                    }232                else:233                    g["locations"] += 1234    con.close()235    results = sorted(groups.values(),236                     key=lambda g: (g["price"] is None, g["price"] or 0))237    total = len(results)238    return {"total": total, "count": min(limit, max(0, total - offset)),239            "dishes": results[offset:offset + limit]}240241242@app.get("/api/restaurants/{uid:path}/prices")243def price_history(uid: str):244    """Historique des prix par item (série temporelle, détection des hausses)."""245    con = db.connect()246    rows = [dict(r) for r in con.execute(247        "SELECT price_context, item_key, ts, price FROM item_price_log"248        " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))]249    con.close()250    return {"uid": uid, "prices": rows}251252253@app.get("/api/restaurants/{uid:path}")254def get_restaurant(uid: str):255    con = db.connect()256    row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone()257    if row is None:258        con.close()259        raise HTTPException(404, "Restaurant introuvable")260    d = _row_to_dict(row)261    # tous les contextes de prix disponibles, meilleur en premier — un menu262    # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)263    menus = []264    for m in con.execute(265            "SELECT price_context, price_source, currency, captured_at,"266            " item_count, sections FROM menus WHERE uid=?", (uid,)):267        menus.append({268            "price_context": m["price_context"],269            "price_source": m["price_source"],270            "currency": m["currency"],271            "captured_at": m["captured_at"],272            "item_count": m["item_count"],273            "sections": json.loads(m["sections"] or "[]"),274        })275    menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9))276    d["menus"] = menus277    # items dont le prix a changé récemment (crédibilité, §12.2)278    d["recent_price_changes"] = [dict(r) for r in con.execute(279        "SELECT price_context, item_key, ts, price FROM item_price_log"280        " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)"281        " GROUP BY item_key HAVING COUNT(*) > 1"282        " ORDER BY ts DESC LIMIT 20", (uid, uid))]283    con.close()284    return d285286287@app.get("/api/facets")288def facets(region: str | None = None):289    """Valeurs distinctes pour construire les filtres du frontend."""290    con = db.connect()291    city_sql = ("SELECT DISTINCT city FROM restaurants"292                " WHERE active=1 AND dup_of IS NULL AND city<>''")293    city_args: list = []294    if region:295        city_sql += " AND region=?"296        city_args.append(region)297    cuisines: dict[str, int] = {}298    diets: dict[str, int] = {}299    for r in con.execute("SELECT cuisines, dietary_options FROM restaurants"300                         " WHERE active=1 AND dup_of IS NULL"):301        for c in json.loads(r["cuisines"] or "[]"):302            cuisines[c] = cuisines.get(c, 0) + 1303        for dd in json.loads(r["dietary_options"] or "[]"):304            diets[dd] = diets.get(dd, 0) + 1305    out = {306        "regions": [dict(r) for r in con.execute(307            "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"308            " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")],309        "all_regions": REGIONS,310        "cities": [r["city"] for r in con.execute(311            city_sql + " ORDER BY city", city_args)],312        "cuisines": [{"cuisine": c, "n": n} for c, n in313                     sorted(cuisines.items(), key=lambda kv: -kv[1])],314        "diets": [{"diet": d, "n": n} for d, n in315                  sorted(diets.items(), key=lambda kv: -kv[1])],316        "establishment_types": [dict(r) for r in con.execute(317            "SELECT establishment_type t, COUNT(*) n FROM restaurants"318            " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''"319            " GROUP BY establishment_type ORDER BY n DESC")],320        "chains": [r["chain"] for r in con.execute(321            "SELECT DISTINCT chain FROM restaurants WHERE active=1"322            " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")],323        "sources": [dict(r) for r in con.execute(324            "SELECT source, COUNT(*) n FROM restaurants WHERE active=1"325            " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],326    }327    con.close()328    return out329330331@app.get("/api/sources")332def sources():333    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]334    con = db.connect()335    counts = {r["source"]: r["n"] for r in con.execute(336        "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")}337    items = {r["source"]: r["n"] for r in con.execute(338        "SELECT r.source, SUM(m.item_count) n FROM menus m"339        " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")}340    last = {r["source"]: r["ts"] for r in con.execute(341        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}342    con.close()343    for s in registry:344        s["active_restaurants"] = counts.get(s["id"], 0)345        s["menu_items"] = items.get(s["id"], 0)346        s["last_sync"] = last.get(s["id"])347        # ne pas exposer les clés d'intégration dans l'API publique348        if "integrations" in s:349            s["integrations"] = [{"label": i.get("label"),350                                  "status": i.get("status", "actif")}351                                 for i in s["integrations"]]352    return {"sources": registry}353354355@app.get("/api/stats")356def stats():357    con = db.connect()358    head = dict(con.execute(359        """SELECT COUNT(*) restaurants,360                  SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))361                      with_menu,362                  COUNT(DISTINCT chain) chains,363                  COUNT(DISTINCT region) regions,364                  COUNT(DISTINCT source) sources365           FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())366    m = dict(con.execute(367        """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items368           FROM menus m JOIN restaurants r ON r.uid=m.uid369           WHERE r.active=1 AND r.dup_of IS NULL""").fetchone())370    by_region = [dict(r) for r in con.execute(371        "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"372        " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")]373    by_context = [dict(r) for r in con.execute(374        "SELECT m.price_context, COUNT(*) n FROM menus m"375        " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1"376        " GROUP BY m.price_context")]377    log = [dict(r) for r in con.execute(378        "SELECT source, ts, found, added, updated, removed, ok, message"379        " FROM sync_log ORDER BY ts DESC LIMIT 20")]380    con.close()381    return {**head, **m, "by_region": by_region, "by_context": by_context,382            "recent_syncs": log}383384385@app.post("/api/sync")386def trigger_sync(background: BackgroundTasks, source: str | None = None):387    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""388    def _job():389        with _sync_lock:390            ingest.run([source] if source else None)391            ingest.enrich()392    background.add_task(_job)393    return {"status": "démarré", "source": source or "toutes"}394395396# --- Frontend React (build Vite) --------------------------------------------397if FRONTEND_DIST.exists():398    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),399              name="assets")400401    @app.middleware("http")402    async def _cache_headers(request, call_next):403        """Bundles hachés immuables ; index.html toujours revalidé."""404        resp = await call_next(request)405        path = request.url.path406        if path.startswith("/assets/"):407            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"408        elif "text/html" in (resp.headers.get("content-type") or ""):409            resp.headers["Cache-Control"] = "no-cache"410        return resp411412    _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris")413414    @app.get("/{full_path:path}")415    def spa(full_path: str):416        target = FRONTEND_DIST / full_path417        if full_path and target.is_file():418            return FileResponse(target)419        known = (full_path == "" or full_path in _CLIENT_PREFIXES420                 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))421        return FileResponse(FRONTEND_DIST / "index.html",422                            status_code=200 if known else 404,423                            headers={"Cache-Control": "no-cache"})424
Files · c9f9e0970a11c1ae39fed1d26753cd00e6448cef
Menu

Theme