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%
31.5 KB · 706 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# seo.py : référencement — SSR léger des fiches événement, robots, sitemaps5#6# Principe (patron Lou-Ka seo.py) : pour chaque route publique, le serveur7# renvoie le MÊME index.html que le frontend, mais avec un <head> unique8# (title, description, canonical, og:, JSON-LD Event) et le contenu essentiel9# en HTML dans <div id="app">. Les moteurs voient une page complète sans10# exécuter JavaScript ; le frontend, en se montant, prend le relais.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import html as H15import json16from pathlib import Path1718from fastapi import APIRouter, HTTPException19from fastapi.responses import HTMLResponse, PlainTextResponse, Response2021from . import db2223router = APIRouter()2425ROOT = Path(__file__).resolve().parent.parent26FRONTEND_INDEX = ROOT / "frontend" / "index.html"2728BASE_URL = "https://www.sorti-ka.com"29SITE_NAME = "Sorti-Ka"30OG_IMAGE = f"{BASE_URL}/og.png"31# balises image de partage par défaut (og.png 1200×630 « Sorti-Ka — par Groupe KA »)32OG_IMAGE_TAGS = (33    f'<meta property="og:image" content="{OG_IMAGE}">\n'34    '<meta property="og:image:width" content="1200">\n'35    '<meta property="og:image:height" content="630">\n'36    '<meta name="twitter:card" content="summary_large_image">\n'37    f'<meta name="twitter:image" content="{OG_IMAGE}">'38)39SITEMAP_CHUNK = 100004041_SRC_LABELS = {42    "sitq": "SIT Québec — Ministère du Tourisme", "montreal": "Ville de Montréal",43    "laval": "Ville de Laval", "lepointdevente": "Le point de vente",44    "evenko": "evenko", "atuvu": "Atuvu.ca", "lavitrine": "La Vitrine",45    "sherbrooke": "Ville de Sherbrooke", "brossard": "Ville de Brossard",46    "ticketmaster": "Ticketmaster", "bandsintown": "Bandsintown",47    "eventbrite": "Eventbrite", "longueuil": "Ville de Longueuil",48    "cantonsdelest": "Tourisme Cantons-de-l'Est",49    "lavalweb": "Ville de Laval (laval.ca)", "outaouais": "Tourisme Outaouais",50    "lanaudiere": "Tourisme Lanaudière",51    "saguenaysorties": "Sorties Saguenay (Ville de Saguenay)",52    "bassaintlaurent": "Tourisme Bas-Saint-Laurent",53    "quebecanimee": "Québec animée (Ville de Québec)",54    "placedesarts": "Place des Arts", "lhjmq": "LHJMQ",55    "sallesjsonld": "Site officiel de la salle",56}575859_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}676869def _tz_offset(day: str) -> str:70    """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00")."""71    from datetime import datetime72    from zoneinfo import ZoneInfo73    try:74        dt = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("America/Montreal"))75        off = dt.utcoffset()76        sign = "-" if off.total_seconds() < 0 else "+"77        secs = abs(int(off.total_seconds()))78        return f"{sign}{secs // 3600:02d}:{(secs % 3600) // 60:02d}"79    except Exception:80        return "-05:00"818283def _e(s) -> str:84    return H.escape(str(s or ""), quote=True)858687def _n(x: int) -> str:88    """1234 → « 1 234 » (séparateur de milliers fr-CA)."""89    return f"{x:,}".replace(",", " ")909192def _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    )100101102def _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>")107108109def _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)]}115116117def _render(head: str, ssr_body: str) -> HTMLResponse:118    """index.html avec <head> réécrit et contenu SSR injecté dans #ssr."""119    import re120    tpl = FRONTEND_INDEX.read_text(encoding="utf-8")121    # retirer title/description/canonical par défaut (remplacés par le SSR)122    tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, count=1, flags=re.S)123    tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl, count=1)124    tpl = re.sub(r'<meta (?:property="og:image[^"]*"|name="twitter:[^"]*")[^>]*>\s*',125                 "", tpl)126    tpl = tpl.replace("<!--SSR_HEAD-->", head, 1)127    tpl = tpl.replace("<!--SSR_BODY-->", ssr_body, 1)128    return HTMLResponse(tpl)129130131def _event_or_404(uid: str) -> dict:132    con = db.connect()133    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()134    con.close()135    if row is None:136        raise HTTPException(404, "événement introuvable")137    d = dict(row)138    d["categories"] = json.loads(d.get("categories") or "[]")139    return d140141142# statut interne → eventStatus schema.org (soldout n'existe pas comme143# eventStatus : il se déclare via offers.availability SoldOut)144_LD_STATUS = {"cancelled": "EventCancelled", "postponed": "EventPostponed"}145146147def _jsonld(ev: dict) -> dict:148    ld: dict = {149        "@context": "https://schema.org",150        "@type": "Event",151        "name": ev["title"],152        "eventStatus": "https://schema.org/"153                       + _LD_STATUS.get(ev.get("status") or "", "EventScheduled"),154        "url": f"{BASE_URL}/evenement/{ev['uid']}",155    }156    if ev.get("start_date"):157        ld["startDate"] = ev["start_date"]158        if ev.get("start_time"):  # heure précise connue → ISO 8601 complet159            ld["startDate"] += f"T{ev['start_time']}:00{_tz_offset(ev['start_date'])}"160    if ev.get("end_date"):161        ld["endDate"] = ev["end_date"]162        if ev.get("end_time"):    # heure de fin connue → ISO 8601 complet163            ld["endDate"] += f"T{ev['end_time']}:00{_tz_offset(ev['end_date'])}"164    artists = json.loads(ev.get("artists") or "[]") \165        if isinstance(ev.get("artists"), str) else (ev.get("artists") or [])166    if artists:167        ld["performer"] = [{"@type": "PerformingGroup", "name": a}168                           for a in artists]169    if ev.get("description"):170        ld["description"] = ev["description"][:400]171    if ev.get("image"):172        ld["image"] = [ev["image"]]173    loc: dict = {"@type": "Place", "name": ev.get("venue") or ev.get("city") or "Québec"}174    addr = {"@type": "PostalAddress", "addressRegion": "QC", "addressCountry": "CA"}175    if ev.get("address"):176        addr["streetAddress"] = ev["address"]177    if ev.get("city"):178        addr["addressLocality"] = ev["city"]179    if ev.get("postal_code"):180        addr["postalCode"] = ev["postal_code"]181    loc["address"] = addr182    if ev.get("lat") and ev.get("lng"):183        loc["geo"] = {"@type": "GeoCoordinates",184                      "latitude": ev["lat"], "longitude": ev["lng"]}185    ld["location"] = loc186    if ev.get("is_free"):187        ld["isAccessibleForFree"] = True188    if ev.get("price_min") is not None:189        ld["offers"] = {"@type": "Offer", "price": f"{ev['price_min']:.2f}",190                        "priceCurrency": "CAD", "url": ev.get("url") or ""}191        if (ev.get("status") or "") == "soldout":192            ld["offers"]["availability"] = "https://schema.org/SoldOut"193    if ev.get("organizer"):194        ld["organizer"] = {"@type": "Organization", "name": ev["organizer"]}195    return ld196197198_STATUS_LABELS = {"cancelled": "Annulé", "postponed": "Reporté",199                  "soldout": "Complet"}200201202@router.get("/evenement/{uid}", include_in_schema=False)203def event_page(uid: str) -> HTMLResponse:204    ev = _event_or_404(uid)205    where = " · ".join(p for p in (ev.get("venue"), ev.get("city"),206                                   ev.get("region")) if p)207    when = ev.get("start_date") or ""208    if ev.get("end_date") and ev["end_date"] != ev.get("start_date"):209        when += f" au {ev['end_date']}"210    elif ev.get("start_time"):211        when += f" à {ev['start_time'].replace(':', ' h ')}"212        if ev.get("end_time"):213            when += f" – {ev['end_time'].replace(':', ' h ')}"214    status_label = _STATUS_LABELS.get(ev.get("status") or "", "")215    title = f"{ev['title']} — {ev.get('city') or 'Québec'} | {SITE_NAME}"216    desc = (ev.get("description") or217            f"{ev['title']}, {where}. Dates, lieu et lien vers la billetterie "218            f"officielle sur {SITE_NAME}.")[:300]219    canonical = f"{BASE_URL}/evenement/{_e(uid)}"220    head = (221        f"<title>{_e(title)}</title>\n"222        f'<meta name="description" content="{_e(desc)}">\n'223        f'<link rel="canonical" href="{canonical}">\n'224        + _head_extras(canonical) + "\n"225        f'<meta property="og:title" content="{_e(title)}">\n'226        f'<meta property="og:description" content="{_e(desc)}">\n'227        f'<meta property="og:type" content="event">\n'228        f'<meta property="og:url" content="{canonical}">\n'229        f'<meta name="twitter:title" content="{_e(title)}">\n'230        + (f'<meta property="og:image" content="{_e(ev["image"])}">\n'231           '<meta name="twitter:card" content="summary_large_image">\n'232           f'<meta name="twitter:image" content="{_e(ev["image"])}">\n'233           if ev.get("image") else OG_IMAGE_TAGS + "\n")234        + _ld_script(_jsonld(ev)) + "\n"235        + _ld_script(_breadcrumb([("Accueil", "/"),236                                  (ev["title"], f"/evenement/{uid}")]))237    )238    src = _SRC_LABELS.get(ev["source"], ev["source"])239    ssr = (240        f"<article><h1>{_e(ev['title'])}</h1>"241        + (f"<p><strong>{_e(status_label)}</strong></p>" if status_label else "")242        + f"<p>{_e(when)} — {_e(where)}</p>"243        + (f"<p>{_e(ev['description'][:500])}</p>" if ev.get("description") else "")244        + f'<p>Source : {_e(src)} — <a href="{_e(ev["url"])}" rel="noopener">'245          f"fiche originale et billets</a></p>"246        f'<p><a href="/">Toutes les sorties et tous les événements du Québec</a></p>'247        f"</article>"248    )249    return _render(head, ssr)250251252@router.get("/", include_in_schema=False)253def 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 date258    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) + 1274    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 = 0284285    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    )310311    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>")316317    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><a href="/region/{slugify(r["region"])}">'328                  f"{_e(r['region'])}</a> — {_n(r['n'])} événements</li>"329                  for r in regions) + "</ul>"330        '<p><a href="/regions">Toutes les régions</a></p>'331        "<h2>Par catégorie</h2><p>"332        + " · ".join(f'<a href="/categorie/{c}">{_e(_CAT_LABELS[c])}</a> ({_n(v)})'333                     for c, v in sorted(cats.items(), key=lambda kv: -kv[1])334                     if c in _CAT_LABELS) + "</p>"335        '<p><a href="/categories">Toutes les catégories</a></p>'336        '<p><a href="/stats">Statistiques Sorti-Ka</a> · '337        '<a href="/contact">Contact</a></p></article>'338    )339    return _render(head, ssr)340341342@router.get("/contact", include_in_schema=False)343def contact_page() -> HTMLResponse:344    """Page /contact (SPA) — head SEO + contenu SSR pour les robots.345    Contenu aligné sur ka-ui/ecosystem.json (contacts Groupe KA)."""346    title = f"Contact — {SITE_NAME} | Un service Groupe KA"347    desc = ("Écrire au Groupe KA : contact@groupe-ka.com (projets, partenariats "348            "et données), info@groupe-ka.com (médias et questions générales), "349            "admin@groupe-ka.com (légal, vie privée et Loi 25).")350    canonical = f"{BASE_URL}/contact"351    head = (352        f"<title>{_e(title)}</title>\n"353        f'<meta name="description" content="{_e(desc)}">\n'354        f'<link rel="canonical" href="{canonical}">\n'355        + _head_extras(canonical) + "\n"356        f'<meta property="og:title" content="{_e(title)}">\n'357        f'<meta property="og:description" content="{_e(desc)}">\n'358        f'<meta property="og:type" content="website">\n'359        f'<meta property="og:url" content="{canonical}">\n'360        f'<meta name="twitter:title" content="{_e(title)}">\n'361        + OG_IMAGE_TAGS + "\n"362        + _ld_script(_breadcrumb([("Accueil", "/"), ("Contact", "/contact")]))363    )364    ssr = (365        "<article><h1>Contact — Groupe KA</h1>"366        "<p>Sorti-Ka est un service du Groupe KA.</p>"367        '<p><a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> — '368        "projets, partenariats et données.</p>"369        '<p><a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a> — '370        "médias et questions générales.</p>"371        '<p><a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a> — '372        "légal, vie privée et Loi 25.</p>"373        '<p><a href="https://www.groupe-ka.com" rel="noopener">Groupe KA — '374        "le portail de l'écosystème</a></p></article>"375    )376    return _render(head, ssr)377378379@router.get("/favoris", include_in_schema=False)380def favoris_page() -> HTMLResponse:381    """Page /favoris (SPA) — favoris ♥ « Mon univers Ka » du membre connecté.382    Page privée (session KA ID) : noindex, absente des sitemaps."""383    title = f"Mes favoris — {SITE_NAME} | Mon univers Ka"384    desc = ("Vos sorties et événements favoris, synchronisés avec votre "385            "compte KA ID sur toutes les plateformes du Groupe KA.")386    head = (387        f"<title>{_e(title)}</title>\n"388        f'<meta name="description" content="{_e(desc)}">\n'389        '<meta name="robots" content="noindex">\n'390        f'<meta property="og:site_name" content="{SITE_NAME}">\n'391        '<meta property="og:locale" content="fr_CA">\n'392        + OG_IMAGE_TAGS393    )394    ssr = ("<article><h1>Mes favoris — Mon univers Ka</h1>"395           f"<p>{_e(desc)}</p>"396           '<p><a href="/">Toutes les sorties et tous les événements du '397           "Québec</a></p></article>")398    return _render(head, ssr)399400401@router.get("/stats", include_in_schema=False)402def stats_page() -> HTMLResponse:403    """Page /stats (SPA) — head SEO + contenu SSR pour les robots."""404    con = db.connect()405    n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]406    con.close()407    title = f"Statistiques — {SITE_NAME} | Un service Groupe KA"408    desc = (f"Tableau de bord analytique de {SITE_NAME} : {n:,} événements actifs"409            .replace(",", " ") +410            " — ajouts par jour, catégories, gratuits vs payants, régions, "411            "calendrier des jours les plus chargés et rapport PDF Groupe KA.")412    canonical = f"{BASE_URL}/stats"413    head = (414        f"<title>{_e(title)}</title>\n"415        f'<meta name="description" content="{_e(desc)}">\n'416        f'<link rel="canonical" href="{canonical}">\n'417        + _head_extras(canonical) + "\n"418        f'<meta property="og:title" content="{_e(title)}">\n'419        f'<meta property="og:description" content="{_e(desc)}">\n'420        f'<meta property="og:type" content="website">\n'421        f'<meta property="og:url" content="{canonical}">\n'422        f'<meta name="twitter:title" content="{_e(title)}">\n'423        + OG_IMAGE_TAGS + "\n"424        + _ld_script(_breadcrumb([("Accueil", "/"),425                                  ("Statistiques", "/stats")]))426    )427    ssr = (428        "<article><h1>Statistiques Sorti-Ka</h1>"429        f"<p>{_e(desc)}</p>"430        '<p><a href="/">Toutes les sorties et tous les événements du Québec</a></p>'431        "</article>"432    )433    return _render(head, ssr)434435436# --- Pages programmatiques : régions et catégories -----------------------------437438def slugify(text: str) -> str:439    """« Bas-Saint-Laurent » → bas-saint-laurent, « Montérégie » → monteregie."""440    import re as _re441    import unicodedata442    t = text.replace("œ", "oe").replace("Œ", "Oe")443    t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()444    return _re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")445446447_UP = ("active=1 AND quarantine IS NULL AND "448       "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")449450451def _today() -> str:452    from datetime import date453    return date.today().isoformat()454455456def _regions_rows() -> list[dict]:457    """[{region, slug, n}] des régions avec événements à venir."""458    today = _today()459    con = db.connect()460    rows = con.execute(461        f"SELECT region, COUNT(*) AS n FROM events WHERE {_UP} AND region != '' "462        "GROUP BY region ORDER BY n DESC", (today, today)).fetchall()463    con.close()464    return [{"region": r["region"], "slug": slugify(r["region"]), "n": r["n"]}465            for r in rows]466467468def _cats_rows() -> list[dict]:469    """[{cat, label, n}] des catégories du vocabulaire avec compte à venir."""470    today = _today()471    con = db.connect()472    counts: dict[str, int] = {}473    for r in con.execute(f"SELECT categories FROM events WHERE {_UP}",474                         (today, today)):475        for c in json.loads(r["categories"] or "[]"):476            counts[c] = counts.get(c, 0) + 1477    con.close()478    return [{"cat": c, "label": _CAT_LABELS[c], "n": counts.get(c, 0)}479            for c in sorted(_CAT_LABELS, key=lambda c: -counts.get(c, 0))480            if counts.get(c, 0) > 0]481482483def _std_head(title: str, desc: str, path: str, lds: list[dict]) -> str:484    canonical = BASE_URL + path485    return (486        f"<title>{_e(title)}</title>\n"487        f'<meta name="description" content="{_e(desc)}">\n'488        f'<link rel="canonical" href="{canonical}">\n'489        + _head_extras(canonical) + "\n"490        f'<meta property="og:title" content="{_e(title)}">\n'491        f'<meta property="og:description" content="{_e(desc)}">\n'492        f'<meta property="og:type" content="website">\n'493        f'<meta property="og:url" content="{canonical}">\n'494        f'<meta name="twitter:title" content="{_e(title)}">\n'495        + OG_IMAGE_TAGS + "\n"496        + "\n".join(_ld_script(o) for o in lds))497498499def _ev_li_full(r) -> str:500    where = " · ".join(p for p in (r["venue"], r["city"]) if p)501    bits = " — ".join(p for p in (r["start_date"], where) if p)502    return (f'<li><a href="/evenement/{_e(r["uid"])}">{_e(r["title"])}</a>'503            + (f" — {_e(bits)}" if bits else "") + "</li>")504505506def _events_list_page(*, path: str, h1: str, title: str, desc: str,507                      rows, n: int, crumbs, others_html: str) -> HTMLResponse:508    lds = [_breadcrumb(crumbs),509           {"@context": "https://schema.org", "@type": "ItemList",510            "name": h1, "numberOfItems": n,511            "itemListElement": [512                {"@type": "ListItem", "position": i + 1, "name": r["title"],513                 "url": f"{BASE_URL}/evenement/{r['uid']}"}514                for i, r in enumerate(rows[:50])]}]515    ssr = (f"<article><h1>{_e(h1)} — {_n(n)} événements à venir</h1>"516           f"<p>{_e(desc)}</p>"517           "<h2>Prochains événements</h2><ul>"518           + "".join(_ev_li_full(r) for r in rows) + "</ul>"519           + others_html520           + '<p><a href="/regions">Toutes les régions</a> · '521             '<a href="/categories">Toutes les catégories</a> · '522             '<a href="/">Toutes les sorties du Québec</a></p></article>')523    return _render(_std_head(title, desc, path, lds), ssr)524525526@router.get("/regions", include_in_schema=False)527def regions_page() -> HTMLResponse:528    regions = _regions_rows()529    total = sum(r["n"] for r in regions)530    title = (f"Sorties et événements par région au Québec "531             f"({len(regions)} régions) | {SITE_NAME}")532    desc = (f"{_n(total)} événements à venir dans {len(regions)} régions du "533            "Québec : concerts, festivals, spectacles, expositions et "534            "activités famille, région par région.")535    lds = [_breadcrumb([("Accueil", "/"), ("Régions", "/regions")]),536           {"@context": "https://schema.org", "@type": "ItemList",537            "name": "Événements par région au Québec",538            "numberOfItems": len(regions),539            "itemListElement": [540                {"@type": "ListItem", "position": i + 1,541                 "name": f"Événements en {r['region']}",542                 "url": f"{BASE_URL}/region/{r['slug']}"}543                for i, r in enumerate(regions)]}]544    ssr = (f"<article><h1>Sorties par région — {len(regions)} régions du "545           "Québec</h1><ul>"546           + "".join(f'<li><a href="/region/{r["slug"]}">Événements — '547                     f'{_e(r["region"])}</a> — {_n(r["n"])} à venir</li>'548                     for r in regions)549           + '</ul><p><a href="/categories">Par catégorie</a> · '550             '<a href="/">Toutes les sorties du Québec</a></p></article>')551    return _render(_std_head(title, desc, "/regions", lds), ssr)552553554@router.get("/region/{slug}", include_in_schema=False)555def region_page(slug: str) -> HTMLResponse:556    regions = _regions_rows()557    match = next((r for r in regions if r["slug"] == slug), None)558    if match is None:559        raise HTTPException(404, "région inconnue")560    region, n = match["region"], match["n"]561    today = _today()562    con = db.connect()563    rows = con.execute(564        f"SELECT uid, title, venue, city, start_date FROM events "565        f"WHERE {_UP} AND region=? "566        "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 100",567        (today, today, region, today)).fetchall()568    con.close()569    path = f"/region/{slug}"570    title = f"Sorties et événements — {region} ({_n(n)} à venir) | {SITE_NAME}"571    desc = (f"{_n(n)} événements à venir en {region} : concerts, festivals, "572            "spectacles, expositions et activités famille, avec dates, lieux "573            "et lien direct vers la billetterie ou la source officielle.")574    others = [r for r in regions if r["slug"] != slug][:12]575    others_html = ("<h2>Autres régions</h2><ul>" + "".join(576        f'<li><a href="/region/{r["slug"]}">{_e(r["region"])}</a>'577        f' — {_n(r["n"])}</li>' for r in others) + "</ul>") if others else ""578    return _events_list_page(579        path=path, h1=f"Sorties et événements — {region}", title=title,580        desc=desc, rows=rows, n=n,581        crumbs=[("Accueil", "/"), ("Régions", "/regions"), (region, path)],582        others_html=others_html)583584585@router.get("/categories", include_in_schema=False)586def categories_page() -> HTMLResponse:587    cats = _cats_rows()588    total = sum(c["n"] for c in cats)589    title = f"Sorties par catégorie au Québec ({len(cats)} catégories) | {SITE_NAME}"590    desc = (f"{_n(total)} événements à venir au Québec classés en "591            f"{len(cats)} catégories : festivals, musique, arts de la scène, "592            "expositions, sport, plein air, famille et plus.")593    lds = [_breadcrumb([("Accueil", "/"), ("Catégories", "/categories")]),594           {"@context": "https://schema.org", "@type": "ItemList",595            "name": "Événements par catégorie au Québec",596            "numberOfItems": len(cats),597            "itemListElement": [598                {"@type": "ListItem", "position": i + 1,599                 "name": c["label"],600                 "url": f"{BASE_URL}/categorie/{c['cat']}"}601                for i, c in enumerate(cats)]}]602    ssr = (f"<article><h1>Sorties par catégorie — {len(cats)} catégories</h1><ul>"603           + "".join(f'<li><a href="/categorie/{c["cat"]}">{_e(c["label"])}</a>'604                     f' — {_n(c["n"])} événements à venir</li>' for c in cats)605           + '</ul><p><a href="/regions">Par région</a> · '606             '<a href="/">Toutes les sorties du Québec</a></p></article>')607    return _render(_std_head(title, desc, "/categories", lds), ssr)608609610@router.get("/categorie/{cat}", include_in_schema=False)611def categorie_page(cat: str) -> HTMLResponse:612    if cat not in _CAT_LABELS:613        raise HTTPException(404, "catégorie inconnue")614    label = _CAT_LABELS[cat]615    today = _today()616    con = db.connect()617    like = f'%"{cat}"%'618    n = con.execute(619        f"SELECT COUNT(*) FROM events WHERE {_UP} AND categories LIKE ?",620        (today, today, like)).fetchone()[0]621    rows = con.execute(622        f"SELECT uid, title, venue, city, start_date FROM events "623        f"WHERE {_UP} AND categories LIKE ? "624        "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 100",625        (today, today, like, today)).fetchall()626    con.close()627    if n == 0:628        raise HTTPException(404, "aucun événement à venir dans cette catégorie")629    path = f"/categorie/{cat}"630    title = f"{label} au Québec — {_n(n)} événements à venir | {SITE_NAME}"631    desc = (f"{_n(n)} événements « {label} » à venir au Québec, avec dates, "632            "lieux et lien direct vers la billetterie ou la source officielle.")633    others = [c for c in _cats_rows() if c["cat"] != cat][:12]634    others_html = ("<h2>Autres catégories</h2><ul>" + "".join(635        f'<li><a href="/categorie/{c["cat"]}">{_e(c["label"])}</a>'636        f' — {_n(c["n"])}</li>' for c in others) + "</ul>") if others else ""637    return _events_list_page(638        path=path, h1=f"{label} au Québec", title=title, desc=desc,639        rows=rows, n=n,640        crumbs=[("Accueil", "/"), ("Catégories", "/categories"), (label, path)],641        others_html=others_html)642643644@router.get("/robots.txt", include_in_schema=False)645def robots() -> PlainTextResponse:646    return PlainTextResponse(647        "User-agent: *\nAllow: /\n"648        "Disallow: /api/\n"649        f"Sitemap: {BASE_URL}/sitemap.xml\n")650651652def _urlset(rows: list[tuple[str, str | None]]) -> Response:653    items = "".join(654        f"<url><loc>{_e(u)}</loc>" + (f"<lastmod>{m}</lastmod>" if m else "")655        + "</url>" for u, m in rows)656    return Response(657        '<?xml version="1.0" encoding="UTF-8"?>'658        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'659        + items + "</urlset>", media_type="application/xml")660661662@router.get("/sitemap.xml", include_in_schema=False)663def sitemap_index() -> Response:664    con = db.connect()665    n = con.execute("SELECT COUNT(*) FROM events WHERE active=1 "666                    "AND quarantine IS NULL").fetchone()[0]667    con.close()668    chunks = max(1, -(-n // SITEMAP_CHUNK))669    items = "".join(670        f"<sitemap><loc>{BASE_URL}/sitemap-evenements-{i}.xml</loc></sitemap>"671        for i in range(chunks))672    return Response(673        '<?xml version="1.0" encoding="UTF-8"?>'674        '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'675        f"<sitemap><loc>{BASE_URL}/sitemap-pages.xml</loc></sitemap>"676        + items + "</sitemapindex>", media_type="application/xml")677678679@router.get("/sitemap-pages.xml", include_in_schema=False)680def sitemap_pages() -> Response:681    today = _today()682    urls = [(f"{BASE_URL}/", None), (f"{BASE_URL}/regions", None),683            (f"{BASE_URL}/categories", None),684            (f"{BASE_URL}/stats", None), (f"{BASE_URL}/contact", None)]685    urls += [(f"{BASE_URL}/region/{r['slug']}", today) for r in _regions_rows()]686    urls += [(f"{BASE_URL}/categorie/{c['cat']}", today) for c in _cats_rows()]687    return _urlset(urls)688689690@router.get("/sitemap-evenements-{num}.xml", include_in_schema=False)691def sitemap_events(num: int) -> Response:692    from datetime import datetime, timezone693    con = db.connect()694    rows = con.execute(695        "SELECT uid, updated_at FROM events WHERE active=1 "696        "AND quarantine IS NULL ORDER BY uid LIMIT ? OFFSET ?",697        (SITEMAP_CHUNK, num * SITEMAP_CHUNK)).fetchall()698    con.close()699    def iso(ts):700        try:701            return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")702        except Exception:703            return None704    return _urlset([(f"{BASE_URL}/evenement/{r['uid']}", iso(r["updated_at"]))705                    for r in rows])706