| 324 |
324 |
"<h2>Prochains événements</h2><ul>" |
| 325 |
325 |
+ "".join(_ev_li(r) for r in nexts) + "</ul>" |
| 326 |
326 |
"<h2>Événements à venir par région</h2><ul>" |
| 327 |
|
− + "".join(f"<li>{_e(r['region'])} — {_n(r['n'])} événements</li>" |
|
327 |
+ + "".join(f'<li><a href="/region/{slugify(r["region"])}">' |
|
328 |
+ f"{_e(r['region'])}</a> — {_n(r['n'])} événements</li>" |
| 328 |
329 |
for r in regions) + "</ul>" |
|
330 |
+ '<p><a href="/regions">Toutes les régions</a></p>' |
| 329 |
331 |
"<h2>Par catégorie</h2><p>" |
| 330 |
|
− + " · ".join(f"{_e(_CAT_LABELS[c])} ({_n(v)})" |
|
332 |
+ + " · ".join(f'<a href="/categorie/{c}">{_e(_CAT_LABELS[c])}</a> ({_n(v)})' |
| 331 |
333 |
for c, v in sorted(cats.items(), key=lambda kv: -kv[1]) |
| 332 |
334 |
if c in _CAT_LABELS) + "</p>" |
|
335 |
+ '<p><a href="/categories">Toutes les catégories</a></p>' |
| 333 |
336 |
'<p><a href="/stats">Statistiques Sorti-Ka</a> · ' |
| 334 |
337 |
'<a href="/contact">Contact</a></p></article>' |
| 335 |
338 |
) |
| 408 |
411 |
return _render(head, ssr) |
| 409 |
412 |
|
| 410 |
413 |
|
|
414 |
+# --- Pages programmatiques : régions et catégories ----------------------------- |
|
415 |
+ |
|
416 |
+def slugify(text: str) -> str: |
|
417 |
+ """« Bas-Saint-Laurent » → bas-saint-laurent, « Montérégie » → monteregie.""" |
|
418 |
+ import re as _re |
|
419 |
+ import unicodedata |
|
420 |
+ t = text.replace("œ", "oe").replace("Œ", "Oe") |
|
421 |
+ t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() |
|
422 |
+ return _re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") |
|
423 |
+ |
|
424 |
+ |
|
425 |
+_UP = ("active=1 AND quarantine IS NULL AND " |
|
426 |
+ "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))") |
|
427 |
+ |
|
428 |
+ |
|
429 |
+def _today() -> str: |
|
430 |
+ from datetime import date |
|
431 |
+ return date.today().isoformat() |
|
432 |
+ |
|
433 |
+ |
|
434 |
+def _regions_rows() -> list[dict]: |
|
435 |
+ """[{region, slug, n}] des régions avec événements à venir.""" |
|
436 |
+ today = _today() |
|
437 |
+ con = db.connect() |
|
438 |
+ rows = con.execute( |
|
439 |
+ f"SELECT region, COUNT(*) AS n FROM events WHERE {_UP} AND region != '' " |
|
440 |
+ "GROUP BY region ORDER BY n DESC", (today, today)).fetchall() |
|
441 |
+ con.close() |
|
442 |
+ return [{"region": r["region"], "slug": slugify(r["region"]), "n": r["n"]} |
|
443 |
+ for r in rows] |
|
444 |
+ |
|
445 |
+ |
|
446 |
+def _cats_rows() -> list[dict]: |
|
447 |
+ """[{cat, label, n}] des catégories du vocabulaire avec compte à venir.""" |
|
448 |
+ today = _today() |
|
449 |
+ con = db.connect() |
|
450 |
+ counts: dict[str, int] = {} |
|
451 |
+ for r in con.execute(f"SELECT categories FROM events WHERE {_UP}", |
|
452 |
+ (today, today)): |
|
453 |
+ for c in json.loads(r["categories"] or "[]"): |
|
454 |
+ counts[c] = counts.get(c, 0) + 1 |
|
455 |
+ con.close() |
|
456 |
+ return [{"cat": c, "label": _CAT_LABELS[c], "n": counts.get(c, 0)} |
|
457 |
+ for c in sorted(_CAT_LABELS, key=lambda c: -counts.get(c, 0)) |
|
458 |
+ if counts.get(c, 0) > 0] |
|
459 |
+ |
|
460 |
+ |
|
461 |
+def _std_head(title: str, desc: str, path: str, lds: list[dict]) -> str: |
|
462 |
+ canonical = BASE_URL + path |
|
463 |
+ return ( |
|
464 |
+ f"<title>{_e(title)}</title>\n" |
|
465 |
+ f'<meta name="description" content="{_e(desc)}">\n' |
|
466 |
+ f'<link rel="canonical" href="{canonical}">\n' |
|
467 |
+ + _head_extras(canonical) + "\n" |
|
468 |
+ f'<meta property="og:title" content="{_e(title)}">\n' |
|
469 |
+ f'<meta property="og:description" content="{_e(desc)}">\n' |
|
470 |
+ f'<meta property="og:type" content="website">\n' |
|
471 |
+ f'<meta property="og:url" content="{canonical}">\n' |
|
472 |
+ f'<meta name="twitter:title" content="{_e(title)}">\n' |
|
473 |
+ + OG_IMAGE_TAGS + "\n" |
|
474 |
+ + "\n".join(_ld_script(o) for o in lds)) |
|
475 |
+ |
|
476 |
+ |
|
477 |
+def _ev_li_full(r) -> str: |
|
478 |
+ where = " · ".join(p for p in (r["venue"], r["city"]) if p) |
|
479 |
+ bits = " — ".join(p for p in (r["start_date"], where) if p) |
|
480 |
+ return (f'<li><a href="/evenement/{_e(r["uid"])}">{_e(r["title"])}</a>' |
|
481 |
+ + (f" — {_e(bits)}" if bits else "") + "</li>") |
|
482 |
+ |
|
483 |
+ |
|
484 |
+def _events_list_page(*, path: str, h1: str, title: str, desc: str, |
|
485 |
+ rows, n: int, crumbs, others_html: str) -> HTMLResponse: |
|
486 |
+ lds = [_breadcrumb(crumbs), |
|
487 |
+ {"@context": "https://schema.org", "@type": "ItemList", |
|
488 |
+ "name": h1, "numberOfItems": n, |
|
489 |
+ "itemListElement": [ |
|
490 |
+ {"@type": "ListItem", "position": i + 1, "name": r["title"], |
|
491 |
+ "url": f"{BASE_URL}/evenement/{r['uid']}"} |
|
492 |
+ for i, r in enumerate(rows[:50])]}] |
|
493 |
+ ssr = (f"<article><h1>{_e(h1)} — {_n(n)} événements à venir</h1>" |
|
494 |
+ f"<p>{_e(desc)}</p>" |
|
495 |
+ "<h2>Prochains événements</h2><ul>" |
|
496 |
+ + "".join(_ev_li_full(r) for r in rows) + "</ul>" |
|
497 |
+ + others_html |
|
498 |
+ + '<p><a href="/regions">Toutes les régions</a> · ' |
|
499 |
+ '<a href="/categories">Toutes les catégories</a> · ' |
|
500 |
+ '<a href="/">Toutes les sorties du Québec</a></p></article>') |
|
501 |
+ return _render(_std_head(title, desc, path, lds), ssr) |
|
502 |
+ |
|
503 |
+ |
|
504 |
+@router.get("/regions", include_in_schema=False) |
|
505 |
+def regions_page() -> HTMLResponse: |
|
506 |
+ regions = _regions_rows() |
|
507 |
+ total = sum(r["n"] for r in regions) |
|
508 |
+ title = (f"Sorties et événements par région au Québec " |
|
509 |
+ f"({len(regions)} régions) | {SITE_NAME}") |
|
510 |
+ desc = (f"{_n(total)} événements à venir dans {len(regions)} régions du " |
|
511 |
+ "Québec : concerts, festivals, spectacles, expositions et " |
|
512 |
+ "activités famille, région par région.") |
|
513 |
+ lds = [_breadcrumb([("Accueil", "/"), ("Régions", "/regions")]), |
|
514 |
+ {"@context": "https://schema.org", "@type": "ItemList", |
|
515 |
+ "name": "Événements par région au Québec", |
|
516 |
+ "numberOfItems": len(regions), |
|
517 |
+ "itemListElement": [ |
|
518 |
+ {"@type": "ListItem", "position": i + 1, |
|
519 |
+ "name": f"Événements en {r['region']}", |
|
520 |
+ "url": f"{BASE_URL}/region/{r['slug']}"} |
|
521 |
+ for i, r in enumerate(regions)]}] |
|
522 |
+ ssr = (f"<article><h1>Sorties par région — {len(regions)} régions du " |
|
523 |
+ "Québec</h1><ul>" |
|
524 |
+ + "".join(f'<li><a href="/region/{r["slug"]}">Événements — ' |
|
525 |
+ f'{_e(r["region"])}</a> — {_n(r["n"])} à venir</li>' |
|
526 |
+ for r in regions) |
|
527 |
+ + '</ul><p><a href="/categories">Par catégorie</a> · ' |
|
528 |
+ '<a href="/">Toutes les sorties du Québec</a></p></article>') |
|
529 |
+ return _render(_std_head(title, desc, "/regions", lds), ssr) |
|
530 |
+ |
|
531 |
+ |
|
532 |
+@router.get("/region/{slug}", include_in_schema=False) |
|
533 |
+def region_page(slug: str) -> HTMLResponse: |
|
534 |
+ regions = _regions_rows() |
|
535 |
+ match = next((r for r in regions if r["slug"] == slug), None) |
|
536 |
+ if match is None: |
|
537 |
+ raise HTTPException(404, "région inconnue") |
|
538 |
+ region, n = match["region"], match["n"] |
|
539 |
+ today = _today() |
|
540 |
+ con = db.connect() |
|
541 |
+ rows = con.execute( |
|
542 |
+ f"SELECT uid, title, venue, city, start_date FROM events " |
|
543 |
+ f"WHERE {_UP} AND region=? " |
|
544 |
+ "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 100", |
|
545 |
+ (today, today, region, today)).fetchall() |
|
546 |
+ con.close() |
|
547 |
+ path = f"/region/{slug}" |
|
548 |
+ title = f"Sorties et événements — {region} ({_n(n)} à venir) | {SITE_NAME}" |
|
549 |
+ desc = (f"{_n(n)} événements à venir en {region} : concerts, festivals, " |
|
550 |
+ "spectacles, expositions et activités famille, avec dates, lieux " |
|
551 |
+ "et lien direct vers la billetterie ou la source officielle.") |
|
552 |
+ others = [r for r in regions if r["slug"] != slug][:12] |
|
553 |
+ others_html = ("<h2>Autres régions</h2><ul>" + "".join( |
|
554 |
+ f'<li><a href="/region/{r["slug"]}">{_e(r["region"])}</a>' |
|
555 |
+ f' — {_n(r["n"])}</li>' for r in others) + "</ul>") if others else "" |
|
556 |
+ return _events_list_page( |
|
557 |
+ path=path, h1=f"Sorties et événements — {region}", title=title, |
|
558 |
+ desc=desc, rows=rows, n=n, |
|
559 |
+ crumbs=[("Accueil", "/"), ("Régions", "/regions"), (region, path)], |
|
560 |
+ others_html=others_html) |
|
561 |
+ |
|
562 |
+ |
|
563 |
+@router.get("/categories", include_in_schema=False) |
|
564 |
+def categories_page() -> HTMLResponse: |
|
565 |
+ cats = _cats_rows() |
|
566 |
+ total = sum(c["n"] for c in cats) |
|
567 |
+ title = f"Sorties par catégorie au Québec ({len(cats)} catégories) | {SITE_NAME}" |
|
568 |
+ desc = (f"{_n(total)} événements à venir au Québec classés en " |
|
569 |
+ f"{len(cats)} catégories : festivals, musique, arts de la scène, " |
|
570 |
+ "expositions, sport, plein air, famille et plus.") |
|
571 |
+ lds = [_breadcrumb([("Accueil", "/"), ("Catégories", "/categories")]), |
|
572 |
+ {"@context": "https://schema.org", "@type": "ItemList", |
|
573 |
+ "name": "Événements par catégorie au Québec", |
|
574 |
+ "numberOfItems": len(cats), |
|
575 |
+ "itemListElement": [ |
|
576 |
+ {"@type": "ListItem", "position": i + 1, |
|
577 |
+ "name": c["label"], |
|
578 |
+ "url": f"{BASE_URL}/categorie/{c['cat']}"} |
|
579 |
+ for i, c in enumerate(cats)]}] |
|
580 |
+ ssr = (f"<article><h1>Sorties par catégorie — {len(cats)} catégories</h1><ul>" |
|
581 |
+ + "".join(f'<li><a href="/categorie/{c["cat"]}">{_e(c["label"])}</a>' |
|
582 |
+ f' — {_n(c["n"])} événements à venir</li>' for c in cats) |
|
583 |
+ + '</ul><p><a href="/regions">Par région</a> · ' |
|
584 |
+ '<a href="/">Toutes les sorties du Québec</a></p></article>') |
|
585 |
+ return _render(_std_head(title, desc, "/categories", lds), ssr) |
|
586 |
+ |
|
587 |
+ |
|
588 |
+@router.get("/categorie/{cat}", include_in_schema=False) |
|
589 |
+def categorie_page(cat: str) -> HTMLResponse: |
|
590 |
+ if cat not in _CAT_LABELS: |
|
591 |
+ raise HTTPException(404, "catégorie inconnue") |
|
592 |
+ label = _CAT_LABELS[cat] |
|
593 |
+ today = _today() |
|
594 |
+ con = db.connect() |
|
595 |
+ like = f'%"{cat}"%' |
|
596 |
+ n = con.execute( |
|
597 |
+ f"SELECT COUNT(*) FROM events WHERE {_UP} AND categories LIKE ?", |
|
598 |
+ (today, today, like)).fetchone()[0] |
|
599 |
+ rows = con.execute( |
|
600 |
+ f"SELECT uid, title, venue, city, start_date FROM events " |
|
601 |
+ f"WHERE {_UP} AND categories LIKE ? " |
|
602 |
+ "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 100", |
|
603 |
+ (today, today, like, today)).fetchall() |
|
604 |
+ con.close() |
|
605 |
+ if n == 0: |
|
606 |
+ raise HTTPException(404, "aucun événement à venir dans cette catégorie") |
|
607 |
+ path = f"/categorie/{cat}" |
|
608 |
+ title = f"{label} au Québec — {_n(n)} événements à venir | {SITE_NAME}" |
|
609 |
+ desc = (f"{_n(n)} événements « {label} » à venir au Québec, avec dates, " |
|
610 |
+ "lieux et lien direct vers la billetterie ou la source officielle.") |
|
611 |
+ others = [c for c in _cats_rows() if c["cat"] != cat][:12] |
|
612 |
+ others_html = ("<h2>Autres catégories</h2><ul>" + "".join( |
|
613 |
+ f'<li><a href="/categorie/{c["cat"]}">{_e(c["label"])}</a>' |
|
614 |
+ f' — {_n(c["n"])}</li>' for c in others) + "</ul>") if others else "" |
|
615 |
+ return _events_list_page( |
|
616 |
+ path=path, h1=f"{label} au Québec", title=title, desc=desc, |
|
617 |
+ rows=rows, n=n, |
|
618 |
+ crumbs=[("Accueil", "/"), ("Catégories", "/categories"), (label, path)], |
|
619 |
+ others_html=others_html) |
|
620 |
+ |
|
621 |
+ |
| 411 |
622 |
@router.get("/robots.txt", include_in_schema=False) |
| 412 |
623 |
def robots() -> PlainTextResponse: |
| 413 |
624 |
return PlainTextResponse( |
| 445 |
656 |
|
| 446 |
657 |
@router.get("/sitemap-pages.xml", include_in_schema=False) |
| 447 |
658 |
def sitemap_pages() -> Response: |
| 448 |
|
− return _urlset([(f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None), |
| 449 |
|
− (f"{BASE_URL}/contact", None)]) |
|
659 |
+ today = _today() |
|
660 |
+ urls = [(f"{BASE_URL}/", None), (f"{BASE_URL}/regions", None), |
|
661 |
+ (f"{BASE_URL}/categories", None), |
|
662 |
+ (f"{BASE_URL}/stats", None), (f"{BASE_URL}/contact", None)] |
|
663 |
+ urls += [(f"{BASE_URL}/region/{r['slug']}", today) for r in _regions_rows()] |
|
664 |
+ urls += [(f"{BASE_URL}/categorie/{c['cat']}", today) for c in _cats_rows()] |
|
665 |
+ return _urlset(urls) |
| 450 |
666 |
|
| 451 |
667 |
|
| 452 |
668 |
@router.get("/sitemap-evenements-{num}.xml", include_in_schema=False) |
| 453 |
669 |
|