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 : SSR léger (accueil + fiches resto avec JSON-LD Restaurant), robots.txt, sitemaps chunkés, lang fr-CA

- restoka/seo.py (nouveau, calqué louka/seo.py) : head unique par route
  (title, description, canonical, hreflang fr-ca, og:* locale fr_CA,
  twitter, JSON-LD) + contenu essentiel serveur dans #root
- accueil : title/h1 avec compte de restos, régions, menus récents,
  WebSite + Organization
- fiche /resto/{uid} : JSON-LD Restaurant (PostalAddress QC/CA, geo,
  servesCuisine, telephone, priceRange, image) + BreadcrumbList,
  og:image = photo du resto, 404/410, canonical vers le uid maître
  pour les doublons inter-sources
- robots.txt (Disallow /api/, /favoris, /docs) + sitemap index,
  sitemap-pages et sitemap-restos-{n} chunkés à 10 000 (lastmod)
- pages statiques /stats /sources /contact : title/description uniques
- web.py : include_router(seo.router) avant le fallback SPA
- frontend/index.html : lang fr-CA
Simon-Pierre Boucher committed 1 mo ago (Aug 22, 2026) parent 23175ab

3 changed files +438 −1

modified frontend/index.html +1 −1
@@ -4,7 +4,7 @@
4 4 File: index.html
5 5 Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix).
6 6 ---------------------------------------------------------------------------- -->
7 <html lang="fr">
7 +<html lang="fr-CA">
8 8 <head>
9 9 <meta charset="UTF-8" />
10 10 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
added restoka/seo.py +432 −0
@@ -0,0 +1,432 @@
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")
modified restoka/web.py +5 −0
@@ -42,6 +42,11 @@ _sync_lock = threading.Lock()
42 42 from . import auth # noqa: E402 (import tardif : évite le cycle web<->auth)
43 43 app.include_router(auth.router)
44 44
45 +# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py
46 +# inclus AVANT le fallback SPA pour que ces routes aient priorité
47 +from . import seo # noqa: E402
48 +app.include_router(seo.router)
49 +
45 50
46 51 def _row_to_dict(row) -> dict:
47 52 d = dict(row)
48 53