| 324 | "<h2>Prochains événements</h2><ul>" |
324 | "<h2>Prochains événements</h2><ul>" |
| 325 | + "".join(_ev_li(r) for r in nexts) + "</ul>" |
325 | + "".join(_ev_li(r) for r in nexts) + "</ul>" |
| 326 | "<h2>Événements à venir par région</h2><ul>" |
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 | for r in regions) + "</ul>" |
329 | for r in regions) + "</ul>" |
| |
330 | + '<p><a href="/regions">Toutes les régions</a></p>' |
| 329 | "<h2>Par catégorie</h2><p>" |
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 | for c, v in sorted(cats.items(), key=lambda kv: -kv[1]) |
333 | for c, v in sorted(cats.items(), key=lambda kv: -kv[1]) |
| 332 | if c in _CAT_LABELS) + "</p>" |
334 | if c in _CAT_LABELS) + "</p>" |
| |
335 | + '<p><a href="/categories">Toutes les catégories</a></p>' |
| 333 | '<p><a href="/stats">Statistiques Sorti-Ka</a> · ' |
336 | '<p><a href="/stats">Statistiques Sorti-Ka</a> · ' |
| 334 | '<a href="/contact">Contact</a></p></article>' |
337 | '<a href="/contact">Contact</a></p></article>' |
| 335 | ) |
338 | ) |
| 408 | return _render(head, ssr) |
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 | @router.get("/robots.txt", include_in_schema=False) |
622 | @router.get("/robots.txt", include_in_schema=False) |
| 412 | def robots() -> PlainTextResponse: |
623 | def robots() -> PlainTextResponse: |
| 413 | return PlainTextResponse( |
624 | return PlainTextResponse( |
| 445 | |
656 | |
| 446 | @router.get("/sitemap-pages.xml", include_in_schema=False) |
657 | @router.get("/sitemap-pages.xml", include_in_schema=False) |
| 447 | def sitemap_pages() -> Response: |
658 | def sitemap_pages() -> Response: |
| 448 | − return _urlset([(f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None), |
659 | + today = _today() |
| 449 | − (f"{BASE_URL}/contact", None)]) |
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 | @router.get("/sitemap-evenements-{num}.xml", include_in_schema=False) |
668 | @router.get("/sitemap-evenements-{num}.xml", include_in_schema=False) |
| 453 | |
669 | |