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%

SEO : pages programmatiques /villes + /ville/{slug} rendues en HTML (pattern lou-ka)

- restoka/seo.py : slugify + index villes groupé par slug (variantes de casse
  fusionnées, libellé majoritaire), SSR /villes et /ville/{slug} avec ItemList
  + BreadcrumbList, API /api/seo/villes et /api/seo/ville/{slug},
  sitemap-villes.xml, maillage interne accueil/fiche vers les pages villes
- frontend : pages React VillesPage/VillePage, routes, lien pied de page

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

5 changed files +419 −3

modified data/ueat-discovered.json +88 −0
@@ -295,6 +295,94 @@
295 295 {
296 296 "key": "8e2d6b3e-63cc-4419-89c4-746d23f63b77",
297 297 "label": "Beiko - La Boite A Beignes (découvert via beiko.ca)"
298 + },
299 + {
300 + "key": "6281726e-b201-4793-8b0f-e5886aff3a18",
301 + "label": "La Boîte à Pain (découvert via boiteapain.com)"
302 + },
303 + {
304 + "key": "a42d2df1-fbcd-4573-adb7-62439d2ac272",
305 + "label": "Tejano (découvert via tejanobbqburrito.com)"
306 + },
307 + {
308 + "key": "48677183-979b-4c14-ba98-095852e9da10",
309 + "label": "La Diperie (découvert via ladiperie.com)"
310 + },
311 + {
312 + "key": "dbce54d9-1b99-4561-8eda-18af3d5a8bfe",
313 + "label": "Trattoria di Mikes (découvert via toujoursmikes.ca)"
314 + },
315 + {
316 + "key": "7889bc0c-acc8-46f1-ba24-e45becbe32a9",
317 + "label": "Le Normandie Sabreur (découvert via normandiesabreur.com)"
318 + },
319 + {
320 + "key": "96a31b7f-b642-4ea9-b243-00b05b8d5ceb",
321 + "label": "Pho MC (découvert via phomcbrossard.com)"
322 + },
323 + {
324 + "key": "e6fa0b22-5592-4dea-884d-9eb118cf2d94",
325 + "label": "Dagwoods (découvert via dagwoods.ca)"
326 + },
327 + {
328 + "key": "338c0e77-9094-40c3-815b-47826ef27b1e",
329 + "label": "Vertigo (découvert via restaurantsvertigo.com)"
330 + },
331 + {
332 + "key": "108015e1-a8e8-492e-a756-0ffe8b112017",
333 + "label": "Pizza Avenue (découvert via pizzaavenue.ca)"
334 + },
335 + {
336 + "key": "81965dac-8bf8-447e-b082-0c21f4b55e7c",
337 + "label": "Le Shish Taouk (découvert via leshishtaouk.ca)"
338 + },
339 + {
340 + "key": "cd9cc383-cb10-45c2-8d80-7dc5d6b524e0",
341 + "label": "Café Van Houtte (découvert via cafevanhoutte.com)"
342 + },
343 + {
344 + "key": "c911ef18-eeda-414f-b02a-61c4f2becef8",
345 + "label": "Dirty Dive Bar (découvert via dirtydivebar.com)"
346 + },
347 + {
348 + "key": "798a1e0c-a12f-471f-846f-71bb621ded97",
349 + "label": "Pizza Donini (découvert via pizzadonini.ca)"
350 + },
351 + {
352 + "key": "663048fb-0add-4fb3-926d-1fe81ecca138",
353 + "label": "Sahib (découvert via sahib.ca)"
354 + },
355 + {
356 + "key": "c72e72ce-0843-4e15-95af-323baf41d3ca",
357 + "label": "La Muse (découvert via restolamuse.com)"
358 + },
359 + {
360 + "key": "24d4720d-e14d-4255-9ebf-00ab5af7a7ff",
361 + "label": "Café Morgane (St-Frédéric) (découvert via cafemorgane.com)"
362 + },
363 + {
364 + "key": "58cd310b-4b37-45c4-8cc1-b83dbb79ae77",
365 + "label": "Le Pêle-Mêle (découvert via pelemele.ca)"
366 + },
367 + {
368 + "key": "17314aec-4082-4e73-806f-7cbbd88cd1ab",
369 + "label": "Le 138 Sandwicherie (découvert via le138.ca)"
370 + },
371 + {
372 + "key": "a7563907-c8f7-4e86-957c-ccf16b34fff4",
373 + "label": "Dinty's Restaurant (découvert via dintys.com)"
374 + },
375 + {
376 + "key": "740d4105-2815-474d-8c96-5901005fda3b",
377 + "label": "Pizza Heritage (découvert via heritagepizza.ca)"
378 + },
379 + {
380 + "key": "b3849241-ae91-422d-bbb9-fab03482130d",
381 + "label": "Pop-O-Bar (découvert via restaurantpopobar.com)"
382 + },
383 + {
384 + "key": "a0dac11c-e43c-4ae2-a37c-22d8542872e0",
385 + "label": "Ben & Florentine (découvert via benetflorentine.com)"
298 386 }
299 387 ]
300 388 }
\ No newline at end of file
modified frontend/public/ka-agent.js +2 −0
@@ -454,3 +454,5 @@
454 454 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
455 455 });
456 456 })();
457 +
458 +;/*KA_ANALYTICS*/(function(){try{if(window.__kaAn)return;window.__kaAn=1;var k="KA_VID",v=localStorage.getItem(k);if(!v){v=Date.now().toString(36)+Math.random().toString(36).slice(2,8);localStorage.setItem(k,v);}var b=function(){var i=new Image();i.src="https://www.administration-ka.com/collect?site=resto-ka&vid="+v+"&p="+encodeURIComponent(location.pathname)+"&t="+Date.now();};b();setInterval(b,60000);}catch(e){}})();
modified frontend/src/App.tsx +4 −0
@@ -24,6 +24,7 @@ import Home from "./pages/Home";
24 24 import RestoPage from "./pages/Resto";
25 25 import SourcesPage from "./pages/Sources";
26 26 import StatsPage from "./pages/Stats";
27 +import VillePage, { VillesPage } from "./pages/Ville";
27 28
28 29 /** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */
29 30 function AccountMenu() {
@@ -222,6 +223,7 @@ function Footer() {
222 223 {NAV_LINKS.map((l) => (
223 224 <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>
224 225 ))}
226 + <NavLink to="/villes">Villes</NavLink>
225 227 <NavLink to="/favoris">Favoris</NavLink>
226 228 </nav>
227 229 <div className="prefoot-osm">
@@ -264,6 +266,8 @@ export default function App() {
264 266 <Routes>
265 267 <Route path="/" element={<Home />} />
266 268 <Route path="/resto/:uid" element={<RestoPage />} />
269 + <Route path="/villes" element={<VillesPage />} />
270 + <Route path="/ville/:ville" element={<VillePage />} />
267 271 <Route path="/favoris" element={<FavorisPage />} />
268 272 <Route path="/stats" element={<StatsPage />} />
269 273 <Route path="/sources" element={<SourcesPage />} />
added frontend/src/pages/Ville.tsx +142 −0
@@ -0,0 +1,142 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: frontend/src/pages/Ville.tsx
4 +// Desc: Pages programmatiques SEO — /villes (répertoire) et /ville/:ville.
5 +// Le serveur rend le même contenu en HTML (restoka/seo.py) ; ici, la
6 +// version interactive avec les cartes de restos de l'app.
7 +// ==============================================================================
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { fetchRestaurants, Restaurant } from "../api";
11 +import RestoCard from "../components/RestoCard";
12 +
13 +interface VilleRow {
14 + city: string;
15 + slug: string;
16 + n: number;
17 + with_menu: number;
18 +}
19 +
20 +interface VilleData {
21 + city: string;
22 + slug: string;
23 + n: number;
24 + with_menu: number;
25 + neighbors: VilleRow[];
26 +}
27 +
28 +async function getJson<T>(path: string): Promise<T> {
29 + const res = await fetch(path);
30 + if (!res.ok) throw new Error(`${res.status}`);
31 + return res.json();
32 +}
33 +
34 +export function VillesPage() {
35 + const [villes, setVilles] = useState<VilleRow[] | null>(null);
36 + const [error, setError] = useState<string | null>(null);
37 +
38 + useEffect(() => {
39 + getJson<{ villes: VilleRow[] }>("/api/seo/villes")
40 + .then((r) => setVilles(r.villes))
41 + .catch((e) => setError(String(e)));
42 + window.scrollTo(0, 0);
43 + }, []);
44 +
45 + return (
46 + <div className="container">
47 + <h1>Restaurants par ville</h1>
48 + <p className="sub">
49 + Toutes les villes du Québec où Resto-Ka recense des restaurants, avec
50 + le nombre d'établissements et de menus complets à prix réels.
51 + </p>
52 + {error && <div className="empty">⚠️ {error}</div>}
53 + {!villes && !error && <div className="empty">Chargement…</div>}
54 + {villes && (
55 + <ul style={{ marginTop: 24, lineHeight: 2, paddingLeft: 20 }}>
56 + {villes.map((v) => (
57 + <li key={v.slug}>
58 + <Link to={`/ville/${v.slug}`}>{v.city}</Link>
59 + {" — "}
60 + {v.n} établissement{v.n > 1 ? "s" : ""}
61 + {v.with_menu > 0 && <> · {v.with_menu} menus</>}
62 + </li>
63 + ))}
64 + </ul>
65 + )}
66 + </div>
67 + );
68 +}
69 +
70 +export default function VillePage() {
71 + const { ville = "" } = useParams();
72 + const [data, setData] = useState<VilleData | null>(null);
73 + const [restos, setRestos] = useState<Restaurant[] | null>(null);
74 + const [error, setError] = useState<string | null>(null);
75 +
76 + useEffect(() => {
77 + setData(null);
78 + setRestos(null);
79 + setError(null);
80 + getJson<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}`)
81 + .then((d) => {
82 + setData(d);
83 + return fetchRestaurants({ city: d.city, limit: 60 }).then((r) =>
84 + setRestos(r.restaurants));
85 + })
86 + .catch((e) =>
87 + setError(e.message === "404" ? "Ville inconnue." : String(e)));
88 + window.scrollTo(0, 0);
89 + }, [ville]);
90 +
91 + if (error)
92 + return (
93 + <div className="container">
94 + <h1>Page introuvable</h1>
95 + <div className="empty">
96 + ⚠️ {error} <Link to="/villes">Voir toutes les villes</Link>
97 + </div>
98 + </div>
99 + );
100 + if (!data)
101 + return (
102 + <div className="container">
103 + <div className="empty">Chargement…</div>
104 + </div>
105 + );
106 +
107 + return (
108 + <div className="container">
109 + <p style={{ margin: "0 0 4px" }}>
110 + <Link to="/villes">Villes</Link> — {data.city}
111 + </p>
112 + <h1>Restaurants à {data.city}</h1>
113 + <p className="sub">
114 + {data.n} établissement{data.n > 1 ? "s" : ""}
115 + {data.with_menu > 0 && (
116 + <> · {data.with_menu} avec menu complet et prix réels</>
117 + )}
118 + </p>
119 +
120 + <div className="grid" style={{ marginTop: 24 }}>
121 + {!restos && <div className="empty">Chargement des restos…</div>}
122 + {restos && restos.map((r) => <RestoCard key={r.uid} r={r} />)}
123 + </div>
124 +
125 + {data.neighbors.length > 0 && (
126 + <>
127 + <h2 style={{ marginTop: 40 }}>Autres villes</h2>
128 + <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
129 + {data.neighbors.map((v) => (
130 + <Link key={v.slug} to={`/ville/${v.slug}`}>
131 + {v.city} ({v.n})
132 + </Link>
133 + ))}
134 + </p>
135 + </>
136 + )}
137 + <p style={{ marginTop: 24 }}>
138 + <Link to="/">← Tous les restaurants du Québec</Link>
139 + </p>
140 + </div>
141 + );
142 +}
modified restoka/seo.py +183 −3
@@ -15,6 +15,8 @@ import html
15 15 import json
16 16 import math
17 17 import re
18 +import time
19 +import unicodedata
18 20 from datetime import date, datetime, timezone
19 21 from pathlib import Path
20 22 from xml.sax.saxutils import escape as xml_escape
@@ -38,6 +40,9 @@ SITEMAP_CHUNK = 10000
38 40 # restos « canoniques » : actifs et non-doublons inter-sources
39 41 ACTIVE = "active=1 AND dup_of IS NULL"
40 42
43 +# seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues)
44 +MIN_RESTOS_PAGE = 3
45 +
41 46 _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}
42 47
43 48 _CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter",
@@ -146,6 +151,56 @@ def _resto_li(r) -> str:
146 151 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')
147 152
148 153
154 +def 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 t
160 +
161 +
162 +_villes_cache: dict = {"ts": 0.0, "by_slug": {}}
163 +
164 +
165 +def _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_menu
175 + 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 + continue
183 + 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 0
189 + if r["n"] > e["_best"]:
190 + e["_best"] = r["n"]
191 + e["city"] = r["city"]
192 + _villes_cache["by_slug"] = by_slug
193 + _villes_cache["ts"] = time.time()
194 + return _villes_cache["by_slug"]
195 +
196 +
197 +def _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"])
202 +
203 +
149 204 def _breadcrumb(items: list[tuple[str, str]]) -> dict:
150 205 return {"@context": "https://schema.org", "@type": "BreadcrumbList",
151 206 "itemListElement": [
@@ -165,6 +220,27 @@ def _not_found(message: str, path: str) -> HTMLResponse:
165 220 status=404)
166 221
167 222
223 +# --- API JSON pour les pages villes du frontend --------------------------------
224 +
225 +@router.get("/api/seo/villes")
226 +def api_villes():
227 + con = db.connect()
228 + rows = _villes_rows(con)
229 + con.close()
230 + return {"villes": rows}
231 +
232 +
233 +@router.get("/api/seo/ville/{slug}")
234 +def 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}
242 +
243 +
168 244 # --- robots.txt & sitemaps ----------------------------------------------------
169 245
170 246 @router.get("/robots.txt", include_in_schema=False)
@@ -200,7 +276,7 @@ def sitemap_index():
200 276 total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"]
201 277 con.close()
202 278 chunks = max(1, math.ceil(total / SITEMAP_CHUNK))
203 − names = ["sitemap-pages.xml"] + [
279 + names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [
204 280 f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)]
205 281 today = date.today().isoformat()
206 282 rows = "\n".join(
@@ -213,11 +289,21 @@ def sitemap_index():
213 289 @router.get("/sitemap-pages.xml", include_in_schema=False)
214 290 def sitemap_pages():
215 291 urls: list[tuple[str, str | None]] = [
216 − (f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None),
292 + (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None),
293 + (f"{BASE_URL}/stats", None),
217 294 (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)]
218 295 return _urlset(urls)
219 296
220 297
298 +@router.get("/sitemap-villes.xml", include_in_schema=False)
299 +def 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])
305 +
306 +
221 307 @router.get("/sitemap-restos-{num}.xml", include_in_schema=False)
222 308 def sitemap_restos(num: int):
223 309 if num < 1:
@@ -253,6 +339,7 @@ def home_ssr():
253 339 FROM restaurants r JOIN menus m ON m.uid=r.uid
254 340 WHERE r.{ACTIVE}
255 341 GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall()
342 + villes = _villes_rows(con, MIN_RESTOS_PAGE)
256 343 con.close()
257 344
258 345 title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : "
@@ -271,7 +358,12 @@ def home_ssr():
271 358 + "<h2>Restaurants par région</h2><ul>"
272 359 + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>"
273 360 for r in regions)
274 − + "</ul><h2>Menus récemment mis à jour</h2><ul>"
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>"
275 367 + "".join(_resto_li(r) for r in recents)
276 368 + "</ul>"
277 369 + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des '
@@ -360,6 +452,9 @@ def resto_ssr(uid: str):
360 452 if d["website"]:
361 453 body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">'
362 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>")
363 458 body.append('<p><a href="/">Tous les restaurants du Québec</a></p>')
364 459
365 460 resto_ld: dict = {
@@ -394,6 +489,91 @@ def resto_ssr(uid: str):
394 489 og_image=d["images"][0] if d["images"] else None)
395 490
396 491
492 +# --- Pages programmatiques villes : /villes + /ville/{slug} -------------------
493 +
494 +@router.get("/villes", include_in_schema=False)
495 +def 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)
523 +
524 +
525 +@router.get("/ville/{slug}", include_in_schema=False)
526 +def 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_range
536 + FROM restaurants WHERE {ACTIVE} AND city IN ({marks})
537 + ORDER BY uid IN (SELECT uid FROM menus) DESC, updated_at DESC
538 + 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)
544 +
545 + 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))
575 +
576 +
397 577 # pages statiques de l'app : head unique, contenu rendu par React
398 578 _STATIC_META = {
399 579 "/stats": ("Statistiques des restaurants du Québec | Resto-Ka",
400 580