SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

SEO : SSR de l'accueil (title avec stats, canonical, OG fr_CA, JSON-LD WebSite/Organization, contenu essentiel) + hreflang/og:locale/BreadcrumbList sur les fiches, /contact et /stats ; lang fr-CA

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 22, 2026) parent b4dd920 Browse files

2 changed files +142 −6

modified frontend/index.html +1 −1
@@ -12,7 +12,7 @@
12 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY. 12 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY.
13 --> 13 -->
14 <!DOCTYPE html> 14 <!DOCTYPE html>
15−<html lang="fr"> 15+<html lang="fr-CA">
16 <head> 16 <head>
17 <meta charset="utf-8"> 17 <meta charset="utf-8">
18 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 18 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
modified sortika/seo.py +141 −5
@@ -56,6 +56,16 @@ _SRC_LABELS = {
56 } 56 }
57 57
58 58
59+_CAT_LABELS = {
60+ "festival": "Festivals", "musique": "Musique",
61+ "arts-scene": "Arts de la scène", "exposition-musee": "Expos et musées",
62+ "cinema": "Cinéma", "sport": "Sport", "plein-air": "Plein air",
63+ "famille": "Famille", "gastronomie": "Gastronomie",
64+ "marche-foire": "Marchés et foires", "conference": "Conférences et ateliers",
65+ "communautaire": "Communautaire", "patrimoine": "Patrimoine",
66+}
67+
68+
59 def _tz_offset(day: str) -> str: 69 def _tz_offset(day: str) -> str:
60 """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00").""" 70 """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00")."""
61 from datetime import datetime 71 from datetime import datetime
@@ -74,6 +84,36 @@ def _e(s) -> str:
74 return H.escape(str(s or ""), quote=True) 84 return H.escape(str(s or ""), quote=True)
75 85
76 86
87+def _n(x: int) -> str:
88+ """1234 → « 1 234 » (séparateur de milliers fr-CA)."""
89+ return f"{x:,}".replace(",", " ")
90+
91+
92+def _head_extras(canonical: str) -> str:
93+ """hreflang + og:site_name/og:locale communs à toutes les pages SSR."""
94+ return (
95+ f'<link rel="alternate" hreflang="fr-ca" href="{canonical}">\n'
96+ f'<link rel="alternate" hreflang="x-default" href="{canonical}">\n'
97+ f'<meta property="og:site_name" content="{SITE_NAME}">\n'
98+ '<meta property="og:locale" content="fr_CA">'
99+ )
100+
101+
102+def _ld_script(obj: dict) -> str:
103+ """Bloc <script> JSON-LD — accents préservés, « </ » échappé (anti-XSS)."""
104+ return ('<script type="application/ld+json">'
105+ + json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
106+ + "</script>")
107+
108+
109+def _breadcrumb(items: list[tuple[str, str]]) -> dict:
110+ return {"@context": "https://schema.org", "@type": "BreadcrumbList",
111+ "itemListElement": [
112+ {"@type": "ListItem", "position": i + 1, "name": name,
113+ "item": BASE_URL + path}
114+ for i, (name, path) in enumerate(items)]}
115+
116+
77 def _render(head: str, ssr_body: str) -> HTMLResponse: 117 def _render(head: str, ssr_body: str) -> HTMLResponse:
78 """index.html avec <head> réécrit et contenu SSR injecté dans #ssr.""" 118 """index.html avec <head> réécrit et contenu SSR injecté dans #ssr."""
79 import re 119 import re
@@ -181,17 +221,19 @@ def event_page(uid: str) -> HTMLResponse:
181 f"<title>{_e(title)}</title>\n" 221 f"<title>{_e(title)}</title>\n"
182 f'<meta name="description" content="{_e(desc)}">\n' 222 f'<meta name="description" content="{_e(desc)}">\n'
183 f'<link rel="canonical" href="{canonical}">\n' 223 f'<link rel="canonical" href="{canonical}">\n'
224+ + _head_extras(canonical) + "\n"
184 f'<meta property="og:title" content="{_e(title)}">\n' 225 f'<meta property="og:title" content="{_e(title)}">\n'
185 f'<meta property="og:description" content="{_e(desc)}">\n' 226 f'<meta property="og:description" content="{_e(desc)}">\n'
186 f'<meta property="og:type" content="event">\n' 227 f'<meta property="og:type" content="event">\n'
187 f'<meta property="og:url" content="{canonical}">\n' 228 f'<meta property="og:url" content="{canonical}">\n'
229+ f'<meta name="twitter:title" content="{_e(title)}">\n'
188 + (f'<meta property="og:image" content="{_e(ev["image"])}">\n' 230 + (f'<meta property="og:image" content="{_e(ev["image"])}">\n'
189 '<meta name="twitter:card" content="summary_large_image">\n' 231 '<meta name="twitter:card" content="summary_large_image">\n'
190 f'<meta name="twitter:image" content="{_e(ev["image"])}">\n' 232 f'<meta name="twitter:image" content="{_e(ev["image"])}">\n'
191 if ev.get("image") else OG_IMAGE_TAGS + "\n") 233 if ev.get("image") else OG_IMAGE_TAGS + "\n")
192− + '<script type="application/ld+json">' 234+ + _ld_script(_jsonld(ev)) + "\n"
193− + json.dumps(_jsonld(ev), ensure_ascii=False) 235+ + _ld_script(_breadcrumb([("Accueil", "/"),
194− + "</script>" 236+ (ev["title"], f"/evenement/{uid}")]))
195 ) 237 )
196 src = _SRC_LABELS.get(ev["source"], ev["source"]) 238 src = _SRC_LABELS.get(ev["source"], ev["source"])
197 ssr = ( 239 ssr = (
@@ -207,6 +249,93 @@ def event_page(uid: str) -> HTMLResponse:
207 return _render(head, ssr) 249 return _render(head, ssr)
208 250
209 251
252+@router.get("/", include_in_schema=False)
253+def home_page() -> HTMLResponse:
254+ """Accueil « / » en SSR : head riche (title avec stats, canonical, OG,
255+ JSON-LD WebSite/Organization) + contenu essentiel pour les robots.
256+ Prend le pas sur le fallback SPA de web.py (seo.router est inclus avant)."""
257+ from datetime import date
258+ today = date.today().isoformat()
259+ up = ("active=1 AND quarantine IS NULL AND "
260+ "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")
261+ con = db.connect()
262+ n = con.execute(f"SELECT COUNT(*) FROM events WHERE {up}",
263+ (today, today)).fetchone()[0]
264+ n_free = con.execute(f"SELECT COUNT(*) FROM events WHERE {up} AND is_free=1",
265+ (today, today)).fetchone()[0]
266+ regions = con.execute(
267+ f"SELECT region, COUNT(*) AS n FROM events WHERE {up} AND region != '' "
268+ "GROUP BY region ORDER BY n DESC", (today, today)).fetchall()
269+ cats: dict[str, int] = {}
270+ for r in con.execute(f"SELECT categories FROM events WHERE {up}",
271+ (today, today)):
272+ for c in json.loads(r["categories"] or "[]"):
273+ cats[c] = cats.get(c, 0) + 1
274+ nexts = con.execute(
275+ f"SELECT uid, title, venue, city, start_date FROM events WHERE {up} "
276+ "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 20",
277+ (today, today, today)).fetchall()
278+ con.close()
279+ try: # sources branchées (statut actif du registre)
280+ reg = json.loads((ROOT / "data" / "sources.json").read_text(encoding="utf-8"))
281+ n_src = sum(1 for s in reg.get("sources", []) if s.get("statut") == "actif")
282+ except Exception:
283+ n_src = 0
284+
285+ title = (f"Sorties et événements au Québec — {_n(n)} événements à venir "
286+ f"| {SITE_NAME}")
287+ desc = (f"{_n(n)} concerts, festivals, spectacles, expositions et activités "
288+ f"à venir dans les 17 régions du Québec, dont {_n(n_free)} gratuits. "
289+ "Agrégés depuis des billetteries et des données ouvertes "
290+ "officielles, avec dates, lieux et lien direct vers la billetterie.")
291+ canonical = f"{BASE_URL}/"
292+ head = (
293+ f"<title>{_e(title)}</title>\n"
294+ f'<meta name="description" content="{_e(desc)}">\n'
295+ f'<link rel="canonical" href="{canonical}">\n'
296+ + _head_extras(canonical) + "\n"
297+ f'<meta property="og:title" content="{_e(title)}">\n'
298+ f'<meta property="og:description" content="{_e(desc)}">\n'
299+ f'<meta property="og:type" content="website">\n'
300+ f'<meta property="og:url" content="{canonical}">\n'
301+ f'<meta name="twitter:title" content="{_e(title)}">\n'
302+ + OG_IMAGE_TAGS + "\n"
303+ + _ld_script({"@context": "https://schema.org", "@type": "WebSite",
304+ "name": SITE_NAME, "url": canonical,
305+ "description": desc, "inLanguage": "fr-CA"}) + "\n"
306+ + _ld_script({"@context": "https://schema.org", "@type": "Organization",
307+ "name": f"{SITE_NAME} (Groupe KA)", "url": canonical,
308+ "logo": OG_IMAGE})
309+ )
310+
311+ def _ev_li(r) -> str:
312+ where = " · ".join(p for p in (r["venue"], r["city"]) if p)
313+ bits = " — ".join(p for p in (r["start_date"], where) if p)
314+ return (f'<li><a href="/evenement/{_e(r["uid"])}">{_e(r["title"])}</a>'
315+ + (f" — {_e(bits)}" if bits else "") + "</li>")
316+
317+ ssr = (
318+ f"<article><h1>Sorties et événements au Québec — {_n(n)} à venir</h1>"
319+ f"<p>{SITE_NAME} agrège concerts, festivals, spectacles, expositions, "
320+ f"sport et activités famille depuis {n_src} billetteries et sources "
321+ f"ouvertes officielles, dans les 17 régions du Québec. {_n(n_free)} "
322+ "événements gratuits à venir. Chaque fiche renvoie vers la billetterie "
323+ "ou la source originale.</p>"
324+ "<h2>Prochains événements</h2><ul>"
325+ + "".join(_ev_li(r) for r in nexts) + "</ul>"
326+ "<h2>Événements à venir par région</h2><ul>"
327+ + "".join(f"<li>{_e(r['region'])} — {_n(r['n'])} événements</li>"
328+ for r in regions) + "</ul>"
329+ "<h2>Par catégorie</h2><p>"
330+ + " · ".join(f"{_e(_CAT_LABELS[c])} ({_n(v)})"
331+ for c, v in sorted(cats.items(), key=lambda kv: -kv[1])
332+ if c in _CAT_LABELS) + "</p>"
333+ '<p><a href="/stats">Statistiques Sorti-Ka</a> · '
334+ '<a href="/contact">Contact</a></p></article>'
335+ )
336+ return _render(head, ssr)
337+
338+
210 @router.get("/contact", include_in_schema=False) 339 @router.get("/contact", include_in_schema=False)
211 def contact_page() -> HTMLResponse: 340 def contact_page() -> HTMLResponse:
212 """Page /contact (SPA) — head SEO + contenu SSR pour les robots. 341 """Page /contact (SPA) — head SEO + contenu SSR pour les robots.
@@ -220,11 +349,14 @@ def contact_page() -> HTMLResponse:
220 f"<title>{_e(title)}</title>\n" 349 f"<title>{_e(title)}</title>\n"
221 f'<meta name="description" content="{_e(desc)}">\n' 350 f'<meta name="description" content="{_e(desc)}">\n'
222 f'<link rel="canonical" href="{canonical}">\n' 351 f'<link rel="canonical" href="{canonical}">\n'
352+ + _head_extras(canonical) + "\n"
223 f'<meta property="og:title" content="{_e(title)}">\n' 353 f'<meta property="og:title" content="{_e(title)}">\n'
224 f'<meta property="og:description" content="{_e(desc)}">\n' 354 f'<meta property="og:description" content="{_e(desc)}">\n'
225 f'<meta property="og:type" content="website">\n' 355 f'<meta property="og:type" content="website">\n'
226 f'<meta property="og:url" content="{canonical}">\n' 356 f'<meta property="og:url" content="{canonical}">\n'
227− + OG_IMAGE_TAGS 357+ f'<meta name="twitter:title" content="{_e(title)}">\n'
358+ + OG_IMAGE_TAGS + "\n"
359+ + _ld_script(_breadcrumb([("Accueil", "/"), ("Contact", "/contact")]))
228 ) 360 )
229 ssr = ( 361 ssr = (
230 "<article><h1>Contact — Groupe KA</h1>" 362 "<article><h1>Contact — Groupe KA</h1>"
@@ -257,11 +389,15 @@ def stats_page() -> HTMLResponse:
257 f"<title>{_e(title)}</title>\n" 389 f"<title>{_e(title)}</title>\n"
258 f'<meta name="description" content="{_e(desc)}">\n' 390 f'<meta name="description" content="{_e(desc)}">\n'
259 f'<link rel="canonical" href="{canonical}">\n' 391 f'<link rel="canonical" href="{canonical}">\n'
392+ + _head_extras(canonical) + "\n"
260 f'<meta property="og:title" content="{_e(title)}">\n' 393 f'<meta property="og:title" content="{_e(title)}">\n'
261 f'<meta property="og:description" content="{_e(desc)}">\n' 394 f'<meta property="og:description" content="{_e(desc)}">\n'
262 f'<meta property="og:type" content="website">\n' 395 f'<meta property="og:type" content="website">\n'
263 f'<meta property="og:url" content="{canonical}">\n' 396 f'<meta property="og:url" content="{canonical}">\n'
264− + OG_IMAGE_TAGS 397+ f'<meta name="twitter:title" content="{_e(title)}">\n'
398+ + OG_IMAGE_TAGS + "\n"
399+ + _ld_script(_breadcrumb([("Accueil", "/"),
400+ ("Statistiques", "/stats")]))
265 ) 401 )
266 ssr = ( 402 ssr = (
267 "<article><h1>Statistiques Sorti-Ka</h1>" 403 "<article><h1>Statistiques Sorti-Ka</h1>"
268 404
Menu

Theme