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