' + 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")