SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%
25.5 KB · 580 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# seo.py : référencement — SSR léger, pages programmatiques, robots, sitemaps5#6# Principe : pour chaque route publique, le serveur renvoie le MÊME index.html7# 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">. Les9# 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# -----------------------------------------------------------------------------12from __future__ import annotations1314import html15import json16import math17import re18import time19from datetime import date, datetime, timezone20from pathlib import Path21from urllib.parse import quote22from xml.sax.saxutils import escape as xml_escape2324from fastapi import APIRouter, HTTPException25from fastapi.responses import HTMLResponse, PlainTextResponse, Response2627from . import db2829router = APIRouter()3031ROOT = Path(__file__).resolve().parent.parent32FRONTEND_DIST = ROOT / "frontend" / "dist"33SOURCES_PATH = ROOT / "data" / "sources.json"3435BASE_URL = "https://www.food-ka.com"36SITE_NAME = "Food-Ka"3738SITEMAP_CHUNK = 10000394041# --- Gabarit (index.html du build Vite) --------------------------------------4243_shell_cache: dict = {"mtime": 0.0, "html": ""}444546def _shell() -> str:47    f = FRONTEND_DIST / "index.html"48    mtime = f.stat().st_mtime49    if mtime != _shell_cache["mtime"]:50        _shell_cache["html"] = f.read_text(encoding="utf-8")51        _shell_cache["mtime"] = mtime52    return _shell_cache["html"]535455def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None,56            body: str = "", og_image: str | None = None, status: int = 200) -> HTMLResponse:57    """index.html du build + head unique + contenu HTML dans #root."""58    canonical = BASE_URL + path59    page = _shell()60    page = re.sub(r"<title>.*?</title>",61                  lambda _m: f"<title>{html.escape(title)}</title>", page, count=1, flags=re.S)62    page = re.sub(r'<meta name="description"[^>]*/>',63                  lambda _m: f'<meta name="description" content="{html.escape(description, quote=True)}" />',64                  page, count=1)65    # retire du gabarit statique toutes les meta og:/twitter: (re-injectées ci-dessous)66    page = re.sub(r'\s*<meta (?:property="og:[^"]*"|name="twitter:[^"]*")[^>]*/>', "", page)67    extras = [68        f'<link rel="canonical" href="{html.escape(canonical, quote=True)}" />',69        f'<link rel="alternate" hreflang="fr-ca" href="{html.escape(canonical, quote=True)}" />',70        f'<link rel="alternate" hreflang="x-default" href="{html.escape(canonical, quote=True)}" />',71        f'<meta property="og:site_name" content="{SITE_NAME}" />',72        '<meta property="og:locale" content="fr_CA" />',73        '<meta property="og:type" content="website" />',74        f'<meta property="og:title" content="{html.escape(title, quote=True)}" />',75        f'<meta property="og:description" content="{html.escape(description, quote=True)}" />',76        f'<meta property="og:url" content="{html.escape(canonical, quote=True)}" />',77        '<meta name="twitter:card" content="summary_large_image" />',78        f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />',79    ]80    img = og_image or (BASE_URL + "/og.png")81    extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />')82    if not og_image:83        extras.append('<meta property="og:image:width" content="1200" />')84        extras.append('<meta property="og:image:height" content="630" />')85    extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />')86    for obj in (jsonld or []):87        blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")88        extras.append(f'<script type="application/ld+json">{blob}</script>')89    page = page.replace("</head>", "  " + "\n  ".join(extras) + "\n</head>", 1)90    if body:91        seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;'92                   'font-family:system-ui,sans-serif;color:#141814">' + body93                   + '<p>Food-Ka — Un service <a href="https://www.groupe-ka.com">Groupe KA</a></p>'94                   + "</div>")95        page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1)96    return HTMLResponse(page, status_code=status,97                        headers={"Cache-Control": "no-cache"})9899100def _e(t) -> str:101    return html.escape(str(t or ""))102103104def _fmt_int(n) -> str:105    """50314 → « 50 314 » (fr-CA)."""106    return f"{n:,}".replace(",", " ")107108109def _fmt_price(p) -> str:110    """4.99 → « 4,99 $ » (fr-CA)."""111    if p is None:112        return ""113    return f"{p:.2f}".replace(".", ",") + " $"114115116def _iso(ts) -> str:117    if not ts:118        return date.today().isoformat()119    return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()120121122# --- Données ------------------------------------------------------------------123124_sources_cache: dict = {"mtime": 0.0, "byid": {}}125126127def _sources_meta() -> dict[str, dict]:128    """id de bannière → {name, url, region…} (data/sources.json)."""129    mtime = SOURCES_PATH.stat().st_mtime130    if mtime != _sources_cache["mtime"]:131        data = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))132        srcs = data["sources"] if isinstance(data, dict) else data133        _sources_cache["byid"] = {s["id"]: s for s in srcs}134        _sources_cache["mtime"] = mtime135    return _sources_cache["byid"]136137138def _source_name(sid: str) -> str:139    meta = _sources_meta().get(sid)140    return meta["name"] if meta else sid141142143_facets_cache: dict = {"ts": 0.0, "categories": [], "sources": []}144145146def _facets() -> tuple[list[dict], list[dict]]:147    """(catégories actives, bannières actives) avec compte et lastmod, TTL 10 min."""148    if time.time() - _facets_cache["ts"] > 600:149        con = db.connect()150        _facets_cache["categories"] = [dict(r) for r in con.execute(151            """SELECT category, COUNT(*) n, MAX(updated_at) last152               FROM products WHERE active=1 AND category<>''153               GROUP BY category ORDER BY n DESC""")]154        _facets_cache["sources"] = [dict(r) for r in con.execute(155            """SELECT source, COUNT(*) n, MAX(updated_at) last156               FROM products WHERE active=1157               GROUP BY source ORDER BY n DESC""")]158        con.close()159        _facets_cache["ts"] = time.time()160    return _facets_cache["categories"], _facets_cache["sources"]161162163def _parse_row(r) -> dict:164    d = dict(r)165    d["keywords"] = json.loads(d.get("keywords") or "[]")166    d["images"] = json.loads(d.get("images") or "[]")167    d["details"] = json.loads(d.get("details") or "{}")168    return d169170171def _product_li(r) -> str:172    """Un produit dans une liste HTML serveur."""173    label = r["name"] or r["uid"]174    bits = [b for b in (r["brand"], r["size_label"], _fmt_price(r["price"]),175                        _source_name(r["source"])) if b]176    return (f'<li><a href="{_uid_path(r["uid"])}">{_e(label)}</a>'177            f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')178179180def _breadcrumb(items: list[tuple[str, str]]) -> dict:181    return {"@context": "https://schema.org", "@type": "BreadcrumbList",182            "itemListElement": [183                {"@type": "ListItem", "position": i + 1, "name": name,184                 "item": BASE_URL + path}185                for i, (name, path) in enumerate(items)]}186187188def _uid_path(uid: str) -> str:189    return "/produit/" + quote(str(uid), safe=":")190191192def _cat_path(category: str) -> str:193    return "/?category=" + quote(category)194195196def _src_path(sid: str) -> str:197    return "/?source=" + quote(sid)198199200def _not_found(message: str, path: str) -> HTMLResponse:201    """404 HTML : le shell React est servi (la SPA affichera sa page), mais le202    statut et le contenu serveur disent clairement « introuvable » aux bots."""203    return _render(title="Page introuvable | Food-Ka",204                   description="Cette page n'existe pas sur Food-Ka.",205                   path=path,206                   body=f"<h1>{_e(message)}</h1>"207                        '<p><a href="/">Comparer les prix d\'épicerie au Québec</a> · '208                        '<a href="/aubaines">Voir les aubaines</a></p>',209                   status=404)210211212# --- robots.txt & sitemaps ----------------------------------------------------213214@router.get("/robots.txt", include_in_schema=False)215def robots() -> PlainTextResponse:216    return PlainTextResponse(217        "User-agent: *\n"218        "Allow: /\n"219        "Disallow: /api/\n"220        "Disallow: /profil\n"221        f"\nSitemap: {BASE_URL}/sitemap.xml\n")222223224def _xml(content: str) -> Response:225    return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content,226                    media_type="application/xml",227                    headers={"Cache-Control": "public, max-age=3600"})228229230def _urlset(urls: list[tuple[str, str | None]]) -> Response:231    rows = []232    for loc, lastmod in urls:233        lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""234        rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>")235    return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'236                + "\n".join(rows) + "\n</urlset>")237238239@router.get("/sitemap.xml", include_in_schema=False)240def sitemap_index():241    con = db.connect()242    total = con.execute("SELECT COUNT(*) c FROM products WHERE active=1").fetchone()["c"]243    con.close()244    chunks = max(1, math.ceil(total / SITEMAP_CHUNK))245    names = ["sitemap-pages.xml"] + [246        f"sitemap-produits-{i}.xml" for i in range(1, chunks + 1)]247    today = date.today().isoformat()248    rows = "\n".join(249        f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>"250        for n in names)251    return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'252                + rows + "\n</sitemapindex>")253254255@router.get("/sitemap-pages.xml", include_in_schema=False)256def sitemap_pages():257    urls: list[tuple[str, str | None]] = [258        (f"{BASE_URL}/", None), (f"{BASE_URL}/aubaines", None),259        (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None),260        (f"{BASE_URL}/contact", None), (f"{BASE_URL}/confidentialite", None)]261    categories, sources = _facets()262    for c in categories:263        urls.append((BASE_URL + _cat_path(c["category"]), _iso(c["last"])))264    for s in sources:265        urls.append((BASE_URL + _src_path(s["source"]), _iso(s["last"])))266    return _urlset(urls)267268269@router.get("/sitemap-produits-{num}.xml", include_in_schema=False)270def sitemap_produits(num: int):271    if num < 1:272        raise HTTPException(404)273    con = db.connect()274    rows = con.execute(275        """SELECT uid, updated_at FROM products WHERE active=1276           ORDER BY uid LIMIT ? OFFSET ?""",277        (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()278    con.close()279    if not rows:280        raise HTTPException(404)281    return _urlset([(BASE_URL + _uid_path(r["uid"]), _iso(r["updated_at"]))282                    for r in rows])283284285# --- Pages SSR ----------------------------------------------------------------286287@router.get("/", include_in_schema=False)288def home_ssr(category: str | None = None, source: str | None = None):289    """Accueil — et ses déclinaisons programmatiques /?category=… et /?source=…290    (mêmes URLs que les filtres de la page Accueil du frontend)."""291    categories, sources = _facets()292    path = "/"293    label_bits: list[str] = []294    if category is not None:295        if category not in {c["category"] for c in categories}:296            return _not_found("Catégorie inconnue", _cat_path(category))297        path = _cat_path(category)298        label_bits.append(category)299    if source is not None:300        if source not in {s["source"] for s in sources}:301            return _not_found("Bannière inconnue", _src_path(source))302        if category is not None:303            path = _cat_path(category) + "&source=" + quote(source)304        else:305            path = _src_path(source)306        label_bits.append(_source_name(source))307308    con = db.connect()309    sql = "SELECT COUNT(*) c FROM products WHERE active=1"310    args: list = []311    if category is not None:312        sql += " AND category=?"; args.append(category)313    if source is not None:314        sql += " AND source=?"; args.append(source)315    n = con.execute(sql, args).fetchone()["c"]316    lsql = """SELECT uid, name, brand, size_label, price, source FROM products317              WHERE active=1 AND price IS NOT NULL"""318    if category is not None:319        lsql += " AND category=?"320    if source is not None:321        lsql += " AND source=?"322    rows = con.execute(lsql + " ORDER BY updated_at DESC LIMIT 30", args).fetchall()323    total = con.execute("SELECT COUNT(*) c FROM products WHERE active=1").fetchone()["c"]324    nsale = con.execute(325        "SELECT COUNT(*) c FROM products WHERE active=1 AND on_sale=1").fetchone()["c"]326    con.close()327    nsources = len(sources)328329    if label_bits:330        what = " chez ".join(label_bits) if (category and source) else label_bits[0]331        title = f"{what} — {n} produits d'épicerie comparés | Food-Ka"332        description = (f"{n} produits « {what} » recensés par Food-Ka dans les épiceries "333                       "du Québec : prix courant, soldes et prix unitaire, avec lien direct "334                       "vers la fiche de la bannière.")335        h1 = f"{what} — {n} produits comparés au Québec"336    else:337        title = (f"Food-Ka — Comparateur d'épicerie au Québec · {_fmt_int(total)} produits, "338                 f"{nsources} bannières")339        description = (f"{_fmt_int(total)} produits d'épicerie comparés dans {nsources} bannières "340                       f"du Québec (Metro, IGA, Maxi, Super C, Provigo…) dont {_fmt_int(nsale)} en "341                       "solde : prix, circulaires et prix unitaires, toujours à jour.")342        h1 = f"Comparer les prix d'épicerie au Québec — {_fmt_int(total)} produits à jour"343344    body = [f"<h1>{_e(h1)}</h1>"]345    if not label_bits:346        body.append(f"<p>Food-Ka agrège en continu les prix de {nsources} bannières "347                    "d'épicerie québécoises : chaque produit avec son prix courant, son "348                    "prix unitaire comparable et ses soldes, plus un lien direct vers la "349                    "fiche originale de la bannière.</p>")350        body.append(f'<p><a href="/aubaines">{_fmt_int(nsale)} produits en solde '351                    "en ce moment</a></p>")352        body.append("<h2>Produits par catégorie</h2><ul>" + "".join(353            f'<li><a href="{_e(_cat_path(c["category"]))}">{_e(c["category"])}</a>'354            f' — {c["n"]} produits</li>' for c in categories) + "</ul>")355        body.append("<h2>Bannières comparées</h2><ul>" + "".join(356            f'<li><a href="{_e(_src_path(s["source"]))}">{_e(_source_name(s["source"]))}</a>'357            f' — {s["n"]} produits</li>' for s in sources[:40]) + "</ul>")358    else:359        body.append('<p><a href="/">Tous les produits d\'épicerie comparés</a> · '360                    '<a href="/aubaines">Aubaines</a></p>')361    body.append("<h2>Produits récents</h2><ul>"362                + "".join(_product_li(r) for r in rows) + "</ul>")363364    jsonld: list[dict] = []365    if not label_bits:366        jsonld = [367            {"@context": "https://schema.org", "@type": "WebSite",368             "name": SITE_NAME, "url": BASE_URL + "/",369             "description": description, "inLanguage": "fr-CA"},370            {"@context": "https://schema.org", "@type": "Organization",371             "name": "Food-Ka (Groupe KA)", "url": BASE_URL + "/"}]372    else:373        jsonld = [374            _breadcrumb([("Accueil", "/"), (what, path)]),375            {"@context": "https://schema.org", "@type": "ItemList",376             "name": f"{what} — produits d'épicerie au Québec",377             "numberOfItems": n,378             "itemListElement": [379                 {"@type": "ListItem", "position": i + 1,380                  "name": r["name"] or r["uid"],381                  "url": BASE_URL + _uid_path(r["uid"])}382                 for i, r in enumerate(rows[:50])]}]383    return _render(title=title, description=description, path=path,384                   jsonld=jsonld, body="".join(body))385386387@router.get("/aubaines", include_in_schema=False)388def aubaines_ssr():389    con = db.connect()390    n = con.execute(391        "SELECT COUNT(*) c FROM products WHERE active=1 AND on_sale=1").fetchone()["c"]392    rows = con.execute(393        """SELECT uid, name, brand, size_label, price, regular_price, source394           FROM products395           WHERE active=1 AND on_sale=1 AND price IS NOT NULL AND regular_price IS NOT NULL396           ORDER BY (regular_price - price) / regular_price DESC LIMIT 30""").fetchall()397    con.close()398    title = f"Aubaines d'épicerie au Québec — {_fmt_int(n)} produits en solde | Food-Ka"399    description = (f"{_fmt_int(n)} produits d'épicerie en solde en ce moment dans les bannières du "400                   "Québec, classés par rabais : prix courant vs prix régulier, "401                   "avec lien direct vers la circulaire ou la fiche de la bannière.")402    body = (f"<h1>Aubaines d'épicerie au Québec — {_fmt_int(n)} produits en solde</h1>"403            + "<p>Les meilleurs rabais du moment, toutes bannières confondues :</p><ul>"404            + "".join(405                f'<li><a href="{_uid_path(r["uid"])}">{_e(r["name"] or r["uid"])}</a>'406                f' — {_fmt_price(r["price"])} (rég. {_fmt_price(r["regular_price"])})'407                f' · {_e(_source_name(r["source"]))}</li>' for r in rows)408            + '</ul><p><a href="/">Tous les produits comparés</a></p>')409    jsonld = [_breadcrumb([("Accueil", "/"), ("Aubaines", "/aubaines")]),410              {"@context": "https://schema.org", "@type": "ItemList",411               "name": "Aubaines d'épicerie au Québec", "numberOfItems": n,412               "itemListElement": [413                   {"@type": "ListItem", "position": i + 1,414                    "name": r["name"] or r["uid"],415                    "url": BASE_URL + _uid_path(r["uid"])}416                   for i, r in enumerate(rows[:50])]}]417    return _render(title=title, description=description, path="/aubaines",418                   jsonld=jsonld, body=body)419420421@router.get("/produit/{uid:path}", include_in_schema=False)422def product_ssr(uid: str):423    con = db.connect()424    row = con.execute("SELECT * FROM products WHERE uid=?", (uid,)).fetchone()425    con.close()426    path = _uid_path(uid)427    if row is None:428        return _render(title="Produit introuvable | Food-Ka",429                       description="Ce produit n'existe pas ou plus sur Food-Ka.",430                       path=path,431                       body="<h1>Produit introuvable</h1>"432                            '<p><a href="/">Comparer les prix d\'épicerie au Québec</a></p>',433                       status=404)434    d = _parse_row(row)435    src_name = _source_name(d["source"])436    cat = d["category"] or ""437    if not d["active"]:438        # produit disparu chez la bannière : 410 Gone + lien vers la catégorie439        link = (f'<a href="{_e(_cat_path(cat))}">Produits {_e(cat)}</a>'440                if cat else '<a href="/">Tous les produits</a>')441        return _render(442            title="Produit retiré | Food-Ka",443            description="Ce produit n'est plus recensé chez la bannière.",444            path=path,445            body="<h1>Ce produit n'est plus disponible</h1>"446                 f"<p>Il n'apparaît plus au catalogue de {_e(src_name)}. {link}.</p>",447            status=410)448449    name = d["name"] or "Produit d'épicerie"450    price_txt = _fmt_price(d["price"])451    title_bits = [name]452    if d["brand"] and d["brand"] not in name:453        title_bits.append(d["brand"])454    tail = f" à {price_txt} chez {src_name}" if price_txt else f" chez {src_name}"455    title = " ".join(title_bits) + tail + " | Food-Ka"456    desc_bits = [b for b in (457        d["brand"], d["size_label"],458        (f"{price_txt}" + (f" (rég. {_fmt_price(d['regular_price'])})"459                           if d["regular_price"] else "")) if price_txt else "",460        d["unit_price_label"], src_name) if b]461    description = ((" · ".join(desc_bits) + ". ") if desc_bits else "") + \462        (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150463         else (d["description"] or "")).strip()464    description = description[:300] or f"{name} chez {src_name} — prix comparé par Food-Ka."465466    body = [f"<h1>{_e(name)}{' — ' + _e(d['brand']) if d['brand'] else ''}</h1>"]467    facts = [("Bannière", src_name), ("Marque", d["brand"]),468             ("Format", d["size_label"]),469             ("Prix", price_txt + (" (en solde)" if d["on_sale"] else "")),470             ("Prix régulier", _fmt_price(d["regular_price"])),471             ("Prix unitaire", d["unit_price_label"]),472             ("Catégorie", cat),473             ("Disponibilité", "" if d["in_stock"] is None474              else ("En stock" if d["in_stock"] else "Rupture de stock"))]475    body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>"476                                 for k, v in facts if v) + "</ul>")477    if d["description"]:478        body.append(f"<p>{_e(d['description'][:600])}</p>")479    if d["keywords"]:480        body.append("<p><strong>Caractéristiques</strong> : "481                    + _e(", ".join(str(k) for k in d["keywords"][:15])) + "</p>")482    if d["url"]:483        body.append(f'<p><a href="{_e(d["url"])}" rel="nofollow">'484                    f"Voir la fiche originale chez {_e(src_name)}</a></p>")485    if cat:486        body.append(f'<p><a href="{_e(_cat_path(cat))}">Autres produits — '487                    f"{_e(cat)}</a></p>")488    body.append(f'<p><a href="{_e(_src_path(d["source"]))}">Tous les produits '489                f"{_e(src_name)}</a></p>")490491    product_ld: dict = {492        "@context": "https://schema.org", "@type": "Product",493        "name": name, "url": BASE_URL + path, "sku": d["external_id"],494        "inLanguage": "fr-CA"}495    if d["images"]:496        product_ld["image"] = d["images"][:5]497    if d["brand"]:498        product_ld["brand"] = {"@type": "Brand", "name": d["brand"]}499    if d["description"]:500        product_ld["description"] = d["description"][:500]501    if cat:502        product_ld["category"] = cat503    if d["size_label"]:504        product_ld["size"] = d["size_label"]505    if d["price"] is not None:506        availability = ("https://schema.org/OutOfStock" if d["in_stock"] is False507                        else "https://schema.org/InStock")508        product_ld["offers"] = {509            "@type": "Offer", "price": d["price"], "priceCurrency": "CAD",510            "availability": availability, "url": BASE_URL + path,511            "seller": {"@type": "Organization", "name": src_name}}512513    crumbs = [("Accueil", "/")]514    if cat:515        crumbs.append((cat, _cat_path(cat)))516    crumbs.append((name, path))517    return _render(title=title, description=description, path=path,518                   jsonld=[product_ld, _breadcrumb(crumbs)], body="".join(body),519                   og_image=d["images"][0] if d["images"] else None)520521522@router.get("/sources", include_in_schema=False)523def sources_ssr():524    _categories, sources = _facets()525    total = sum(s["n"] for s in sources)526    title = f"Bannières d'épicerie comparées ({len(sources)}) | Food-Ka"527    description = (f"Les {len(sources)} bannières d'épicerie du Québec dont Food-Ka compare "528                   f"les prix — {_fmt_int(total)} produits recensés chez Metro, IGA, Maxi, "529                   "Super C, Provigo, Walmart, Costco et plus.")530    body = (f"<h1>Bannières comparées — {len(sources)} épiceries du Québec</h1><ul>"531            + "".join(532                f'<li><a href="{_e(_src_path(s["source"]))}">{_e(_source_name(s["source"]))}</a>'533                f' — {s["n"]} produits</li>' for s in sources)534            + "</ul>")535    jsonld = [_breadcrumb([("Accueil", "/"), ("Sources", "/sources")]),536              {"@context": "https://schema.org", "@type": "ItemList",537               "name": "Bannières d'épicerie comparées par Food-Ka",538               "numberOfItems": len(sources),539               "itemListElement": [540                   {"@type": "ListItem", "position": i + 1,541                    "name": _source_name(s["source"]),542                    "url": BASE_URL + _src_path(s["source"])}543                   for i, s in enumerate(sources[:100])]}]544    return _render(title=title, description=description, path="/sources",545                   jsonld=jsonld, body=body)546547548# pages statiques de l'app : head unique, contenu rendu par React549_STATIC_META = {550    "/stats": ("Statistiques du panier d'épicerie québécois | Food-Ka",551               "Prix moyens, soldes et répartition par catégorie et par bannière : les "552               "statistiques du panier d'épicerie québécois, calculées en continu par Food-Ka."),553    "/contact": ("Nous joindre | Food-Ka",554                 "Contactez l'équipe Food-Ka (Groupe KA) : questions, corrections de prix, "555                 "ajout d'une bannière d'épicerie ou partenariats."),556    "/confidentialite": ("Politique de confidentialité | Food-Ka",557                         "Politique de confidentialité de Food-Ka : données collectées, "558                         "témoins (cookies) et droits des utilisateurs."),559}560561562def _static_page(path: str):563    title, description = _STATIC_META[path]564    return _render(title=title, description=description, path=path)565566567@router.get("/stats", include_in_schema=False)568def stats_page():569    return _static_page("/stats")570571572@router.get("/contact", include_in_schema=False)573def contact_page():574    return _static_page("/contact")575576577@router.get("/confidentialite", include_in_schema=False)578def privacy_page():579    return _static_page("/confidentialite")580