| |
1 | +# ============================================================================== |
| |
2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| |
3 | +# File: restoka/seo.py |
| |
4 | +# 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.html |
| |
7 | +# 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">. Les |
| |
9 | +# 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 | +# ============================================================================== |
| |
12 | +from __future__ import annotations |
| |
13 | + |
| |
14 | +import html |
| |
15 | +import json |
| |
16 | +import math |
| |
17 | +import re |
| |
18 | +from datetime import date, datetime, timezone |
| |
19 | +from pathlib import Path |
| |
20 | +from xml.sax.saxutils import escape as xml_escape |
| |
21 | + |
| |
22 | +from fastapi import APIRouter, HTTPException |
| |
23 | +from fastapi.responses import HTMLResponse, PlainTextResponse, Response |
| |
24 | + |
| |
25 | +from . import db |
| |
26 | +from .normalize import PRICE_CONTEXTS |
| |
27 | + |
| |
28 | +router = APIRouter() |
| |
29 | + |
| |
30 | +ROOT = Path(__file__).resolve().parent.parent |
| |
31 | +FRONTEND_DIST = ROOT / "frontend" / "dist" |
| |
32 | + |
| |
33 | +BASE_URL = "https://www.resto-ka.com" |
| |
34 | +SITE_NAME = "Resto-Ka" |
| |
35 | + |
| |
36 | +SITEMAP_CHUNK = 10000 |
| |
37 | + |
| |
38 | +# restos « canoniques » : actifs et non-doublons inter-sources |
| |
39 | +ACTIVE = "active=1 AND dup_of IS NULL" |
| |
40 | + |
| |
41 | +_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} |
| |
42 | + |
| |
43 | +_CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter", |
| |
44 | + "delivery": "prix en livraison"} |
| |
45 | + |
| |
46 | + |
| |
47 | +# --- Gabarit (index.html du build Vite) -------------------------------------- |
| |
48 | + |
| |
49 | +_shell_cache: dict = {"mtime": 0.0, "html": ""} |
| |
50 | + |
| |
51 | + |
| |
52 | +def _shell() -> str: |
| |
53 | + f = FRONTEND_DIST / "index.html" |
| |
54 | + mtime = f.stat().st_mtime |
| |
55 | + if mtime != _shell_cache["mtime"]: |
| |
56 | + _shell_cache["html"] = f.read_text(encoding="utf-8") |
| |
57 | + _shell_cache["mtime"] = mtime |
| |
58 | + return _shell_cache["html"] |
| |
59 | + |
| |
60 | + |
| |
61 | +def _render(*, title: str, description: str, path: str, |
| |
62 | + jsonld: list[dict] | None = None, body: str = "", |
| |
63 | + og_image: str | None = None, canonical_path: str | None = None, |
| |
64 | + status: int = 200) -> HTMLResponse: |
| |
65 | + """index.html du build + head unique + contenu HTML dans #root.""" |
| |
66 | + canonical = BASE_URL + (canonical_path or path) |
| |
67 | + page = _shell() |
| |
68 | + page = re.sub(r"<title>.*?</title>", |
| |
69 | + lambda _m: f"<title>{html.escape(title)}</title>", page, count=1, flags=re.S) |
| |
70 | + page = re.sub(r'<meta name="description"[^>]*/>', |
| |
71 | + lambda _m: f'<meta name="description" content="{html.escape(description, quote=True)}" />', |
| |
72 | + page, count=1) |
| |
73 | + # retire du gabarit statique les meta og:image/twitter (re-injectées ci-dessous) |
| |
74 | + page = re.sub(r'\s*<meta (?:property="og:image[^"]*"|name="twitter:(?:card|image)")[^>]*/>', "", page) |
| |
75 | + extras = [ |
| |
76 | + f'<link rel="canonical" href="{canonical}" />', |
| |
77 | + f'<link rel="alternate" hreflang="fr-ca" href="{canonical}" />', |
| |
78 | + f'<link rel="alternate" hreflang="x-default" href="{canonical}" />', |
| |
79 | + f'<meta property="og:site_name" content="{SITE_NAME}" />', |
| |
80 | + '<meta property="og:locale" content="fr_CA" />', |
| |
81 | + '<meta property="og:type" content="website" />', |
| |
82 | + f'<meta property="og:title" content="{html.escape(title, quote=True)}" />', |
| |
83 | + f'<meta property="og:description" content="{html.escape(description, quote=True)}" />', |
| |
84 | + f'<meta property="og:url" content="{canonical}" />', |
| |
85 | + '<meta name="twitter:card" content="summary_large_image" />', |
| |
86 | + f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />', |
| |
87 | + ] |
| |
88 | + img = og_image or (BASE_URL + "/og.png") |
| |
89 | + extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />') |
| |
90 | + if not og_image: |
| |
91 | + extras.append('<meta property="og:image:width" content="1200" />') |
| |
92 | + extras.append('<meta property="og:image:height" content="630" />') |
| |
93 | + extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />') |
| |
94 | + for obj in (jsonld or []): |
| |
95 | + blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/") |
| |
96 | + extras.append(f'<script type="application/ld+json">{blob}</script>') |
| |
97 | + page = page.replace("</head>", " " + "\n ".join(extras) + "\n</head>", 1) |
| |
98 | + if body: |
| |
99 | + seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;' |
| |
100 | + 'font-family:system-ui,sans-serif;color:#141814">' + body |
| |
101 | + + '<p>Resto-Ka — Un service <a href="https://www.groupe-ka.com">Groupe KA</a></p>' |
| |
102 | + + "</div>") |
| |
103 | + page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1) |
| |
104 | + return HTMLResponse(page, status_code=status, |
| |
105 | + headers={"Cache-Control": "no-cache"}) |
| |
106 | + |
| |
107 | + |
| |
108 | +def _e(t) -> str: |
| |
109 | + return html.escape(str(t or "")) |
| |
110 | + |
| |
111 | + |
| |
112 | +def _fmt_n(n: int) -> str: |
| |
113 | + return f"{n:,}".replace(",", " ") |
| |
114 | + |
| |
115 | + |
| |
116 | +def _fmt_price(p) -> str: |
| |
117 | + if p is None: |
| |
118 | + return "" |
| |
119 | + s = f"{p:.2f}".rstrip("0").rstrip(".") |
| |
120 | + return s.replace(".", ",") + " $" |
| |
121 | + |
| |
122 | + |
| |
123 | +def _iso(ts) -> str: |
| |
124 | + if not ts: |
| |
125 | + return date.today().isoformat() |
| |
126 | + return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() |
| |
127 | + |
| |
128 | + |
| |
129 | +def _cuisine_label(slug: str) -> str: |
| |
130 | + return (slug or "").replace("-", " ").strip().capitalize() |
| |
131 | + |
| |
132 | + |
| |
133 | +def _parse_row(r) -> dict: |
| |
134 | + d = dict(r) |
| |
135 | + for k in ("cuisines", "services", "dietary_options", "languages", "images"): |
| |
136 | + d[k] = json.loads(d.get(k) or "[]") |
| |
137 | + return d |
| |
138 | + |
| |
139 | + |
| |
140 | +def _resto_li(r) -> str: |
| |
141 | + """Un restaurant dans une liste HTML serveur.""" |
| |
142 | + bits = [b for b in (", ".join(_cuisine_label(c) for c in |
| |
143 | + json.loads(r["cuisines"] or "[]")[:3]), |
| |
144 | + r["city"], r["price_range"]) if b] |
| |
145 | + return (f'<li><a href="/resto/{_e(r["uid"])}">{_e(r["name"] or r["uid"])}</a>' |
| |
146 | + f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') |
| |
147 | + |
| |
148 | + |
| |
149 | +def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
| |
150 | + return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
| |
151 | + "itemListElement": [ |
| |
152 | + {"@type": "ListItem", "position": i + 1, "name": name, |
| |
153 | + "item": BASE_URL + path} |
| |
154 | + for i, (name, path) in enumerate(items)]} |
| |
155 | + |
| |
156 | + |
| |
157 | +def _not_found(message: str, path: str) -> HTMLResponse: |
| |
158 | + """404 HTML : le shell React est servi (la SPA affichera sa page), mais le |
| |
159 | + statut et le contenu serveur disent clairement « introuvable » aux bots.""" |
| |
160 | + return _render(title="Page introuvable | Resto-Ka", |
| |
161 | + description="Cette page n'existe pas sur Resto-Ka.", |
| |
162 | + path=path, |
| |
163 | + body=f"<h1>{_e(message)}</h1>" |
| |
164 | + '<p><a href="/">Voir tous les restaurants du Québec</a></p>', |
| |
165 | + status=404) |
| |
166 | + |
| |
167 | + |
| |
168 | +# --- robots.txt & sitemaps ---------------------------------------------------- |
| |
169 | + |
| |
170 | +@router.get("/robots.txt", include_in_schema=False) |
| |
171 | +def robots() -> PlainTextResponse: |
| |
172 | + return PlainTextResponse( |
| |
173 | + "User-agent: *\n" |
| |
174 | + "Allow: /\n" |
| |
175 | + "Disallow: /api/\n" |
| |
176 | + "Disallow: /favoris\n" |
| |
177 | + "Disallow: /docs\n" |
| |
178 | + "Disallow: /openapi.json\n" |
| |
179 | + f"\nSitemap: {BASE_URL}/sitemap.xml\n") |
| |
180 | + |
| |
181 | + |
| |
182 | +def _xml(content: str) -> Response: |
| |
183 | + return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content, |
| |
184 | + media_type="application/xml", |
| |
185 | + headers={"Cache-Control": "public, max-age=3600"}) |
| |
186 | + |
| |
187 | + |
| |
188 | +def _urlset(urls: list[tuple[str, str | None]]) -> Response: |
| |
189 | + rows = [] |
| |
190 | + for loc, lastmod in urls: |
| |
191 | + lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else "" |
| |
192 | + rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>") |
| |
193 | + return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' |
| |
194 | + + "\n".join(rows) + "\n</urlset>") |
| |
195 | + |
| |
196 | + |
| |
197 | +@router.get("/sitemap.xml", include_in_schema=False) |
| |
198 | +def sitemap_index(): |
| |
199 | + con = db.connect() |
| |
200 | + total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] |
| |
201 | + con.close() |
| |
202 | + chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
| |
203 | + names = ["sitemap-pages.xml"] + [ |
| |
204 | + f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)] |
| |
205 | + today = date.today().isoformat() |
| |
206 | + rows = "\n".join( |
| |
207 | + f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>" |
| |
208 | + for n in names) |
| |
209 | + return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' |
| |
210 | + + rows + "\n</sitemapindex>") |
| |
211 | + |
| |
212 | + |
| |
213 | +@router.get("/sitemap-pages.xml", include_in_schema=False) |
| |
214 | +def sitemap_pages(): |
| |
215 | + urls: list[tuple[str, str | None]] = [ |
| |
216 | + (f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None), |
| |
217 | + (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)] |
| |
218 | + return _urlset(urls) |
| |
219 | + |
| |
220 | + |
| |
221 | +@router.get("/sitemap-restos-{num}.xml", include_in_schema=False) |
| |
222 | +def sitemap_restos(num: int): |
| |
223 | + if num < 1: |
| |
224 | + raise HTTPException(404) |
| |
225 | + con = db.connect() |
| |
226 | + rows = con.execute( |
| |
227 | + f"""SELECT uid, updated_at FROM restaurants WHERE {ACTIVE} |
| |
228 | + ORDER BY uid LIMIT ? OFFSET ?""", |
| |
229 | + (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() |
| |
230 | + con.close() |
| |
231 | + if not rows: |
| |
232 | + raise HTTPException(404) |
| |
233 | + return _urlset([(f"{BASE_URL}/resto/{r['uid']}", _iso(r["updated_at"])) |
| |
234 | + for r in rows]) |
| |
235 | + |
| |
236 | + |
| |
237 | +# --- Pages SSR ---------------------------------------------------------------- |
| |
238 | + |
| |
239 | +@router.get("/", include_in_schema=False) |
| |
240 | +def home_ssr(): |
| |
241 | + con = db.connect() |
| |
242 | + total = con.execute( |
| |
243 | + f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] |
| |
244 | + with_menu = con.execute( |
| |
245 | + f"""SELECT COUNT(DISTINCT m.uid) c FROM menus m |
| |
246 | + JOIN restaurants r ON r.uid=m.uid WHERE r.{ACTIVE}""").fetchone()["c"] |
| |
247 | + regions = con.execute( |
| |
248 | + f"""SELECT region, COUNT(*) n FROM restaurants |
| |
249 | + WHERE {ACTIVE} AND region<>'' |
| |
250 | + GROUP BY region ORDER BY n DESC""").fetchall() |
| |
251 | + recents = con.execute( |
| |
252 | + f"""SELECT r.uid, r.name, r.city, r.cuisines, r.price_range |
| |
253 | + FROM restaurants r JOIN menus m ON m.uid=r.uid |
| |
254 | + WHERE r.{ACTIVE} |
| |
255 | + GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall() |
| |
256 | + con.close() |
| |
257 | + |
| |
258 | + title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : " |
| |
259 | + "menus complets et prix réels") |
| |
260 | + description = (f"{_fmt_n(total)} restaurants recensés dans les 17 régions du " |
| |
261 | + f"Québec, dont {_fmt_n(with_menu)} avec menu complet et prix " |
| |
262 | + "réels — comparables et cherchables : Montréal, Québec, Laval, " |
| |
263 | + "Gatineau et plus. Chaque resto, chaque plat, chaque prix.") |
| |
264 | + body = ( |
| |
265 | + f"<h1>Restaurants du Québec — {_fmt_n(total)} établissements, " |
| |
266 | + f"{_fmt_n(with_menu)} menus avec prix réels</h1>" |
| |
267 | + + "<p>Resto-Ka agrège les restaurants des 17 régions du Québec avec " |
| |
268 | + "leurs menus complets et leurs prix réels, toujours étiquetés selon " |
| |
269 | + "leur contexte (salle, emporter, livraison), avec photos, coordonnées " |
| |
270 | + "et cuisines.</p>" |
| |
271 | + + "<h2>Restaurants par région</h2><ul>" |
| |
272 | + + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>" |
| |
273 | + for r in regions) |
| |
274 | + + "</ul><h2>Menus récemment mis à jour</h2><ul>" |
| |
275 | + + "".join(_resto_li(r) for r in recents) |
| |
276 | + + "</ul>" |
| |
277 | + + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des ' |
| |
278 | + 'données</a> · <a href="/contact">Contact</a></p>') |
| |
279 | + jsonld = [ |
| |
280 | + {"@context": "https://schema.org", "@type": "WebSite", |
| |
281 | + "name": SITE_NAME, "url": BASE_URL + "/", |
| |
282 | + "description": description, "inLanguage": "fr-CA"}, |
| |
283 | + {"@context": "https://schema.org", "@type": "Organization", |
| |
284 | + "name": "Resto-Ka (Groupe KA)", "url": BASE_URL + "/"}] |
| |
285 | + return _render(title=title, description=description, path="/", |
| |
286 | + jsonld=jsonld, body=body) |
| |
287 | + |
| |
288 | + |
| |
289 | +@router.get("/resto/{uid:path}", include_in_schema=False) |
| |
290 | +def resto_ssr(uid: str): |
| |
291 | + con = db.connect() |
| |
292 | + row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone() |
| |
293 | + if row is None: |
| |
294 | + con.close() |
| |
295 | + return _not_found("Restaurant introuvable", f"/resto/{uid}") |
| |
296 | + d = _parse_row(row) |
| |
297 | + if not d["active"]: |
| |
298 | + con.close() |
| |
299 | + return _render( |
| |
300 | + title="Restaurant retiré | Resto-Ka", |
| |
301 | + description="Ce restaurant n'est plus recensé sur Resto-Ka.", |
| |
302 | + path=f"/resto/{uid}", |
| |
303 | + body="<h1>Ce restaurant n'est plus recensé</h1>" |
| |
304 | + '<p><a href="/">Voir tous les restaurants du Québec</a></p>', |
| |
305 | + status=410) |
| |
306 | + |
| |
307 | + # meilleur menu disponible (dine-in > takeout > delivery) |
| |
308 | + menu = None |
| |
309 | + for m in con.execute( |
| |
310 | + "SELECT price_context, captured_at, item_count, sections" |
| |
311 | + " FROM menus WHERE uid=?", (uid,)): |
| |
312 | + if menu and _CTX_ORDER.get(menu["price_context"], 9) <= \ |
| |
313 | + _CTX_ORDER.get(m["price_context"], 9): |
| |
314 | + continue |
| |
315 | + menu = dict(m) |
| |
316 | + con.close() |
| |
317 | + |
| |
318 | + name = d["name"] or "Restaurant" |
| |
319 | + cuisines = [_cuisine_label(c) for c in d["cuisines"][:4]] |
| |
320 | + where = d["city"] or d["region"] or "Québec" |
| |
321 | + title = (f"{name} — " |
| |
322 | + + (f"menu et prix, " if menu else "") |
| |
323 | + + f"restaurant à {where} | Resto-Ka") |
| |
324 | + desc_bits = [b for b in (", ".join(cuisines), d["address"], d["city"], |
| |
325 | + d["price_range"], d["phone"]) if b] |
| |
326 | + description = (f"{name} : " + " · ".join(desc_bits) + ". " |
| |
327 | + + (f"Menu complet ({menu['item_count']} plats, " |
| |
328 | + f"{_CTX_LABEL.get(menu['price_context'], menu['price_context'])}) " |
| |
329 | + "avec prix réels sur Resto-Ka." |
| |
330 | + if menu and menu.get("item_count") |
| |
331 | + else "Fiche complète sur Resto-Ka, l'agrégateur des " |
| |
332 | + "restaurants du Québec."))[:300] |
| |
333 | + |
| |
334 | + body = [f"<h1>{_e(name)}</h1>"] |
| |
335 | + facts = [("Adresse", ", ".join(b for b in (d["address"], d["city"], |
| |
336 | + d["postal_code"]) if b)), |
| |
337 | + ("Région", d["region"]), |
| |
338 | + ("Cuisine", ", ".join(cuisines)), |
| |
339 | + ("Type", d["establishment_type"]), |
| |
340 | + ("Fourchette de prix", d["price_range"]), |
| |
341 | + ("Téléphone", d["phone"])] |
| |
342 | + body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>" |
| |
343 | + for k, v in facts if v) + "</ul>") |
| |
344 | + if menu: |
| |
345 | + sections = json.loads(menu.get("sections") or "[]") |
| |
346 | + body.append(f"<h2>Menu ({_e(_CTX_LABEL.get(menu['price_context'], menu['price_context']))}" |
| |
347 | + + (f", {menu['item_count']} plats" if menu.get("item_count") else "") |
| |
348 | + + ")</h2>") |
| |
349 | + lis = [] |
| |
350 | + for sec in sections[:10]: |
| |
351 | + items = sec.get("items") or [] |
| |
352 | + prices = [it["price"] for it in items |
| |
353 | + if isinstance(it.get("price"), (int, float)) and it["price"] > 0] |
| |
354 | + rng = (f" — {_fmt_price(min(prices))} à {_fmt_price(max(prices))}" |
| |
355 | + if prices else "") |
| |
356 | + lis.append(f"<li>{_e(sec.get('name') or 'Section')} " |
| |
357 | + f"({len(items)} items){_e(rng)}</li>") |
| |
358 | + if lis: |
| |
359 | + body.append("<ul>" + "".join(lis) + "</ul>") |
| |
360 | + if d["website"]: |
| |
361 | + body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">' |
| |
362 | + "Site web du restaurant</a></p>") |
| |
363 | + body.append('<p><a href="/">Tous les restaurants du Québec</a></p>') |
| |
364 | + |
| |
365 | + resto_ld: dict = { |
| |
366 | + "@context": "https://schema.org", "@type": "Restaurant", |
| |
367 | + "name": name, "url": f"{BASE_URL}/resto/{uid}", |
| |
368 | + "address": {"@type": "PostalAddress", |
| |
369 | + "streetAddress": d["address"] or None, |
| |
370 | + "addressLocality": d["city"] or None, |
| |
371 | + "postalCode": d["postal_code"] or None, |
| |
372 | + "addressRegion": "QC", "addressCountry": "CA"}} |
| |
373 | + if d["lat"] and d["lng"]: |
| |
374 | + resto_ld["geo"] = {"@type": "GeoCoordinates", |
| |
375 | + "latitude": d["lat"], "longitude": d["lng"]} |
| |
376 | + if cuisines: |
| |
377 | + resto_ld["servesCuisine"] = cuisines |
| |
378 | + if d["phone"]: |
| |
379 | + resto_ld["telephone"] = d["phone"] |
| |
380 | + if d["price_range"]: |
| |
381 | + resto_ld["priceRange"] = d["price_range"] |
| |
382 | + if d["images"]: |
| |
383 | + resto_ld["image"] = d["images"][:5] |
| |
384 | + if d["website"]: |
| |
385 | + resto_ld["sameAs"] = [d["website"]] |
| |
386 | + if menu: |
| |
387 | + resto_ld["hasMenu"] = f"{BASE_URL}/resto/{uid}" |
| |
388 | + crumbs = [("Accueil", "/"), (name, f"/resto/{uid}")] |
| |
389 | + # doublon inter-sources : la fiche canonique est celle du uid maître |
| |
390 | + canonical_path = f"/resto/{d['dup_of']}" if d.get("dup_of") else None |
| |
391 | + return _render(title=title, description=description, path=f"/resto/{uid}", |
| |
392 | + canonical_path=canonical_path, |
| |
393 | + jsonld=[resto_ld, _breadcrumb(crumbs)], body="".join(body), |
| |
394 | + og_image=d["images"][0] if d["images"] else None) |
| |
395 | + |
| |
396 | + |
| |
397 | +# pages statiques de l'app : head unique, contenu rendu par React |
| |
398 | +_STATIC_META = { |
| |
399 | + "/stats": ("Statistiques des restaurants du Québec | Resto-Ka", |
| |
400 | + "Restaurants recensés, menus capturés et prix réels par région et " |
| |
401 | + "par contexte (salle, emporter, livraison) : les statistiques du " |
| |
402 | + "paysage restaurant québécois, calculées en continu par Resto-Ka."), |
| |
403 | + "/sources": ("Sources des données | Resto-Ka", |
| |
404 | + "Les sources publiques et plateformes dont Resto-Ka agrège les " |
| |
405 | + "restaurants et les menus du Québec, avec le nombre " |
| |
406 | + "d'établissements recensés pour chacune."), |
| |
407 | + "/contact": ("Contact | Resto-Ka", |
| |
408 | + "Joindre l'équipe de Resto-Ka, l'agrégateur des restaurants du " |
| |
409 | + "Québec : menus complets, prix réels et fiches des 17 régions."), |
| |
410 | +} |
| |
411 | + |
| |
412 | + |
| |
413 | +def _static_page(path: str): |
| |
414 | + title, description = _STATIC_META[path] |
| |
415 | + return _render(title=title, description=description, path=path, |
| |
416 | + jsonld=[_breadcrumb([("Accueil", "/"), |
| |
417 | + (title.split(" | ")[0], path)])]) |
| |
418 | + |
| |
419 | + |
| |
420 | +@router.get("/stats", include_in_schema=False) |
| |
421 | +def stats_page(): |
| |
422 | + return _static_page("/stats") |
| |
423 | + |
| |
424 | + |
| |
425 | +@router.get("/sources", include_in_schema=False) |
| |
426 | +def sources_page(): |
| |
427 | + return _static_page("/sources") |
| |
428 | + |
| |
429 | + |
| |
430 | +@router.get("/contact", include_in_schema=False) |
| |
431 | +def contact_page(): |
| |
432 | + return _static_page("/contact") |