# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/seo.py # Desc: Référencement — SSR léger, robots.txt, sitemaps. Calqué louka/seo.py. # # Principe : pour chaque route publique, le serveur renvoie le MÊME index.html # que le build Vite, mais avec un unique (title, description, canonical, # og:, JSON-LD) et le contenu essentiel en HTML DANS
. Les # moteurs de recherche voient une page complète sans exécuter JavaScript ; # React, en se montant, remplace ce contenu par l'application interactive. # ============================================================================== from __future__ import annotations import html import json import math import re import time import unicodedata from datetime import date, datetime, timezone from pathlib import Path from xml.sax.saxutils import escape as xml_escape from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import db from .normalize import PRICE_CONTEXTS router = APIRouter() ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" BASE_URL = "https://www.resto-ka.com" SITE_NAME = "Resto-Ka" SITEMAP_CHUNK = 10000 # restos « canoniques » : actifs et non-doublons inter-sources ACTIVE = "active=1 AND dup_of IS NULL" # seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues) MIN_RESTOS_PAGE = 3 _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} _CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter", "delivery": "prix en livraison"} # --- Gabarit (index.html du build Vite) -------------------------------------- _shell_cache: dict = {"mtime": 0.0, "html": ""} def _shell() -> str: f = FRONTEND_DIST / "index.html" mtime = f.stat().st_mtime if mtime != _shell_cache["mtime"]: _shell_cache["html"] = f.read_text(encoding="utf-8") _shell_cache["mtime"] = mtime return _shell_cache["html"] def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None, body: str = "", og_image: str | None = None, canonical_path: str | None = None, status: int = 200) -> HTMLResponse: """index.html du build + head unique + contenu HTML dans #root.""" canonical = BASE_URL + (canonical_path or path) page = _shell() page = re.sub(r".*?", lambda _m: f"{html.escape(title)}", page, count=1, flags=re.S) page = re.sub(r']*/>', lambda _m: f'', page, count=1) # retire du gabarit statique les meta og:image/twitter (re-injectées ci-dessous) page = re.sub(r'\s*]*/>', "", page) extras = [ f'', f'', f'', f'', '', '', f'', f'', f'', '', f'', ] img = og_image or (BASE_URL + "/og.png") extras.append(f'') if not og_image: extras.append('') extras.append('') extras.append(f'') for obj in (jsonld or []): blob = json.dumps(obj, ensure_ascii=False).replace("{blob}') page = page.replace("", " " + "\n ".join(extras) + "\n", 1) if body: seo_div = ('
' + body + '

Resto-Ka — Un service Groupe KA

