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%

SEO : SSR léger, robots.txt, sitemaps et données structurées (foodka/seo.py)

- foodka/seo.py : robots.txt (Disallow /api/, /profil, Sitemap), sitemap index
  + sitemap-pages + sitemap-produits-{n}.xml chunkés 10 000 avec lastmod ;
  SSR accueil (title/description riches, H1 + liens serveur, WebSite/Organization
  JSON-LD fr-CA), pages programmatiques /?category= et /?source= (ItemList +
  BreadcrumbList, 404 si inconnue), /aubaines (ItemList), fiche /produit/{uid}
  (Product JSON-LD avec Offer CAD + availability, brand, image ; BreadcrumbList ;
  og:image = image produit ; 404 introuvable / 410 retiré), /sources (ItemList),
  meta uniques stats/contact/confidentialite. Head unique : canonical, hreflang
  fr-ca, og:* complet locale fr_CA, twitter ; contenu HTML dans #root remplacé
  par React au montage.
- foodka/web.py : include du routeur SEO AVANT le catch-all SPA (robots.txt et
  sitemap.xml renvoyaient l index.html du SPA).
- frontend/index.html : lang fr-CA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 22, 2026) parent 4474b20

3 changed files +584 −1

added foodka/seo.py +579 −0
@@ -0,0 +1,579 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# seo.py : référencement — SSR léger, pages programmatiques, robots, sitemaps
5 +#
6 +# Principe : pour chaque route publique, le serveur renvoie le MÊME index.html
7 +# 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">. Les
9 +# 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 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import html
15 +import json
16 +import math
17 +import re
18 +import time
19 +from datetime import date, datetime, timezone
20 +from pathlib import Path
21 +from urllib.parse import quote
22 +from xml.sax.saxutils import escape as xml_escape
23 +
24 +from fastapi import APIRouter, HTTPException
25 +from fastapi.responses import HTMLResponse, PlainTextResponse, Response
26 +
27 +from . import db
28 +
29 +router = APIRouter()
30 +
31 +ROOT = Path(__file__).resolve().parent.parent
32 +FRONTEND_DIST = ROOT / "frontend" / "dist"
33 +SOURCES_PATH = ROOT / "data" / "sources.json"
34 +
35 +BASE_URL = "https://www.food-ka.com"
36 +SITE_NAME = "Food-Ka"
37 +
38 +SITEMAP_CHUNK = 10000
39 +
40 +
41 +# --- Gabarit (index.html du build Vite) --------------------------------------
42 +
43 +_shell_cache: dict = {"mtime": 0.0, "html": ""}
44 +
45 +
46 +def _shell() -> str:
47 + f = FRONTEND_DIST / "index.html"
48 + mtime = f.stat().st_mtime
49 + if mtime != _shell_cache["mtime"]:
50 + _shell_cache["html"] = f.read_text(encoding="utf-8")
51 + _shell_cache["mtime"] = mtime
52 + return _shell_cache["html"]
53 +
54 +
55 +def _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 + path
59 + 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">' + body
93 + + '<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"})
98 +
99 +
100 +def _e(t) -> str:
101 + return html.escape(str(t or ""))
102 +
103 +
104 +def _fmt_int(n) -> str:
105 + """50314 → « 50 314 » (fr-CA)."""
106 + return f"{n:,}".replace(",", " ")
107 +
108 +
109 +def _fmt_price(p) -> str:
110 + """4.99 → « 4,99 $ » (fr-CA)."""
111 + if p is None:
112 + return ""
113 + return f"{p:.2f}".replace(".", ",") + " $"
114 +
115 +
116 +def _iso(ts) -> str:
117 + if not ts:
118 + return date.today().isoformat()
119 + return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()
120 +
121 +
122 +# --- Données ------------------------------------------------------------------
123 +
124 +_sources_cache: dict = {"mtime": 0.0, "byid": {}}
125 +
126 +
127 +def _sources_meta() -> dict[str, dict]:
128 + """id de bannière → {name, url, region…} (data/sources.json)."""
129 + mtime = SOURCES_PATH.stat().st_mtime
130 + 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 data
133 + _sources_cache["byid"] = {s["id"]: s for s in srcs}
134 + _sources_cache["mtime"] = mtime
135 + return _sources_cache["byid"]
136 +
137 +
138 +def _source_name(sid: str) -> str:
139 + meta = _sources_meta().get(sid)
140 + return meta["name"] if meta else sid
141 +
142 +
143 +_facets_cache: dict = {"ts": 0.0, "categories": [], "sources": []}
144 +
145 +
146 +def _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) last
152 + 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) last
156 + FROM products WHERE active=1
157 + GROUP BY source ORDER BY n DESC""")]
158 + con.close()
159 + _facets_cache["ts"] = time.time()
160 + return _facets_cache["categories"], _facets_cache["sources"]
161 +
162 +
163 +def _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 d
169 +
170 +
171 +def _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>')
178 +
179 +
180 +def _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)]}
186 +
187 +
188 +def _uid_path(uid: str) -> str:
189 + return "/produit/" + quote(str(uid), safe=":")
190 +
191 +
192 +def _cat_path(category: str) -> str:
193 + return "/?category=" + quote(category)
194 +
195 +
196 +def _src_path(sid: str) -> str:
197 + return "/?source=" + quote(sid)
198 +
199 +
200 +def _not_found(message: str, path: str) -> HTMLResponse:
201 + """404 HTML : le shell React est servi (la SPA affichera sa page), mais le
202 + 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)
210 +
211 +
212 +# --- robots.txt & sitemaps ----------------------------------------------------
213 +
214 +@router.get("/robots.txt", include_in_schema=False)
215 +def 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")
222 +
223 +
224 +def _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"})
228 +
229 +
230 +def _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>")
237 +
238 +
239 +@router.get("/sitemap.xml", include_in_schema=False)
240 +def 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>")
253 +
254 +
255 +@router.get("/sitemap-pages.xml", include_in_schema=False)
256 +def 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)
267 +
268 +
269 +@router.get("/sitemap-produits-{num}.xml", include_in_schema=False)
270 +def 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=1
276 + 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])
283 +
284 +
285 +# --- Pages SSR ----------------------------------------------------------------
286 +
287 +@router.get("/", include_in_schema=False)
288 +def 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))
307 +
308 + 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 products
317 + 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)
328 +
329 + 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"
343 +
344 + 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>")
363 +
364 + 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))
385 +
386 +
387 +@router.get("/aubaines", include_in_schema=False)
388 +def 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, source
394 + FROM products
395 + WHERE active=1 AND on_sale=1 AND price IS NOT NULL AND regular_price IS NOT NULL
396 + 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)
419 +
420 +
421 +@router.get("/produit/{uid:path}", include_in_schema=False)
422 +def 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égorie
439 + 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)
448 +
449 + 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 "") > 150
463 + else (d["description"] or "")).strip()
464 + description = description[:300] or f"{name} chez {src_name} — prix comparé par Food-Ka."
465 +
466 + 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 None
474 + 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>")
490 +
491 + 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"] = cat
503 + 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 False
507 + 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}}
512 +
513 + 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)
520 +
521 +
522 +@router.get("/sources", include_in_schema=False)
523 +def 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)
546 +
547 +
548 +# pages statiques de l'app : head unique, contenu rendu par React
549 +_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 +}
560 +
561 +
562 +def _static_page(path: str):
563 + title, description = _STATIC_META[path]
564 + return _render(title=title, description=description, path=path)
565 +
566 +
567 +@router.get("/stats", include_in_schema=False)
568 +def stats_page():
569 + return _static_page("/stats")
570 +
571 +
572 +@router.get("/contact", include_in_schema=False)
573 +def contact_page():
574 + return _static_page("/contact")
575 +
576 +
577 +@router.get("/confidentialite", include_in_schema=False)
578 +def privacy_page():
579 + return _static_page("/confidentialite")
modified foodka/web.py +4 −0
@@ -350,6 +350,10 @@ app.include_router(auth.router)
350 350 if FRONTEND_DIST.exists():
351 351 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
352 352
353 + # --- SEO : robots, sitemaps, SSR des pages publiques — AVANT le catch-all
354 + from . import seo # noqa: E402
355 + app.include_router(seo.router)
356 +
353 357 @app.get("/{full_path:path}")
354 358 def spa(full_path: str):
355 359 target = FRONTEND_DIST / full_path
modified frontend/index.html +1 −1
@@ -3,7 +3,7 @@
3 3 Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
4 4 Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 5 ---------------------------------------------------------------------------- -->
6 −<html lang="fr">
6 +<html lang="fr-CA">
7 7 <head>
8 8 <meta charset="UTF-8" />
9 9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 10