# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # seo.py : référencement — SSR léger, pages programmatiques, robots, sitemaps # # 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 from datetime import date, datetime, timezone from pathlib import Path from urllib.parse import quote from xml.sax.saxutils import escape as xml_escape from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import db router = APIRouter() ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" SOURCES_PATH = ROOT / "data" / "sources.json" BASE_URL = "https://www.food-ka.com" SITE_NAME = "Food-Ka" SITEMAP_CHUNK = 10000 # --- 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, status: int = 200) -> HTMLResponse: """index.html du build + head unique + contenu HTML dans #root.""" canonical = BASE_URL + 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 toutes les meta og:/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 + '

Food-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_int(n) -> str: """50314 → « 50 314 » (fr-CA).""" return f"{n:,}".replace(",", " ") def _fmt_price(p) -> str: """4.99 → « 4,99 $ » (fr-CA).""" if p is None: return "" return f"{p:.2f}".replace(".", ",") + " $" def _iso(ts) -> str: if not ts: return date.today().isoformat() return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() # --- Données ------------------------------------------------------------------ _sources_cache: dict = {"mtime": 0.0, "byid": {}} def _sources_meta() -> dict[str, dict]: """id de bannière → {name, url, region…} (data/sources.json).""" mtime = SOURCES_PATH.stat().st_mtime if mtime != _sources_cache["mtime"]: data = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) srcs = data["sources"] if isinstance(data, dict) else data _sources_cache["byid"] = {s["id"]: s for s in srcs} _sources_cache["mtime"] = mtime return _sources_cache["byid"] def _source_name(sid: str) -> str: meta = _sources_meta().get(sid) return meta["name"] if meta else sid _facets_cache: dict = {"ts": 0.0, "categories": [], "sources": []} def _facets() -> tuple[list[dict], list[dict]]: """(catégories actives, bannières actives) avec compte et lastmod, TTL 10 min.""" if time.time() - _facets_cache["ts"] > 600: con = db.connect() _facets_cache["categories"] = [dict(r) for r in con.execute( """SELECT category, COUNT(*) n, MAX(updated_at) last FROM products WHERE active=1 AND category<>'' GROUP BY category ORDER BY n DESC""")] _facets_cache["sources"] = [dict(r) for r in con.execute( """SELECT source, COUNT(*) n, MAX(updated_at) last FROM products WHERE active=1 GROUP BY source ORDER BY n DESC""")] con.close() _facets_cache["ts"] = time.time() return _facets_cache["categories"], _facets_cache["sources"] def _parse_row(r) -> dict: d = dict(r) d["keywords"] = json.loads(d.get("keywords") or "[]") d["images"] = json.loads(d.get("images") or "[]") d["details"] = json.loads(d.get("details") or "{}") return d def _product_li(r) -> str: """Un produit dans une liste HTML serveur.""" label = r["name"] or r["uid"] bits = [b for b in (r["brand"], r["size_label"], _fmt_price(r["price"]), _source_name(r["source"])) if b] return (f'
  • {_e(label)}' f'{" — " + _e(" · ".join(bits)) if bits else ""}
  • ') 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 _uid_path(uid: str) -> str: return "/produit/" + quote(str(uid), safe=":") def _cat_path(category: str) -> str: return "/?category=" + quote(category) def _src_path(sid: str) -> str: return "/?source=" + quote(sid) 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 | Food-Ka", description="Cette page n'existe pas sur Food-Ka.", path=path, body=f"

    {_e(message)}

    " '

    Comparer les prix d\'épicerie au Québec · ' 'Voir les aubaines

    ', status=404) # --- 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: /profil\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("SELECT COUNT(*) c FROM products WHERE active=1").fetchone()["c"] con.close() chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) names = ["sitemap-pages.xml"] + [ f"sitemap-produits-{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}/aubaines", None), (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None), (f"{BASE_URL}/confidentialite", None)] categories, sources = _facets() for c in categories: urls.append((BASE_URL + _cat_path(c["category"]), _iso(c["last"]))) for s in sources: urls.append((BASE_URL + _src_path(s["source"]), _iso(s["last"]))) return _urlset(urls) @router.get("/sitemap-produits-{num}.xml", include_in_schema=False) def sitemap_produits(num: int): if num < 1: raise HTTPException(404) con = db.connect() rows = con.execute( """SELECT uid, updated_at FROM products WHERE active=1 ORDER BY uid LIMIT ? OFFSET ?""", (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() con.close() if not rows: raise HTTPException(404) return _urlset([(BASE_URL + _uid_path(r["uid"]), _iso(r["updated_at"])) for r in rows]) # --- Pages SSR ---------------------------------------------------------------- @router.get("/", include_in_schema=False) def home_ssr(category: str | None = None, source: str | None = None): """Accueil — et ses déclinaisons programmatiques /?category=… et /?source=… (mêmes URLs que les filtres de la page Accueil du frontend).""" categories, sources = _facets() path = "/" label_bits: list[str] = [] if category is not None: if category not in {c["category"] for c in categories}: return _not_found("Catégorie inconnue", _cat_path(category)) path = _cat_path(category) label_bits.append(category) if source is not None: if source not in {s["source"] for s in sources}: return _not_found("Bannière inconnue", _src_path(source)) if category is not None: path = _cat_path(category) + "&source=" + quote(source) else: path = _src_path(source) label_bits.append(_source_name(source)) con = db.connect() sql = "SELECT COUNT(*) c FROM products WHERE active=1" args: list = [] if category is not None: sql += " AND category=?"; args.append(category) if source is not None: sql += " AND source=?"; args.append(source) n = con.execute(sql, args).fetchone()["c"] lsql = """SELECT uid, name, brand, size_label, price, source FROM products WHERE active=1 AND price IS NOT NULL""" if category is not None: lsql += " AND category=?" if source is not None: lsql += " AND source=?" rows = con.execute(lsql + " ORDER BY updated_at DESC LIMIT 30", args).fetchall() total = con.execute("SELECT COUNT(*) c FROM products WHERE active=1").fetchone()["c"] nsale = con.execute( "SELECT COUNT(*) c FROM products WHERE active=1 AND on_sale=1").fetchone()["c"] con.close() nsources = len(sources) if label_bits: what = " chez ".join(label_bits) if (category and source) else label_bits[0] title = f"{what} — {n} produits d'épicerie comparés | Food-Ka" description = (f"{n} produits « {what} » recensés par Food-Ka dans les épiceries " "du Québec : prix courant, soldes et prix unitaire, avec lien direct " "vers la fiche de la bannière.") h1 = f"{what} — {n} produits comparés au Québec" else: title = (f"Food-Ka — Comparateur d'épicerie au Québec · {_fmt_int(total)} produits, " f"{nsources} bannières") description = (f"{_fmt_int(total)} produits d'épicerie comparés dans {nsources} bannières " f"du Québec (Metro, IGA, Maxi, Super C, Provigo…) dont {_fmt_int(nsale)} en " "solde : prix, circulaires et prix unitaires, toujours à jour.") h1 = f"Comparer les prix d'épicerie au Québec — {_fmt_int(total)} produits à jour" body = [f"

    {_e(h1)}

    "] if not label_bits: body.append(f"

    Food-Ka agrège en continu les prix de {nsources} bannières " "d'épicerie québécoises : chaque produit avec son prix courant, son " "prix unitaire comparable et ses soldes, plus un lien direct vers la " "fiche originale de la bannière.

    ") body.append(f'

    {_fmt_int(nsale)} produits en solde ' "en ce moment

    ") body.append("

    Produits par catégorie

    ") body.append("

    Bannières comparées

    ") else: body.append('

    Tous les produits d\'épicerie comparés · ' 'Aubaines

    ') body.append("

    Produits récents

      " + "".join(_product_li(r) for r in rows) + "
    ") jsonld: list[dict] = [] if not label_bits: 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": "Food-Ka (Groupe KA)", "url": BASE_URL + "/"}] else: jsonld = [ _breadcrumb([("Accueil", "/"), (what, path)]), {"@context": "https://schema.org", "@type": "ItemList", "name": f"{what} — produits d'épicerie au Québec", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["name"] or r["uid"], "url": BASE_URL + _uid_path(r["uid"])} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path=path, jsonld=jsonld, body="".join(body)) @router.get("/aubaines", include_in_schema=False) def aubaines_ssr(): con = db.connect() n = con.execute( "SELECT COUNT(*) c FROM products WHERE active=1 AND on_sale=1").fetchone()["c"] rows = con.execute( """SELECT uid, name, brand, size_label, price, regular_price, source FROM products WHERE active=1 AND on_sale=1 AND price IS NOT NULL AND regular_price IS NOT NULL ORDER BY (regular_price - price) / regular_price DESC LIMIT 30""").fetchall() con.close() title = f"Aubaines d'épicerie au Québec — {_fmt_int(n)} produits en solde | Food-Ka" description = (f"{_fmt_int(n)} produits d'épicerie en solde en ce moment dans les bannières du " "Québec, classés par rabais : prix courant vs prix régulier, " "avec lien direct vers la circulaire ou la fiche de la bannière.") body = (f"

    Aubaines d'épicerie au Québec — {_fmt_int(n)} produits en solde

    " + "

    Les meilleurs rabais du moment, toutes bannières confondues :

      " + "".join( f'
    • {_e(r["name"] or r["uid"])}' f' — {_fmt_price(r["price"])} (rég. {_fmt_price(r["regular_price"])})' f' · {_e(_source_name(r["source"]))}
    • ' for r in rows) + '

    Tous les produits comparés

    ') jsonld = [_breadcrumb([("Accueil", "/"), ("Aubaines", "/aubaines")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Aubaines d'épicerie au Québec", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["name"] or r["uid"], "url": BASE_URL + _uid_path(r["uid"])} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path="/aubaines", jsonld=jsonld, body=body) @router.get("/produit/{uid:path}", include_in_schema=False) def product_ssr(uid: str): con = db.connect() row = con.execute("SELECT * FROM products WHERE uid=?", (uid,)).fetchone() con.close() path = _uid_path(uid) if row is None: return _render(title="Produit introuvable | Food-Ka", description="Ce produit n'existe pas ou plus sur Food-Ka.", path=path, body="

    Produit introuvable

    " '

    Comparer les prix d\'épicerie au Québec

    ', status=404) d = _parse_row(row) src_name = _source_name(d["source"]) cat = d["category"] or "" if not d["active"]: # produit disparu chez la bannière : 410 Gone + lien vers la catégorie link = (f'Produits {_e(cat)}' if cat else 'Tous les produits') return _render( title="Produit retiré | Food-Ka", description="Ce produit n'est plus recensé chez la bannière.", path=path, body="

    Ce produit n'est plus disponible

    " f"

    Il n'apparaît plus au catalogue de {_e(src_name)}. {link}.

    ", status=410) name = d["name"] or "Produit d'épicerie" price_txt = _fmt_price(d["price"]) title_bits = [name] if d["brand"] and d["brand"] not in name: title_bits.append(d["brand"]) tail = f" à {price_txt} chez {src_name}" if price_txt else f" chez {src_name}" title = " ".join(title_bits) + tail + " | Food-Ka" desc_bits = [b for b in ( d["brand"], d["size_label"], (f"{price_txt}" + (f" (rég. {_fmt_price(d['regular_price'])})" if d["regular_price"] else "")) if price_txt else "", d["unit_price_label"], src_name) if b] description = ((" · ".join(desc_bits) + ". ") if desc_bits else "") + \ (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150 else (d["description"] or "")).strip() description = description[:300] or f"{name} chez {src_name} — prix comparé par Food-Ka." body = [f"

    {_e(name)}{' — ' + _e(d['brand']) if d['brand'] else ''}

    "] facts = [("Bannière", src_name), ("Marque", d["brand"]), ("Format", d["size_label"]), ("Prix", price_txt + (" (en solde)" if d["on_sale"] else "")), ("Prix régulier", _fmt_price(d["regular_price"])), ("Prix unitaire", d["unit_price_label"]), ("Catégorie", cat), ("Disponibilité", "" if d["in_stock"] is None else ("En stock" if d["in_stock"] else "Rupture de stock"))] body.append("
      " + "".join(f"
    • {k} : {_e(v)}
    • " for k, v in facts if v) + "
    ") if d["description"]: body.append(f"

    {_e(d['description'][:600])}

    ") if d["keywords"]: body.append("

    Caractéristiques : " + _e(", ".join(str(k) for k in d["keywords"][:15])) + "

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

    ' f"Voir la fiche originale chez {_e(src_name)}

    ") if cat: body.append(f'

    Autres produits — ' f"{_e(cat)}

    ") body.append(f'

    Tous les produits ' f"{_e(src_name)}

    ") product_ld: dict = { "@context": "https://schema.org", "@type": "Product", "name": name, "url": BASE_URL + path, "sku": d["external_id"], "inLanguage": "fr-CA"} if d["images"]: product_ld["image"] = d["images"][:5] if d["brand"]: product_ld["brand"] = {"@type": "Brand", "name": d["brand"]} if d["description"]: product_ld["description"] = d["description"][:500] if cat: product_ld["category"] = cat if d["size_label"]: product_ld["size"] = d["size_label"] if d["price"] is not None: availability = ("https://schema.org/OutOfStock" if d["in_stock"] is False else "https://schema.org/InStock") product_ld["offers"] = { "@type": "Offer", "price": d["price"], "priceCurrency": "CAD", "availability": availability, "url": BASE_URL + path, "seller": {"@type": "Organization", "name": src_name}} crumbs = [("Accueil", "/")] if cat: crumbs.append((cat, _cat_path(cat))) crumbs.append((name, path)) return _render(title=title, description=description, path=path, jsonld=[product_ld, _breadcrumb(crumbs)], body="".join(body), og_image=d["images"][0] if d["images"] else None) @router.get("/sources", include_in_schema=False) def sources_ssr(): _categories, sources = _facets() total = sum(s["n"] for s in sources) title = f"Bannières d'épicerie comparées ({len(sources)}) | Food-Ka" description = (f"Les {len(sources)} bannières d'épicerie du Québec dont Food-Ka compare " f"les prix — {_fmt_int(total)} produits recensés chez Metro, IGA, Maxi, " "Super C, Provigo, Walmart, Costco et plus.") body = (f"

    Bannières comparées — {len(sources)} épiceries du Québec

    ") jsonld = [_breadcrumb([("Accueil", "/"), ("Sources", "/sources")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Bannières d'épicerie comparées par Food-Ka", "numberOfItems": len(sources), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": _source_name(s["source"]), "url": BASE_URL + _src_path(s["source"])} for i, s in enumerate(sources[:100])]}] return _render(title=title, description=description, path="/sources", jsonld=jsonld, body=body) # pages statiques de l'app : head unique, contenu rendu par React _STATIC_META = { "/stats": ("Statistiques du panier d'épicerie québécois | Food-Ka", "Prix moyens, soldes et répartition par catégorie et par bannière : les " "statistiques du panier d'épicerie québécois, calculées en continu par Food-Ka."), "/contact": ("Nous joindre | Food-Ka", "Contactez l'équipe Food-Ka (Groupe KA) : questions, corrections de prix, " "ajout d'une bannière d'épicerie ou partenariats."), "/confidentialite": ("Politique de confidentialité | Food-Ka", "Politique de confidentialité de Food-Ka : données collectées, " "témoins (cookies) et droits des utilisateurs."), } def _static_page(path: str): title, description = _STATIC_META[path] return _render(title=title, description=description, path=path) @router.get("/stats", include_in_schema=False) def stats_page(): return _static_page("/stats") @router.get("/contact", include_in_schema=False) def contact_page(): return _static_page("/contact") @router.get("/confidentialite", include_in_schema=False) def privacy_page(): return _static_page("/confidentialite")