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