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