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>
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 | |