SPB Git forge

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.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

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 1 mo ago (Aug 22, 2026) parent b4dd920

2 changed files +142 −6

modified frontend/index.html +1 −1
@@ -12,7 +12,7 @@
12 12 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY.
13 13 -->
14 14 <!DOCTYPE html>
15 −<html lang="fr">
15 +<html lang="fr-CA">
16 16 <head>
17 17 <meta charset="utf-8">
18 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 69 def _tz_offset(day: str) -> str:
60 70 """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00")."""
61 71 from datetime import datetime
@@ -74,6 +84,36 @@ def _e(s) -> str:
74 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 117 def _render(head: str, ssr_body: str) -> HTMLResponse:
78 118 """index.html avec <head> réécrit et contenu SSR injecté dans #ssr."""
79 119 import re
@@ -181,17 +221,19 @@ def event_page(uid: str) -> HTMLResponse:
181 221 f"<title>{_e(title)}</title>\n"
182 222 f'<meta name="description" content="{_e(desc)}">\n'
183 223 f'<link rel="canonical" href="{canonical}">\n'
224 + + _head_extras(canonical) + "\n"
184 225 f'<meta property="og:title" content="{_e(title)}">\n'
185 226 f'<meta property="og:description" content="{_e(desc)}">\n'
186 227 f'<meta property="og:type" content="event">\n'
187 228 f'<meta property="og:url" content="{canonical}">\n'
229 + f'<meta name="twitter:title" content="{_e(title)}">\n'
188 230 + (f'<meta property="og:image" content="{_e(ev["image"])}">\n'
189 231 '<meta name="twitter:card" content="summary_large_image">\n'
190 232 f'<meta name="twitter:image" content="{_e(ev["image"])}">\n'
191 233 if ev.get("image") else OG_IMAGE_TAGS + "\n")
192 − + '<script type="application/ld+json">'
193 − + json.dumps(_jsonld(ev), ensure_ascii=False)
194 − + "</script>"
234 + + _ld_script(_jsonld(ev)) + "\n"
235 + + _ld_script(_breadcrumb([("Accueil", "/"),
236 + (ev["title"], f"/evenement/{uid}")]))
195 237 )
196 238 src = _SRC_LABELS.get(ev["source"], ev["source"])
197 239 ssr = (
@@ -207,6 +249,93 @@ def event_page(uid: str) -> HTMLResponse:
207 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 339 @router.get("/contact", include_in_schema=False)
211 340 def contact_page() -> HTMLResponse:
212 341 """Page /contact (SPA) — head SEO + contenu SSR pour les robots.
@@ -220,11 +349,14 @@ def contact_page() -> HTMLResponse:
220 349 f"<title>{_e(title)}</title>\n"
221 350 f'<meta name="description" content="{_e(desc)}">\n'
222 351 f'<link rel="canonical" href="{canonical}">\n'
352 + + _head_extras(canonical) + "\n"
223 353 f'<meta property="og:title" content="{_e(title)}">\n'
224 354 f'<meta property="og:description" content="{_e(desc)}">\n'
225 355 f'<meta property="og:type" content="website">\n'
226 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 361 ssr = (
230 362 "<article><h1>Contact — Groupe KA</h1>"
@@ -257,11 +389,15 @@ def stats_page() -> HTMLResponse:
257 389 f"<title>{_e(title)}</title>\n"
258 390 f'<meta name="description" content="{_e(desc)}">\n'
259 391 f'<link rel="canonical" href="{canonical}">\n'
392 + + _head_extras(canonical) + "\n"
260 393 f'<meta property="og:title" content="{_e(title)}">\n'
261 394 f'<meta property="og:description" content="{_e(desc)}">\n'
262 395 f'<meta property="og:type" content="website">\n'
263 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 402 ssr = (
267 403 "<article><h1>Statistiques Sorti-Ka</h1>"
268 404