SPB Git

spb/resto-ka

Public

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

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 (Aug 23, 2026) parent 093ddf9 Browse files

5 changed files +419 −3

modified data/ueat-discovered.json +88 −0
@@ -295,6 +295,94 @@
295 { 295 {
296 "key": "8e2d6b3e-63cc-4419-89c4-746d23f63b77", 296 "key": "8e2d6b3e-63cc-4419-89c4-746d23f63b77",
297 "label": "Beiko - La Boite A Beignes (découvert via beiko.ca)" 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 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); } 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 import RestoPage from "./pages/Resto"; 24 import RestoPage from "./pages/Resto";
25 import SourcesPage from "./pages/Sources"; 25 import SourcesPage from "./pages/Sources";
26 import StatsPage from "./pages/Stats"; 26 import StatsPage from "./pages/Stats";
27+import VillePage, { VillesPage } from "./pages/Ville";
27 28
28 /** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */ 29 /** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */
29 function AccountMenu() { 30 function AccountMenu() {
@@ -222,6 +223,7 @@ function Footer() {
222 {NAV_LINKS.map((l) => ( 223 {NAV_LINKS.map((l) => (
223 <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink> 224 <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>
224 ))} 225 ))}
226+ <NavLink to="/villes">Villes</NavLink>
225 <NavLink to="/favoris">Favoris</NavLink> 227 <NavLink to="/favoris">Favoris</NavLink>
226 </nav> 228 </nav>
227 <div className="prefoot-osm"> 229 <div className="prefoot-osm">
@@ -264,6 +266,8 @@ export default function App() {
264 <Routes> 266 <Routes>
265 <Route path="/" element={<Home />} /> 267 <Route path="/" element={<Home />} />
266 <Route path="/resto/:uid" element={<RestoPage />} /> 268 <Route path="/resto/:uid" element={<RestoPage />} />
269+ <Route path="/villes" element={<VillesPage />} />
270+ <Route path="/ville/:ville" element={<VillePage />} />
267 <Route path="/favoris" element={<FavorisPage />} /> 271 <Route path="/favoris" element={<FavorisPage />} />
268 <Route path="/stats" element={<StatsPage />} /> 272 <Route path="/stats" element={<StatsPage />} />
269 <Route path="/sources" element={<SourcesPage />} /> 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 import json 15 import json
16 import math 16 import math
17 import re 17 import re
18+import time
19+import unicodedata
18 from datetime import date, datetime, timezone 20 from datetime import date, datetime, timezone
19 from pathlib import Path 21 from pathlib import Path
20 from xml.sax.saxutils import escape as xml_escape 22 from xml.sax.saxutils import escape as xml_escape
@@ -38,6 +40,9 @@ SITEMAP_CHUNK = 10000
38 # restos « canoniques » : actifs et non-doublons inter-sources 40 # restos « canoniques » : actifs et non-doublons inter-sources
39 ACTIVE = "active=1 AND dup_of IS NULL" 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 _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} 46 _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}
42 47
43 _CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter", 48 _CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter",
@@ -146,6 +151,56 @@ def _resto_li(r) -> str:
146 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') 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 def _breadcrumb(items: list[tuple[str, str]]) -> dict: 204 def _breadcrumb(items: list[tuple[str, str]]) -> dict:
150 return {"@context": "https://schema.org", "@type": "BreadcrumbList", 205 return {"@context": "https://schema.org", "@type": "BreadcrumbList",
151 "itemListElement": [ 206 "itemListElement": [
@@ -165,6 +220,27 @@ def _not_found(message: str, path: str) -> HTMLResponse:
165 status=404) 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 # --- robots.txt & sitemaps ---------------------------------------------------- 244 # --- robots.txt & sitemaps ----------------------------------------------------
169 245
170 @router.get("/robots.txt", include_in_schema=False) 246 @router.get("/robots.txt", include_in_schema=False)
@@ -200,7 +276,7 @@ def sitemap_index():
200 total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] 276 total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"]
201 con.close() 277 con.close()
202 chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) 278 chunks = max(1, math.ceil(total / SITEMAP_CHUNK))
203− names = ["sitemap-pages.xml"] + [ 279+ names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [
204 f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)] 280 f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)]
205 today = date.today().isoformat() 281 today = date.today().isoformat()
206 rows = "\n".join( 282 rows = "\n".join(
@@ -213,11 +289,21 @@ def sitemap_index():
213 @router.get("/sitemap-pages.xml", include_in_schema=False) 289 @router.get("/sitemap-pages.xml", include_in_schema=False)
214 def sitemap_pages(): 290 def sitemap_pages():
215 urls: list[tuple[str, str | None]] = [ 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 (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)] 294 (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)]
218 return _urlset(urls) 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 @router.get("/sitemap-restos-{num}.xml", include_in_schema=False) 307 @router.get("/sitemap-restos-{num}.xml", include_in_schema=False)
222 def sitemap_restos(num: int): 308 def sitemap_restos(num: int):
223 if num < 1: 309 if num < 1:
@@ -253,6 +339,7 @@ def home_ssr():
253 FROM restaurants r JOIN menus m ON m.uid=r.uid 339 FROM restaurants r JOIN menus m ON m.uid=r.uid
254 WHERE r.{ACTIVE} 340 WHERE r.{ACTIVE}
255 GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall() 341 GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall()
342+ villes = _villes_rows(con, MIN_RESTOS_PAGE)
256 con.close() 343 con.close()
257 344
258 title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : " 345 title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : "
@@ -271,7 +358,12 @@ def home_ssr():
271 + "<h2>Restaurants par région</h2><ul>" 358 + "<h2>Restaurants par région</h2><ul>"
272 + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>" 359 + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>"
273 for r in regions) 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 + "".join(_resto_li(r) for r in recents) 367 + "".join(_resto_li(r) for r in recents)
276 + "</ul>" 368 + "</ul>"
277 + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des ' 369 + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des '
@@ -360,6 +452,9 @@ def resto_ssr(uid: str):
360 if d["website"]: 452 if d["website"]:
361 body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">' 453 body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">'
362 "Site web du restaurant</a></p>") 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 body.append('<p><a href="/">Tous les restaurants du Québec</a></p>') 458 body.append('<p><a href="/">Tous les restaurants du Québec</a></p>')
364 459
365 resto_ld: dict = { 460 resto_ld: dict = {
@@ -394,6 +489,91 @@ def resto_ssr(uid: str):
394 og_image=d["images"][0] if d["images"] else None) 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 # pages statiques de l'app : head unique, contenu rendu par React 577 # pages statiques de l'app : head unique, contenu rendu par React
398 _STATIC_META = { 578 _STATIC_META = {
399 "/stats": ("Statistiques des restaurants du Québec | Resto-Ka", 579 "/stats": ("Statistiques des restaurants du Québec | Resto-Ka",
400 580
Menu

Theme