SPB Git

spb/resto-ka

Public

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

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 (Aug 22, 2026) parent 23175ab Browse files

3 changed files +438 −1

modified frontend/index.html +1 −1
@@ -4,7 +4,7 @@
4 File: index.html 4 File: index.html
5 Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix). 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 <head> 8 <head>
9 <meta charset="UTF-8" /> 9 <meta charset="UTF-8" />
10 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> 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 from . import auth # noqa: E402 (import tardif : évite le cycle web<->auth) 42 from . import auth # noqa: E402 (import tardif : évite le cycle web<->auth)
43 app.include_router(auth.router) 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 def _row_to_dict(row) -> dict: 51 def _row_to_dict(row) -> dict:
47 d = dict(row) 52 d = dict(row)
48 53
Menu

Theme