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%
25.9 KB · 613 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/seo.py4# Desc:   Référencement — SSR léger, robots.txt, sitemaps. Calqué louka/seo.py.5#6# Principe : pour chaque route publique, le serveur renvoie le MÊME index.html7# que le build Vite, mais avec un <head> unique (title, description, canonical,8# og:, JSON-LD) et le contenu essentiel en HTML DANS <div id="root">. Les9# moteurs de recherche voient une page complète sans exécuter JavaScript ;10# React, en se montant, remplace ce contenu par l'application interactive.11# ==============================================================================12from __future__ import annotations1314import html15import json16import math17import re18import time19import unicodedata20from datetime import date, datetime, timezone21from pathlib import Path22from xml.sax.saxutils import escape as xml_escape2324from fastapi import APIRouter, HTTPException25from fastapi.responses import HTMLResponse, PlainTextResponse, Response2627from . import db28from .normalize import PRICE_CONTEXTS2930router = APIRouter()3132ROOT = Path(__file__).resolve().parent.parent33FRONTEND_DIST = ROOT / "frontend" / "dist"3435BASE_URL = "https://www.resto-ka.com"36SITE_NAME = "Resto-Ka"3738SITEMAP_CHUNK = 100003940# restos « canoniques » : actifs et non-doublons inter-sources41ACTIVE = "active=1 AND dup_of IS NULL"4243# seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues)44MIN_RESTOS_PAGE = 34546_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}4748_CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter",49              "delivery": "prix en livraison"}505152# --- Gabarit (index.html du build Vite) --------------------------------------5354_shell_cache: dict = {"mtime": 0.0, "html": ""}555657def _shell() -> str:58    f = FRONTEND_DIST / "index.html"59    mtime = f.stat().st_mtime60    if mtime != _shell_cache["mtime"]:61        _shell_cache["html"] = f.read_text(encoding="utf-8")62        _shell_cache["mtime"] = mtime63    return _shell_cache["html"]646566def _render(*, title: str, description: str, path: str,67            jsonld: list[dict] | None = None, body: str = "",68            og_image: str | None = None, canonical_path: str | None = None,69            status: int = 200) -> HTMLResponse:70    """index.html du build + head unique + contenu HTML dans #root."""71    canonical = BASE_URL + (canonical_path or path)72    page = _shell()73    page = re.sub(r"<title>.*?</title>",74                  lambda _m: f"<title>{html.escape(title)}</title>", page, count=1, flags=re.S)75    page = re.sub(r'<meta name="description"[^>]*/>',76                  lambda _m: f'<meta name="description" content="{html.escape(description, quote=True)}" />',77                  page, count=1)78    # retire du gabarit statique les meta og:image/twitter (re-injectées ci-dessous)79    page = re.sub(r'\s*<meta (?:property="og:image[^"]*"|name="twitter:(?:card|image)")[^>]*/>', "", page)80    extras = [81        f'<link rel="canonical" href="{canonical}" />',82        f'<link rel="alternate" hreflang="fr-ca" href="{canonical}" />',83        f'<link rel="alternate" hreflang="x-default" href="{canonical}" />',84        f'<meta property="og:site_name" content="{SITE_NAME}" />',85        '<meta property="og:locale" content="fr_CA" />',86        '<meta property="og:type" content="website" />',87        f'<meta property="og:title" content="{html.escape(title, quote=True)}" />',88        f'<meta property="og:description" content="{html.escape(description, quote=True)}" />',89        f'<meta property="og:url" content="{canonical}" />',90        '<meta name="twitter:card" content="summary_large_image" />',91        f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />',92    ]93    img = og_image or (BASE_URL + "/og.png")94    extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />')95    if not og_image:96        extras.append('<meta property="og:image:width" content="1200" />')97        extras.append('<meta property="og:image:height" content="630" />')98    extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />')99    for obj in (jsonld or []):100        blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")101        extras.append(f'<script type="application/ld+json">{blob}</script>')102    page = page.replace("</head>", "  " + "\n  ".join(extras) + "\n</head>", 1)103    if body:104        seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;'105                   'font-family:system-ui,sans-serif;color:#141814">' + body106                   + '<p>Resto-Ka — Un service <a href="https://www.groupe-ka.com">Groupe KA</a></p>'107                   + "</div>")108        page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1)109    return HTMLResponse(page, status_code=status,110                        headers={"Cache-Control": "no-cache"})111112113def _e(t) -> str:114    return html.escape(str(t or ""))115116117def _fmt_n(n: int) -> str:118    return f"{n:,}".replace(",", " ")119120121def _fmt_price(p) -> str:122    if p is None:123        return ""124    s = f"{p:.2f}".rstrip("0").rstrip(".")125    return s.replace(".", ",") + " $"126127128def _iso(ts) -> str:129    if not ts:130        return date.today().isoformat()131    return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()132133134def _cuisine_label(slug: str) -> str:135    return (slug or "").replace("-", " ").strip().capitalize()136137138def _parse_row(r) -> dict:139    d = dict(r)140    for k in ("cuisines", "services", "dietary_options", "languages", "images"):141        d[k] = json.loads(d.get(k) or "[]")142    return d143144145def _resto_li(r) -> str:146    """Un restaurant dans une liste HTML serveur."""147    bits = [b for b in (", ".join(_cuisine_label(c) for c in148                                  json.loads(r["cuisines"] or "[]")[:3]),149                        r["city"], r["price_range"]) if b]150    return (f'<li><a href="/resto/{_e(r["uid"])}">{_e(r["name"] or r["uid"])}</a>'151            f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')152153154def slugify(text: str) -> str:155    """« Trois-Rivières » → trois-rivieres, « L'Île-Perrot » → l-ile-perrot."""156    t = text.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae")157    t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()158    t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")159    return t160161162_villes_cache: dict = {"ts": 0.0, "by_slug": {}}163164165def _villes_index() -> dict[str, dict]:166    """slug → {city (libellé majoritaire), cities (variantes), n, with_menu} —167    les variantes de casse/accents d'une même ville sont regroupées par slug.168    Reconstruit au plus toutes les 10 minutes."""169    if time.time() - _villes_cache["ts"] > 600:170        con = db.connect()171        rows = con.execute(172            f"""SELECT city, COUNT(*) n,173                       SUM(CASE WHEN uid IN (SELECT uid FROM menus)174                           THEN 1 ELSE 0 END) with_menu175                FROM restaurants WHERE {ACTIVE} AND city<>''176                GROUP BY city""").fetchall()177        con.close()178        by_slug: dict[str, dict] = {}179        for r in rows:180            s = slugify(r["city"])181            if not s:182                continue183            e = by_slug.setdefault(s, {"slug": s, "city": r["city"],184                                       "cities": [], "n": 0, "with_menu": 0,185                                       "_best": -1})186            e["cities"].append(r["city"])187            e["n"] += r["n"]188            e["with_menu"] += r["with_menu"] or 0189            if r["n"] > e["_best"]:190                e["_best"] = r["n"]191                e["city"] = r["city"]192        _villes_cache["by_slug"] = by_slug193        _villes_cache["ts"] = time.time()194    return _villes_cache["by_slug"]195196197def _villes_rows(_con=None, minimum: int = 1) -> list[dict]:198    rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"],199             "with_menu": e["with_menu"]}200            for e in _villes_index().values() if e["n"] >= minimum]201    return sorted(rows, key=lambda r: -r["n"])202203204def _breadcrumb(items: list[tuple[str, str]]) -> dict:205    return {"@context": "https://schema.org", "@type": "BreadcrumbList",206            "itemListElement": [207                {"@type": "ListItem", "position": i + 1, "name": name,208                 "item": BASE_URL + path}209                for i, (name, path) in enumerate(items)]}210211212def _not_found(message: str, path: str) -> HTMLResponse:213    """404 HTML : le shell React est servi (la SPA affichera sa page), mais le214    statut et le contenu serveur disent clairement « introuvable » aux bots."""215    return _render(title="Page introuvable | Resto-Ka",216                   description="Cette page n'existe pas sur Resto-Ka.",217                   path=path,218                   body=f"<h1>{_e(message)}</h1>"219                        '<p><a href="/">Voir tous les restaurants du Québec</a></p>',220                   status=404)221222223# --- API JSON pour les pages villes du frontend --------------------------------224225@router.get("/api/seo/villes")226def api_villes():227    con = db.connect()228    rows = _villes_rows(con)229    con.close()230    return {"villes": rows}231232233@router.get("/api/seo/ville/{slug}")234def api_ville(slug: str):235    e = _villes_index().get(slug)236    if not e:237        raise HTTPException(404, "Ville inconnue")238    neighbors = [v for v in _villes_rows(minimum=MIN_RESTOS_PAGE)239                 if v["slug"] != slug][:12]240    return {"city": e["city"], "slug": slug, "n": e["n"],241            "with_menu": e["with_menu"], "neighbors": neighbors}242243244# --- robots.txt & sitemaps ----------------------------------------------------245246@router.get("/robots.txt", include_in_schema=False)247def robots() -> PlainTextResponse:248    return PlainTextResponse(249        "User-agent: *\n"250        "Allow: /\n"251        "Disallow: /api/\n"252        "Disallow: /favoris\n"253        "Disallow: /docs\n"254        "Disallow: /openapi.json\n"255        f"\nSitemap: {BASE_URL}/sitemap.xml\n")256257258def _xml(content: str) -> Response:259    return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content,260                    media_type="application/xml",261                    headers={"Cache-Control": "public, max-age=3600"})262263264def _urlset(urls: list[tuple[str, str | None]]) -> Response:265    rows = []266    for loc, lastmod in urls:267        lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""268        rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>")269    return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'270                + "\n".join(rows) + "\n</urlset>")271272273@router.get("/sitemap.xml", include_in_schema=False)274def sitemap_index():275    con = db.connect()276    total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"]277    con.close()278    chunks = max(1, math.ceil(total / SITEMAP_CHUNK))279    names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [280        f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)]281    today = date.today().isoformat()282    rows = "\n".join(283        f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>"284        for n in names)285    return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'286                + rows + "\n</sitemapindex>")287288289@router.get("/sitemap-pages.xml", include_in_schema=False)290def sitemap_pages():291    urls: list[tuple[str, str | None]] = [292        (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None),293        (f"{BASE_URL}/stats", None),294        (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)]295    return _urlset(urls)296297298@router.get("/sitemap-villes.xml", include_in_schema=False)299def sitemap_villes():300    con = db.connect()301    villes = _villes_rows(con, MIN_RESTOS_PAGE)302    con.close()303    today = date.today().isoformat()304    return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes])305306307@router.get("/sitemap-restos-{num}.xml", include_in_schema=False)308def sitemap_restos(num: int):309    if num < 1:310        raise HTTPException(404)311    con = db.connect()312    rows = con.execute(313        f"""SELECT uid, updated_at FROM restaurants WHERE {ACTIVE}314            ORDER BY uid LIMIT ? OFFSET ?""",315        (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()316    con.close()317    if not rows:318        raise HTTPException(404)319    return _urlset([(f"{BASE_URL}/resto/{r['uid']}", _iso(r["updated_at"]))320                    for r in rows])321322323# --- Pages SSR ----------------------------------------------------------------324325@router.get("/", include_in_schema=False)326def home_ssr():327    con = db.connect()328    total = con.execute(329        f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"]330    with_menu = con.execute(331        f"""SELECT COUNT(DISTINCT m.uid) c FROM menus m332            JOIN restaurants r ON r.uid=m.uid WHERE r.{ACTIVE}""").fetchone()["c"]333    regions = con.execute(334        f"""SELECT region, COUNT(*) n FROM restaurants335            WHERE {ACTIVE} AND region<>''336            GROUP BY region ORDER BY n DESC""").fetchall()337    recents = con.execute(338        f"""SELECT r.uid, r.name, r.city, r.cuisines, r.price_range339            FROM restaurants r JOIN menus m ON m.uid=r.uid340            WHERE r.{ACTIVE}341            GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall()342    villes = _villes_rows(con, MIN_RESTOS_PAGE)343    con.close()344345    title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : "346             "menus complets et prix réels")347    description = (f"{_fmt_n(total)} restaurants recensés dans les 17 régions du "348                   f"Québec, dont {_fmt_n(with_menu)} avec menu complet et prix "349                   "réels — comparables et cherchables : Montréal, Québec, Laval, "350                   "Gatineau et plus. Chaque resto, chaque plat, chaque prix.")351    body = (352        f"<h1>Restaurants du Québec — {_fmt_n(total)} établissements, "353        f"{_fmt_n(with_menu)} menus avec prix réels</h1>"354        + "<p>Resto-Ka agrège les restaurants des 17 régions du Québec avec "355          "leurs menus complets et leurs prix réels, toujours étiquetés selon "356          "leur contexte (salle, emporter, livraison), avec photos, coordonnées "357          "et cuisines.</p>"358        + "<h2>Restaurants par région</h2><ul>"359        + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>"360                  for r in regions)361        + "</ul><h2>Restaurants par ville</h2><ul>"362        + "".join(f'<li><a href="/ville/{v["slug"]}">Restaurants à '363                  f'{_e(v["city"])}</a> — {_fmt_n(v["n"])} établissements</li>'364                  for v in villes[:30])365        + f'</ul><p><a href="/villes">Toutes les villes ({len(villes)})</a></p>'366        + "<h2>Menus récemment mis à jour</h2><ul>"367        + "".join(_resto_li(r) for r in recents)368        + "</ul>"369        + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des '370          'données</a> · <a href="/contact">Contact</a></p>')371    jsonld = [372        {"@context": "https://schema.org", "@type": "WebSite",373         "name": SITE_NAME, "url": BASE_URL + "/",374         "description": description, "inLanguage": "fr-CA"},375        {"@context": "https://schema.org", "@type": "Organization",376         "name": "Resto-Ka (Groupe KA)", "url": BASE_URL + "/"}]377    return _render(title=title, description=description, path="/",378                   jsonld=jsonld, body=body)379380381@router.get("/resto/{uid:path}", include_in_schema=False)382def resto_ssr(uid: str):383    con = db.connect()384    row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone()385    if row is None:386        con.close()387        return _not_found("Restaurant introuvable", f"/resto/{uid}")388    d = _parse_row(row)389    if not d["active"]:390        con.close()391        return _render(392            title="Restaurant retiré | Resto-Ka",393            description="Ce restaurant n'est plus recensé sur Resto-Ka.",394            path=f"/resto/{uid}",395            body="<h1>Ce restaurant n'est plus recensé</h1>"396                 '<p><a href="/">Voir tous les restaurants du Québec</a></p>',397            status=410)398399    # meilleur menu disponible (dine-in > takeout > delivery)400    menu = None401    for m in con.execute(402            "SELECT price_context, captured_at, item_count, sections"403            " FROM menus WHERE uid=?", (uid,)):404        if menu and _CTX_ORDER.get(menu["price_context"], 9) <= \405                _CTX_ORDER.get(m["price_context"], 9):406            continue407        menu = dict(m)408    con.close()409410    name = d["name"] or "Restaurant"411    cuisines = [_cuisine_label(c) for c in d["cuisines"][:4]]412    where = d["city"] or d["region"] or "Québec"413    title = (f"{name} — "414             + (f"menu et prix, " if menu else "")415             + f"restaurant à {where} | Resto-Ka")416    desc_bits = [b for b in (", ".join(cuisines), d["address"], d["city"],417                             d["price_range"], d["phone"]) if b]418    description = (f"{name} : " + " · ".join(desc_bits) + ". "419                   + (f"Menu complet ({menu['item_count']} plats, "420                      f"{_CTX_LABEL.get(menu['price_context'], menu['price_context'])}) "421                      "avec prix réels sur Resto-Ka."422                      if menu and menu.get("item_count")423                      else "Fiche complète sur Resto-Ka, l'agrégateur des "424                           "restaurants du Québec."))[:300]425426    body = [f"<h1>{_e(name)}</h1>"]427    facts = [("Adresse", ", ".join(b for b in (d["address"], d["city"],428                                               d["postal_code"]) if b)),429             ("Région", d["region"]),430             ("Cuisine", ", ".join(cuisines)),431             ("Type", d["establishment_type"]),432             ("Fourchette de prix", d["price_range"]),433             ("Téléphone", d["phone"])]434    body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>"435                                 for k, v in facts if v) + "</ul>")436    if menu:437        sections = json.loads(menu.get("sections") or "[]")438        body.append(f"<h2>Menu ({_e(_CTX_LABEL.get(menu['price_context'], menu['price_context']))}"439                    + (f", {menu['item_count']} plats" if menu.get("item_count") else "")440                    + ")</h2>")441        lis = []442        for sec in sections[:10]:443            items = sec.get("items") or []444            prices = [it["price"] for it in items445                      if isinstance(it.get("price"), (int, float)) and it["price"] > 0]446            rng = (f" — {_fmt_price(min(prices))} à {_fmt_price(max(prices))}"447                   if prices else "")448            lis.append(f"<li>{_e(sec.get('name') or 'Section')} "449                       f"({len(items)} items){_e(rng)}</li>")450        if lis:451            body.append("<ul>" + "".join(lis) + "</ul>")452    if d["website"]:453        body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">'454                    "Site web du restaurant</a></p>")455    if d["city"]:456        body.append(f'<p><a href="/ville/{slugify(d["city"])}">'457                    f"Autres restaurants à {_e(d['city'])}</a></p>")458    body.append('<p><a href="/">Tous les restaurants du Québec</a></p>')459460    resto_ld: dict = {461        "@context": "https://schema.org", "@type": "Restaurant",462        "name": name, "url": f"{BASE_URL}/resto/{uid}",463        "address": {"@type": "PostalAddress",464                    "streetAddress": d["address"] or None,465                    "addressLocality": d["city"] or None,466                    "postalCode": d["postal_code"] or None,467                    "addressRegion": "QC", "addressCountry": "CA"}}468    if d["lat"] and d["lng"]:469        resto_ld["geo"] = {"@type": "GeoCoordinates",470                           "latitude": d["lat"], "longitude": d["lng"]}471    if cuisines:472        resto_ld["servesCuisine"] = cuisines473    if d["phone"]:474        resto_ld["telephone"] = d["phone"]475    if d["price_range"]:476        resto_ld["priceRange"] = d["price_range"]477    if d["images"]:478        resto_ld["image"] = d["images"][:5]479    if d["website"]:480        resto_ld["sameAs"] = [d["website"]]481    if menu:482        resto_ld["hasMenu"] = f"{BASE_URL}/resto/{uid}"483    crumbs = [("Accueil", "/"), (name, f"/resto/{uid}")]484    # doublon inter-sources : la fiche canonique est celle du uid maître485    canonical_path = f"/resto/{d['dup_of']}" if d.get("dup_of") else None486    return _render(title=title, description=description, path=f"/resto/{uid}",487                   canonical_path=canonical_path,488                   jsonld=[resto_ld, _breadcrumb(crumbs)], body="".join(body),489                   og_image=d["images"][0] if d["images"] else None)490491492# --- Pages programmatiques villes : /villes + /ville/{slug} -------------------493494@router.get("/villes", include_in_schema=False)495def villes_ssr():496    con = db.connect()497    villes = _villes_rows(con)498    con.close()499    total = sum(v["n"] for v in villes)500    title = f"Restaurants par ville au Québec ({len(villes)} villes) | Resto-Ka"501    description = (502        f"Toutes les villes du Québec où Resto-Ka recense des restaurants : "503        f"{_fmt_n(total)} établissements dans {len(villes)} villes, avec menus "504        "complets et prix réels quand ils sont publiés.")505    body = (f"<h1>Restaurants par ville — {len(villes)} villes au Québec</h1><ul>"506            + "".join(507                f'<li><a href="/ville/{v["slug"]}">Restaurants à {_e(v["city"])}</a>'508                f' — {_fmt_n(v["n"])} établissements'509                + (f", {_fmt_n(v['with_menu'])} menus" if v["with_menu"] else "")510                + "</li>" for v in villes)511            + "</ul>")512    jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]),513              {"@context": "https://schema.org", "@type": "ItemList",514               "name": "Restaurants par ville au Québec",515               "numberOfItems": len(villes),516               "itemListElement": [517                   {"@type": "ListItem", "position": i + 1,518                    "name": f"Restaurants à {v['city']}",519                    "url": f"{BASE_URL}/ville/{v['slug']}"}520                   for i, v in enumerate(villes[:100])]}]521    return _render(title=title, description=description, path="/villes",522                   jsonld=jsonld, body=body)523524525@router.get("/ville/{slug}", include_in_schema=False)526def ville_ssr(slug: str):527    path = f"/ville/{slug}"528    e = _villes_index().get(slug)529    if not e:530        return _not_found("Aucun restaurant recensé pour cette ville", path)531    city, n, with_menu = e["city"], e["n"], e["with_menu"]532    marks = ",".join("?" * len(e["cities"]))533    con = db.connect()534    rows = con.execute(535        f"""SELECT uid, name, city, cuisines, price_range536            FROM restaurants WHERE {ACTIVE} AND city IN ({marks})537            ORDER BY uid IN (SELECT uid FROM menus) DESC, updated_at DESC538            LIMIT 100""", e["cities"]).fetchall()539    con.close()540    neighbors = [v for v in _villes_rows(minimum=MIN_RESTOS_PAGE)541                 if v["slug"] != slug][:12]542    if n == 0:543        return _not_found(f"Aucun restaurant actif à {city}", path)544545    title = f"Restaurants à {city} — {_fmt_n(n)} établissements | Resto-Ka"546    description = (547        f"{_fmt_n(n)} restaurants à {city}"548        + (f", dont {_fmt_n(with_menu)} avec menu complet et prix réels"549           if with_menu else "")550        + ". Cuisines, coordonnées, fourchettes de prix et menus, "551          "agrégés et mis à jour en continu par Resto-Ka.")552    body = [f"<h1>Restaurants à {_e(city)} — {_fmt_n(n)} établissements</h1>"]553    if with_menu:554        body.append(f"<p>{_fmt_n(with_menu)} restaurants de {_e(city)} ont leur "555                    "menu complet avec prix réels sur Resto-Ka.</p>")556    body.append("<h2>Établissements</h2><ul>"557                + "".join(_resto_li(r) for r in rows) + "</ul>")558    if neighbors:559        body.append("<h2>Autres villes</h2><ul>" + "".join(560            f'<li><a href="/ville/{v["slug"]}">Restaurants à {_e(v["city"])}</a>'561            f' — {_fmt_n(v["n"])}</li>' for v in neighbors) + "</ul>")562    body.append('<p><a href="/villes">Toutes les villes</a> · '563                '<a href="/">Tous les restaurants du Québec</a></p>')564    crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)]565    jsonld = [_breadcrumb(crumbs),566              {"@context": "https://schema.org", "@type": "ItemList",567               "name": f"Restaurants à {city}", "numberOfItems": n,568               "itemListElement": [569                   {"@type": "ListItem", "position": i + 1,570                    "name": r["name"] or r["uid"],571                    "url": f"{BASE_URL}/resto/{r['uid']}"}572                   for i, r in enumerate(rows[:50])]}]573    return _render(title=title, description=description, path=path,574                   jsonld=jsonld, body="".join(body))575576577# pages statiques de l'app : head unique, contenu rendu par React578_STATIC_META = {579    "/stats": ("Statistiques des restaurants du Québec | Resto-Ka",580               "Restaurants recensés, menus capturés et prix réels par région et "581               "par contexte (salle, emporter, livraison) : les statistiques du "582               "paysage restaurant québécois, calculées en continu par Resto-Ka."),583    "/sources": ("Sources des données | Resto-Ka",584                 "Les sources publiques et plateformes dont Resto-Ka agrège les "585                 "restaurants et les menus du Québec, avec le nombre "586                 "d'établissements recensés pour chacune."),587    "/contact": ("Contact | Resto-Ka",588                 "Joindre l'équipe de Resto-Ka, l'agrégateur des restaurants du "589                 "Québec : menus complets, prix réels et fiches des 17 régions."),590}591592593def _static_page(path: str):594    title, description = _STATIC_META[path]595    return _render(title=title, description=description, path=path,596                   jsonld=[_breadcrumb([("Accueil", "/"),597                                        (title.split(" | ")[0], path)])])598599600@router.get("/stats", include_in_schema=False)601def stats_page():602    return _static_page("/stats")603604605@router.get("/sources", include_in_schema=False)606def sources_page():607    return _static_page("/sources")608609610@router.get("/contact", include_in_schema=False)611def contact_page():612    return _static_page("/contact")613