' + "
") page = page.replace('
', '
' + seo_div, 1) return HTMLResponse(page, status_code=status, headers={"Cache-Control": "no-cache"}) def _e(t) -> str: return html.escape(str(t or "")) def _fmt_n(n: int) -> str: return f"{n:,}".replace(",", " ") def _fmt_price(p) -> str: if p is None: return "" s = f"{p:.2f}".rstrip("0").rstrip(".") return s.replace(".", ",") + " $" def _iso(ts) -> str: if not ts: return date.today().isoformat() return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() def _cuisine_label(slug: str) -> str: return (slug or "").replace("-", " ").strip().capitalize() def _parse_row(r) -> dict: d = dict(r) for k in ("cuisines", "services", "dietary_options", "languages", "images"): d[k] = json.loads(d.get(k) or "[]") return d def _resto_li(r) -> str: """Un restaurant dans une liste HTML serveur.""" bits = [b for b in (", ".join(_cuisine_label(c) for c in json.loads(r["cuisines"] or "[]")[:3]), r["city"], r["price_range"]) if b] return (f'
  • {_e(r["name"] or r["uid"])}' f'{" — " + _e(" · ".join(bits)) if bits else ""}
  • ') def slugify(text: str) -> str: """« Trois-Rivières » → trois-rivieres, « L'Île-Perrot » → l-ile-perrot.""" t = text.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae") t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") return t _villes_cache: dict = {"ts": 0.0, "by_slug": {}} def _villes_index() -> dict[str, dict]: """slug → {city (libellé majoritaire), cities (variantes), n, with_menu} — les variantes de casse/accents d'une même ville sont regroupées par slug. Reconstruit au plus toutes les 10 minutes.""" if time.time() - _villes_cache["ts"] > 600: con = db.connect() rows = con.execute( f"""SELECT city, COUNT(*) n, SUM(CASE WHEN uid IN (SELECT uid FROM menus) THEN 1 ELSE 0 END) with_menu FROM restaurants WHERE {ACTIVE} AND city<>'' GROUP BY city""").fetchall() con.close() by_slug: dict[str, dict] = {} for r in rows: s = slugify(r["city"]) if not s: continue e = by_slug.setdefault(s, {"slug": s, "city": r["city"], "cities": [], "n": 0, "with_menu": 0, "_best": -1}) e["cities"].append(r["city"]) e["n"] += r["n"] e["with_menu"] += r["with_menu"] or 0 if r["n"] > e["_best"]: e["_best"] = r["n"] e["city"] = r["city"] _villes_cache["by_slug"] = by_slug _villes_cache["ts"] = time.time() return _villes_cache["by_slug"] def _villes_rows(_con=None, minimum: int = 1) -> list[dict]: rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"], "with_menu": e["with_menu"]} for e in _villes_index().values() if e["n"] >= minimum] return sorted(rows, key=lambda r: -r["n"]) def _breadcrumb(items: list[tuple[str, str]]) -> dict: return {"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": name, "item": BASE_URL + path} for i, (name, path) in enumerate(items)]} def _not_found(message: str, path: str) -> HTMLResponse: """404 HTML : le shell React est servi (la SPA affichera sa page), mais le statut et le contenu serveur disent clairement « introuvable » aux bots.""" return _render(title="Page introuvable | Resto-Ka", description="Cette page n'existe pas sur Resto-Ka.", path=path, body=f"

    {_e(message)}

    " '

    Voir tous les restaurants du Québec

    ', status=404) # --- API JSON pour les pages villes du frontend -------------------------------- @router.get("/api/seo/villes") def api_villes(): con = db.connect() rows = _villes_rows(con) con.close() return {"villes": rows} @router.get("/api/seo/ville/{slug}") def api_ville(slug: str): e = _villes_index().get(slug) if not e: raise HTTPException(404, "Ville inconnue") neighbors = [v for v in _villes_rows(minimum=MIN_RESTOS_PAGE) if v["slug"] != slug][:12] return {"city": e["city"], "slug": slug, "n": e["n"], "with_menu": e["with_menu"], "neighbors": neighbors} # --- robots.txt & sitemaps ---------------------------------------------------- @router.get("/robots.txt", include_in_schema=False) def robots() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Allow: /\n" "Disallow: /api/\n" "Disallow: /favoris\n" "Disallow: /docs\n" "Disallow: /openapi.json\n" f"\nSitemap: {BASE_URL}/sitemap.xml\n") def _xml(content: str) -> Response: return Response('\n' + content, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"}) def _urlset(urls: list[tuple[str, str | None]]) -> Response: rows = [] for loc, lastmod in urls: lm = f"{lastmod}" if lastmod else "" rows.append(f"{xml_escape(loc)}{lm}") return _xml('\n' + "\n".join(rows) + "\n") @router.get("/sitemap.xml", include_in_schema=False) def sitemap_index(): con = db.connect() total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] con.close() chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)] today = date.today().isoformat() rows = "\n".join( f"{BASE_URL}/{n}{today}" for n in names) return _xml('\n' + rows + "\n") @router.get("/sitemap-pages.xml", include_in_schema=False) def sitemap_pages(): urls: list[tuple[str, str | None]] = [ (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)] return _urlset(urls) @router.get("/sitemap-villes.xml", include_in_schema=False) def sitemap_villes(): con = db.connect() villes = _villes_rows(con, MIN_RESTOS_PAGE) con.close() today = date.today().isoformat() return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes]) @router.get("/sitemap-restos-{num}.xml", include_in_schema=False) def sitemap_restos(num: int): if num < 1: raise HTTPException(404) con = db.connect() rows = con.execute( f"""SELECT uid, updated_at FROM restaurants WHERE {ACTIVE} ORDER BY uid LIMIT ? OFFSET ?""", (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() con.close() if not rows: raise HTTPException(404) return _urlset([(f"{BASE_URL}/resto/{r['uid']}", _iso(r["updated_at"])) for r in rows]) # --- Pages SSR ---------------------------------------------------------------- @router.get("/", include_in_schema=False) def home_ssr(): con = db.connect() total = con.execute( f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] with_menu = con.execute( f"""SELECT COUNT(DISTINCT m.uid) c FROM menus m JOIN restaurants r ON r.uid=m.uid WHERE r.{ACTIVE}""").fetchone()["c"] regions = con.execute( f"""SELECT region, COUNT(*) n FROM restaurants WHERE {ACTIVE} AND region<>'' GROUP BY region ORDER BY n DESC""").fetchall() recents = con.execute( f"""SELECT r.uid, r.name, r.city, r.cuisines, r.price_range FROM restaurants r JOIN menus m ON m.uid=r.uid WHERE r.{ACTIVE} GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall() villes = _villes_rows(con, MIN_RESTOS_PAGE) con.close() title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : " "menus complets et prix réels") description = (f"{_fmt_n(total)} restaurants recensés dans les 17 régions du " f"Québec, dont {_fmt_n(with_menu)} avec menu complet et prix " "réels — comparables et cherchables : Montréal, Québec, Laval, " "Gatineau et plus. Chaque resto, chaque plat, chaque prix.") body = ( f"

    Restaurants du Québec — {_fmt_n(total)} établissements, " f"{_fmt_n(with_menu)} menus avec prix réels

    " + "

    Resto-Ka agrège les restaurants des 17 régions du Québec avec " "leurs menus complets et leurs prix réels, toujours étiquetés selon " "leur contexte (salle, emporter, livraison), avec photos, coordonnées " "et cuisines.

    " + "

    Restaurants par région

      " + "".join(f"
    • {_e(r['region'])} — {_fmt_n(r['n'])} restaurants
    • " for r in regions) + "

    Restaurants par ville

    Toutes les villes ({len(villes)})

    ' + "

    Menus récemment mis à jour

      " + "".join(_resto_li(r) for r in recents) + "
    " + '

    Statistiques · Sources des ' 'données · Contact

    ') jsonld = [ {"@context": "https://schema.org", "@type": "WebSite", "name": SITE_NAME, "url": BASE_URL + "/", "description": description, "inLanguage": "fr-CA"}, {"@context": "https://schema.org", "@type": "Organization", "name": "Resto-Ka (Groupe KA)", "url": BASE_URL + "/"}] return _render(title=title, description=description, path="/", jsonld=jsonld, body=body) @router.get("/resto/{uid:path}", include_in_schema=False) def resto_ssr(uid: str): con = db.connect() row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone() if row is None: con.close() return _not_found("Restaurant introuvable", f"/resto/{uid}") d = _parse_row(row) if not d["active"]: con.close() return _render( title="Restaurant retiré | Resto-Ka", description="Ce restaurant n'est plus recensé sur Resto-Ka.", path=f"/resto/{uid}", body="

    Ce restaurant n'est plus recensé

    " '

    Voir tous les restaurants du Québec

    ', status=410) # meilleur menu disponible (dine-in > takeout > delivery) menu = None for m in con.execute( "SELECT price_context, captured_at, item_count, sections" " FROM menus WHERE uid=?", (uid,)): if menu and _CTX_ORDER.get(menu["price_context"], 9) <= \ _CTX_ORDER.get(m["price_context"], 9): continue menu = dict(m) con.close() name = d["name"] or "Restaurant" cuisines = [_cuisine_label(c) for c in d["cuisines"][:4]] where = d["city"] or d["region"] or "Québec" title = (f"{name} — " + (f"menu et prix, " if menu else "") + f"restaurant à {where} | Resto-Ka") desc_bits = [b for b in (", ".join(cuisines), d["address"], d["city"], d["price_range"], d["phone"]) if b] description = (f"{name} : " + " · ".join(desc_bits) + ". " + (f"Menu complet ({menu['item_count']} plats, " f"{_CTX_LABEL.get(menu['price_context'], menu['price_context'])}) " "avec prix réels sur Resto-Ka." if menu and menu.get("item_count") else "Fiche complète sur Resto-Ka, l'agrégateur des " "restaurants du Québec."))[:300] body = [f"

    {_e(name)}

    "] facts = [("Adresse", ", ".join(b for b in (d["address"], d["city"], d["postal_code"]) if b)), ("Région", d["region"]), ("Cuisine", ", ".join(cuisines)), ("Type", d["establishment_type"]), ("Fourchette de prix", d["price_range"]), ("Téléphone", d["phone"])] body.append("
      " + "".join(f"
    • {k} : {_e(v)}
    • " for k, v in facts if v) + "
    ") if menu: sections = json.loads(menu.get("sections") or "[]") body.append(f"

    Menu ({_e(_CTX_LABEL.get(menu['price_context'], menu['price_context']))}" + (f", {menu['item_count']} plats" if menu.get("item_count") else "") + ")

    ") lis = [] for sec in sections[:10]: items = sec.get("items") or [] prices = [it["price"] for it in items if isinstance(it.get("price"), (int, float)) and it["price"] > 0] rng = (f" — {_fmt_price(min(prices))} à {_fmt_price(max(prices))}" if prices else "") lis.append(f"
  • {_e(sec.get('name') or 'Section')} " f"({len(items)} items){_e(rng)}
  • ") if lis: body.append("
      " + "".join(lis) + "
    ") if d["website"]: body.append(f'

    ' "Site web du restaurant

    ") if d["city"]: body.append(f'

    ' f"Autres restaurants à {_e(d['city'])}

    ") body.append('

    Tous les restaurants du Québec

    ') resto_ld: dict = { "@context": "https://schema.org", "@type": "Restaurant", "name": name, "url": f"{BASE_URL}/resto/{uid}", "address": {"@type": "PostalAddress", "streetAddress": d["address"] or None, "addressLocality": d["city"] or None, "postalCode": d["postal_code"] or None, "addressRegion": "QC", "addressCountry": "CA"}} if d["lat"] and d["lng"]: resto_ld["geo"] = {"@type": "GeoCoordinates", "latitude": d["lat"], "longitude": d["lng"]} if cuisines: resto_ld["servesCuisine"] = cuisines if d["phone"]: resto_ld["telephone"] = d["phone"] if d["price_range"]: resto_ld["priceRange"] = d["price_range"] if d["images"]: resto_ld["image"] = d["images"][:5] if d["website"]: resto_ld["sameAs"] = [d["website"]] if menu: resto_ld["hasMenu"] = f"{BASE_URL}/resto/{uid}" crumbs = [("Accueil", "/"), (name, f"/resto/{uid}")] # doublon inter-sources : la fiche canonique est celle du uid maître canonical_path = f"/resto/{d['dup_of']}" if d.get("dup_of") else None return _render(title=title, description=description, path=f"/resto/{uid}", canonical_path=canonical_path, jsonld=[resto_ld, _breadcrumb(crumbs)], body="".join(body), og_image=d["images"][0] if d["images"] else None) # --- Pages programmatiques villes : /villes + /ville/{slug} ------------------- @router.get("/villes", include_in_schema=False) def villes_ssr(): con = db.connect() villes = _villes_rows(con) con.close() total = sum(v["n"] for v in villes) title = f"Restaurants par ville au Québec ({len(villes)} villes) | Resto-Ka" description = ( f"Toutes les villes du Québec où Resto-Ka recense des restaurants : " f"{_fmt_n(total)} établissements dans {len(villes)} villes, avec menus " "complets et prix réels quand ils sont publiés.") body = (f"

    Restaurants par ville — {len(villes)} villes au Québec

      " + "".join( f'
    • Restaurants à {_e(v["city"])}' f' — {_fmt_n(v["n"])} établissements' + (f", {_fmt_n(v['with_menu'])} menus" if v["with_menu"] else "") + "
    • " for v in villes) + "
    ") jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Restaurants par ville au Québec", "numberOfItems": len(villes), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": f"Restaurants à {v['city']}", "url": f"{BASE_URL}/ville/{v['slug']}"} for i, v in enumerate(villes[:100])]}] return _render(title=title, description=description, path="/villes", jsonld=jsonld, body=body) @router.get("/ville/{slug}", include_in_schema=False) def ville_ssr(slug: str): path = f"/ville/{slug}" e = _villes_index().get(slug) if not e: return _not_found("Aucun restaurant recensé pour cette ville", path) city, n, with_menu = e["city"], e["n"], e["with_menu"] marks = ",".join("?" * len(e["cities"])) con = db.connect() rows = con.execute( f"""SELECT uid, name, city, cuisines, price_range FROM restaurants WHERE {ACTIVE} AND city IN ({marks}) ORDER BY uid IN (SELECT uid FROM menus) DESC, updated_at DESC LIMIT 100""", e["cities"]).fetchall() con.close() neighbors = [v for v in _villes_rows(minimum=MIN_RESTOS_PAGE) if v["slug"] != slug][:12] if n == 0: return _not_found(f"Aucun restaurant actif à {city}", path) title = f"Restaurants à {city} — {_fmt_n(n)} établissements | Resto-Ka" description = ( f"{_fmt_n(n)} restaurants à {city}" + (f", dont {_fmt_n(with_menu)} avec menu complet et prix réels" if with_menu else "") + ". Cuisines, coordonnées, fourchettes de prix et menus, " "agrégés et mis à jour en continu par Resto-Ka.") body = [f"

    Restaurants à {_e(city)} — {_fmt_n(n)} établissements

    "] if with_menu: body.append(f"

    {_fmt_n(with_menu)} restaurants de {_e(city)} ont leur " "menu complet avec prix réels sur Resto-Ka.

    ") body.append("

    Établissements

      " + "".join(_resto_li(r) for r in rows) + "
    ") if neighbors: body.append("

    Autres villes

    ") body.append('

    Toutes les villes · ' 'Tous les restaurants du Québec

    ') crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)] jsonld = [_breadcrumb(crumbs), {"@context": "https://schema.org", "@type": "ItemList", "name": f"Restaurants à {city}", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["name"] or r["uid"], "url": f"{BASE_URL}/resto/{r['uid']}"} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path=path, jsonld=jsonld, body="".join(body)) # pages statiques de l'app : head unique, contenu rendu par React _STATIC_META = { "/stats": ("Statistiques des restaurants du Québec | Resto-Ka", "Restaurants recensés, menus capturés et prix réels par région et " "par contexte (salle, emporter, livraison) : les statistiques du " "paysage restaurant québécois, calculées en continu par Resto-Ka."), "/sources": ("Sources des données | Resto-Ka", "Les sources publiques et plateformes dont Resto-Ka agrège les " "restaurants et les menus du Québec, avec le nombre " "d'établissements recensés pour chacune."), "/contact": ("Contact | Resto-Ka", "Joindre l'équipe de Resto-Ka, l'agrégateur des restaurants du " "Québec : menus complets, prix réels et fiches des 17 régions."), } def _static_page(path: str): title, description = _STATIC_META[path] return _render(title=title, description=description, path=path, jsonld=[_breadcrumb([("Accueil", "/"), (title.split(" | ")[0], path)])]) @router.get("/stats", include_in_schema=False) def stats_page(): return _static_page("/stats") @router.get("/sources", include_in_schema=False) def sources_page(): return _static_page("/sources") @router.get("/contact", include_in_schema=False) def contact_page(): return _static_page("/contact")