Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 53.9%
TypeScript 24%
CSS 14.9%
JavaScript 5.8%
HTML 1.4%
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