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