Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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