# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # seo.py : référencement — SSR léger des fiches événement, robots, sitemaps # # Principe (patron Lou-Ka seo.py) : pour chaque route publique, le serveur # renvoie le MÊME index.html que le frontend, mais avec un unique # (title, description, canonical, og:, JSON-LD Event) et le contenu essentiel # en HTML dans
. Les moteurs voient une page complète sans # exécuter JavaScript ; le frontend, en se montant, prend le relais. # ----------------------------------------------------------------------------- from __future__ import annotations import html as H import json from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import db router = APIRouter() ROOT = Path(__file__).resolve().parent.parent FRONTEND_INDEX = ROOT / "frontend" / "index.html" BASE_URL = "https://www.sorti-ka.com" SITE_NAME = "Sorti-Ka" OG_IMAGE = f"{BASE_URL}/og.png" # balises image de partage par défaut (og.png 1200×630 « Sorti-Ka — par Groupe KA ») OG_IMAGE_TAGS = ( f'\n' '\n' '\n' '\n' f'' ) SITEMAP_CHUNK = 10000 _SRC_LABELS = { "sitq": "SIT Québec — Ministère du Tourisme", "montreal": "Ville de Montréal", "laval": "Ville de Laval", "lepointdevente": "Le point de vente", "evenko": "evenko", "atuvu": "Atuvu.ca", "lavitrine": "La Vitrine", "sherbrooke": "Ville de Sherbrooke", "brossard": "Ville de Brossard", "ticketmaster": "Ticketmaster", "bandsintown": "Bandsintown", "eventbrite": "Eventbrite", "longueuil": "Ville de Longueuil", "cantonsdelest": "Tourisme Cantons-de-l'Est", "lavalweb": "Ville de Laval (laval.ca)", "outaouais": "Tourisme Outaouais", "lanaudiere": "Tourisme Lanaudière", "saguenaysorties": "Sorties Saguenay (Ville de Saguenay)", "bassaintlaurent": "Tourisme Bas-Saint-Laurent", "quebecanimee": "Québec animée (Ville de Québec)", "placedesarts": "Place des Arts", "lhjmq": "LHJMQ", "sallesjsonld": "Site officiel de la salle", } _CAT_LABELS = { "festival": "Festivals", "musique": "Musique", "arts-scene": "Arts de la scène", "exposition-musee": "Expos et musées", "cinema": "Cinéma", "sport": "Sport", "plein-air": "Plein air", "famille": "Famille", "gastronomie": "Gastronomie", "marche-foire": "Marchés et foires", "conference": "Conférences et ateliers", "communautaire": "Communautaire", "patrimoine": "Patrimoine", } def _tz_offset(day: str) -> str: """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00").""" from datetime import datetime from zoneinfo import ZoneInfo try: dt = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("America/Montreal")) off = dt.utcoffset() sign = "-" if off.total_seconds() < 0 else "+" secs = abs(int(off.total_seconds())) return f"{sign}{secs // 3600:02d}:{(secs % 3600) // 60:02d}" except Exception: return "-05:00" def _e(s) -> str: return H.escape(str(s or ""), quote=True) def _n(x: int) -> str: """1234 → « 1 234 » (séparateur de milliers fr-CA).""" return f"{x:,}".replace(",", " ") def _head_extras(canonical: str) -> str: """hreflang + og:site_name/og:locale communs à toutes les pages SSR.""" return ( f'\n' f'\n' f'\n' '' ) def _ld_script(obj: dict) -> str: """Bloc ") def _breadcrumb(items: list[tuple[str, str]]) -> dict: return {"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": name, "item": BASE_URL + path} for i, (name, path) in enumerate(items)]} def _render(head: str, ssr_body: str) -> HTMLResponse: """index.html avec réécrit et contenu SSR injecté dans #ssr.""" import re tpl = FRONTEND_INDEX.read_text(encoding="utf-8") # retirer title/description/canonical par défaut (remplacés par le SSR) tpl = re.sub(r".*?\s*", "", tpl, count=1, flags=re.S) tpl = re.sub(r']*>\s*', "", tpl, count=1) tpl = re.sub(r']*>\s*', "", tpl) tpl = tpl.replace("", head, 1) tpl = tpl.replace("", ssr_body, 1) return HTMLResponse(tpl) def _event_or_404(uid: str) -> dict: con = db.connect() row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone() con.close() if row is None: raise HTTPException(404, "événement introuvable") d = dict(row) d["categories"] = json.loads(d.get("categories") or "[]") return d # statut interne → eventStatus schema.org (soldout n'existe pas comme # eventStatus : il se déclare via offers.availability SoldOut) _LD_STATUS = {"cancelled": "EventCancelled", "postponed": "EventPostponed"} def _jsonld(ev: dict) -> dict: ld: dict = { "@context": "https://schema.org", "@type": "Event", "name": ev["title"], "eventStatus": "https://schema.org/" + _LD_STATUS.get(ev.get("status") or "", "EventScheduled"), "url": f"{BASE_URL}/evenement/{ev['uid']}", } if ev.get("start_date"): ld["startDate"] = ev["start_date"] if ev.get("start_time"): # heure précise connue → ISO 8601 complet ld["startDate"] += f"T{ev['start_time']}:00{_tz_offset(ev['start_date'])}" if ev.get("end_date"): ld["endDate"] = ev["end_date"] if ev.get("end_time"): # heure de fin connue → ISO 8601 complet ld["endDate"] += f"T{ev['end_time']}:00{_tz_offset(ev['end_date'])}" artists = json.loads(ev.get("artists") or "[]") \ if isinstance(ev.get("artists"), str) else (ev.get("artists") or []) if artists: ld["performer"] = [{"@type": "PerformingGroup", "name": a} for a in artists] if ev.get("description"): ld["description"] = ev["description"][:400] if ev.get("image"): ld["image"] = [ev["image"]] loc: dict = {"@type": "Place", "name": ev.get("venue") or ev.get("city") or "Québec"} addr = {"@type": "PostalAddress", "addressRegion": "QC", "addressCountry": "CA"} if ev.get("address"): addr["streetAddress"] = ev["address"] if ev.get("city"): addr["addressLocality"] = ev["city"] if ev.get("postal_code"): addr["postalCode"] = ev["postal_code"] loc["address"] = addr if ev.get("lat") and ev.get("lng"): loc["geo"] = {"@type": "GeoCoordinates", "latitude": ev["lat"], "longitude": ev["lng"]} ld["location"] = loc if ev.get("is_free"): ld["isAccessibleForFree"] = True if ev.get("price_min") is not None: ld["offers"] = {"@type": "Offer", "price": f"{ev['price_min']:.2f}", "priceCurrency": "CAD", "url": ev.get("url") or ""} if (ev.get("status") or "") == "soldout": ld["offers"]["availability"] = "https://schema.org/SoldOut" if ev.get("organizer"): ld["organizer"] = {"@type": "Organization", "name": ev["organizer"]} return ld _STATUS_LABELS = {"cancelled": "Annulé", "postponed": "Reporté", "soldout": "Complet"} @router.get("/evenement/{uid}", include_in_schema=False) def event_page(uid: str) -> HTMLResponse: ev = _event_or_404(uid) where = " · ".join(p for p in (ev.get("venue"), ev.get("city"), ev.get("region")) if p) when = ev.get("start_date") or "" if ev.get("end_date") and ev["end_date"] != ev.get("start_date"): when += f" au {ev['end_date']}" elif ev.get("start_time"): when += f" à {ev['start_time'].replace(':', ' h ')}" if ev.get("end_time"): when += f" – {ev['end_time'].replace(':', ' h ')}" status_label = _STATUS_LABELS.get(ev.get("status") or "", "") title = f"{ev['title']} — {ev.get('city') or 'Québec'} | {SITE_NAME}" desc = (ev.get("description") or f"{ev['title']}, {where}. Dates, lieu et lien vers la billetterie " f"officielle sur {SITE_NAME}.")[:300] canonical = f"{BASE_URL}/evenement/{_e(uid)}" head = ( f"{_e(title)}\n" f'\n' f'\n' + _head_extras(canonical) + "\n" f'\n' f'\n' f'\n' f'\n' f'\n' + (f'\n' '\n' f'\n' if ev.get("image") else OG_IMAGE_TAGS + "\n") + _ld_script(_jsonld(ev)) + "\n" + _ld_script(_breadcrumb([("Accueil", "/"), (ev["title"], f"/evenement/{uid}")])) ) src = _SRC_LABELS.get(ev["source"], ev["source"]) ssr = ( f"

{_e(ev['title'])}

" + (f"

{_e(status_label)}

" if status_label else "") + f"

{_e(when)} — {_e(where)}

" + (f"

{_e(ev['description'][:500])}

" if ev.get("description") else "") + f'

Source : {_e(src)} — ' f"fiche originale et billets

" f'

Toutes les sorties et tous les événements du Québec

' f"
" ) return _render(head, ssr) @router.get("/", include_in_schema=False) def home_page() -> HTMLResponse: """Accueil « / » en SSR : head riche (title avec stats, canonical, OG, JSON-LD WebSite/Organization) + contenu essentiel pour les robots. Prend le pas sur le fallback SPA de web.py (seo.router est inclus avant).""" from datetime import date today = date.today().isoformat() up = ("active=1 AND quarantine IS NULL AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))") con = db.connect() n = con.execute(f"SELECT COUNT(*) FROM events WHERE {up}", (today, today)).fetchone()[0] n_free = con.execute(f"SELECT COUNT(*) FROM events WHERE {up} AND is_free=1", (today, today)).fetchone()[0] regions = con.execute( f"SELECT region, COUNT(*) AS n FROM events WHERE {up} AND region != '' " "GROUP BY region ORDER BY n DESC", (today, today)).fetchall() cats: dict[str, int] = {} for r in con.execute(f"SELECT categories FROM events WHERE {up}", (today, today)): for c in json.loads(r["categories"] or "[]"): cats[c] = cats.get(c, 0) + 1 nexts = con.execute( f"SELECT uid, title, venue, city, start_date FROM events WHERE {up} " "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 20", (today, today, today)).fetchall() con.close() try: # sources branchées (statut actif du registre) reg = json.loads((ROOT / "data" / "sources.json").read_text(encoding="utf-8")) n_src = sum(1 for s in reg.get("sources", []) if s.get("statut") == "actif") except Exception: n_src = 0 title = (f"Sorties et événements au Québec — {_n(n)} événements à venir " f"| {SITE_NAME}") desc = (f"{_n(n)} concerts, festivals, spectacles, expositions et activités " f"à venir dans les 17 régions du Québec, dont {_n(n_free)} gratuits. " "Agrégés depuis des billetteries et des données ouvertes " "officielles, avec dates, lieux et lien direct vers la billetterie.") canonical = f"{BASE_URL}/" head = ( f"{_e(title)}\n" f'\n' f'\n' + _head_extras(canonical) + "\n" f'\n' f'\n' f'\n' f'\n' f'\n' + OG_IMAGE_TAGS + "\n" + _ld_script({"@context": "https://schema.org", "@type": "WebSite", "name": SITE_NAME, "url": canonical, "description": desc, "inLanguage": "fr-CA"}) + "\n" + _ld_script({"@context": "https://schema.org", "@type": "Organization", "name": f"{SITE_NAME} (Groupe KA)", "url": canonical, "logo": OG_IMAGE}) ) def _ev_li(r) -> str: where = " · ".join(p for p in (r["venue"], r["city"]) if p) bits = " — ".join(p for p in (r["start_date"], where) if p) return (f'
  • {_e(r["title"])}' + (f" — {_e(bits)}" if bits else "") + "
  • ") ssr = ( f"

    Sorties et événements au Québec — {_n(n)} à venir

    " f"

    {SITE_NAME} agrège concerts, festivals, spectacles, expositions, " f"sport et activités famille depuis {n_src} billetteries et sources " f"ouvertes officielles, dans les 17 régions du Québec. {_n(n_free)} " "événements gratuits à venir. Chaque fiche renvoie vers la billetterie " "ou la source originale.

    " "

    Prochains événements

    " "

    Événements à venir par région

    " '

    Toutes les régions

    ' "

    Par catégorie

    " + " · ".join(f'{_e(_CAT_LABELS[c])} ({_n(v)})' for c, v in sorted(cats.items(), key=lambda kv: -kv[1]) if c in _CAT_LABELS) + "

    " '

    Toutes les catégories

    ' '

    Statistiques Sorti-Ka · ' 'Contact

    ' ) return _render(head, ssr) @router.get("/contact", include_in_schema=False) def contact_page() -> HTMLResponse: """Page /contact (SPA) — head SEO + contenu SSR pour les robots. Contenu aligné sur ka-ui/ecosystem.json (contacts Groupe KA).""" title = f"Contact — {SITE_NAME} | Un service Groupe KA" desc = ("Écrire au Groupe KA : contact@groupe-ka.com (projets, partenariats " "et données), info@groupe-ka.com (médias et questions générales), " "admin@groupe-ka.com (légal, vie privée et Loi 25).") canonical = f"{BASE_URL}/contact" head = ( f"{_e(title)}\n" f'\n' f'\n' + _head_extras(canonical) + "\n" f'\n' f'\n' f'\n' f'\n' f'\n' + OG_IMAGE_TAGS + "\n" + _ld_script(_breadcrumb([("Accueil", "/"), ("Contact", "/contact")])) ) ssr = ( "

    Contact — Groupe KA

    " "

    Sorti-Ka est un service du Groupe KA.

    " '

    contact@groupe-ka.com — ' "projets, partenariats et données.

    " '

    info@groupe-ka.com — ' "médias et questions générales.

    " '

    admin@groupe-ka.com — ' "légal, vie privée et Loi 25.

    " '

    Groupe KA — ' "le portail de l'écosystème

    " ) return _render(head, ssr) @router.get("/favoris", include_in_schema=False) def favoris_page() -> HTMLResponse: """Page /favoris (SPA) — favoris ♥ « Mon univers Ka » du membre connecté. Page privée (session KA ID) : noindex, absente des sitemaps.""" title = f"Mes favoris — {SITE_NAME} | Mon univers Ka" desc = ("Vos sorties et événements favoris, synchronisés avec votre " "compte KA ID sur toutes les plateformes du Groupe KA.") head = ( f"{_e(title)}\n" f'\n' '\n' f'\n' '\n' + OG_IMAGE_TAGS ) ssr = ("

    Mes favoris — Mon univers Ka

    " f"

    {_e(desc)}

    " '

    Toutes les sorties et tous les événements du ' "Québec

    ") return _render(head, ssr) @router.get("/stats", include_in_schema=False) def stats_page() -> HTMLResponse: """Page /stats (SPA) — head SEO + contenu SSR pour les robots.""" con = db.connect() n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0] con.close() title = f"Statistiques — {SITE_NAME} | Un service Groupe KA" desc = (f"Tableau de bord analytique de {SITE_NAME} : {n:,} événements actifs" .replace(",", " ") + " — ajouts par jour, catégories, gratuits vs payants, régions, " "calendrier des jours les plus chargés et rapport PDF Groupe KA.") canonical = f"{BASE_URL}/stats" head = ( f"{_e(title)}\n" f'\n' f'\n' + _head_extras(canonical) + "\n" f'\n' f'\n' f'\n' f'\n' f'\n' + OG_IMAGE_TAGS + "\n" + _ld_script(_breadcrumb([("Accueil", "/"), ("Statistiques", "/stats")])) ) ssr = ( "

    Statistiques Sorti-Ka

    " f"

    {_e(desc)}

    " '

    Toutes les sorties et tous les événements du Québec

    ' "
    " ) return _render(head, ssr) # --- Pages programmatiques : régions et catégories ----------------------------- def slugify(text: str) -> str: """« Bas-Saint-Laurent » → bas-saint-laurent, « Montérégie » → monteregie.""" import re as _re import unicodedata t = text.replace("œ", "oe").replace("Œ", "Oe") t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() return _re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") _UP = ("active=1 AND quarantine IS NULL AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))") def _today() -> str: from datetime import date return date.today().isoformat() def _regions_rows() -> list[dict]: """[{region, slug, n}] des régions avec événements à venir.""" today = _today() con = db.connect() rows = con.execute( f"SELECT region, COUNT(*) AS n FROM events WHERE {_UP} AND region != '' " "GROUP BY region ORDER BY n DESC", (today, today)).fetchall() con.close() return [{"region": r["region"], "slug": slugify(r["region"]), "n": r["n"]} for r in rows] def _cats_rows() -> list[dict]: """[{cat, label, n}] des catégories du vocabulaire avec compte à venir.""" today = _today() con = db.connect() counts: dict[str, int] = {} for r in con.execute(f"SELECT categories FROM events WHERE {_UP}", (today, today)): for c in json.loads(r["categories"] or "[]"): counts[c] = counts.get(c, 0) + 1 con.close() return [{"cat": c, "label": _CAT_LABELS[c], "n": counts.get(c, 0)} for c in sorted(_CAT_LABELS, key=lambda c: -counts.get(c, 0)) if counts.get(c, 0) > 0] def _std_head(title: str, desc: str, path: str, lds: list[dict]) -> str: canonical = BASE_URL + path return ( f"{_e(title)}\n" f'\n' f'\n' + _head_extras(canonical) + "\n" f'\n' f'\n' f'\n' f'\n' f'\n' + OG_IMAGE_TAGS + "\n" + "\n".join(_ld_script(o) for o in lds)) def _ev_li_full(r) -> str: where = " · ".join(p for p in (r["venue"], r["city"]) if p) bits = " — ".join(p for p in (r["start_date"], where) if p) return (f'
  • {_e(r["title"])}' + (f" — {_e(bits)}" if bits else "") + "
  • ") def _events_list_page(*, path: str, h1: str, title: str, desc: str, rows, n: int, crumbs, others_html: str) -> HTMLResponse: lds = [_breadcrumb(crumbs), {"@context": "https://schema.org", "@type": "ItemList", "name": h1, "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["title"], "url": f"{BASE_URL}/evenement/{r['uid']}"} for i, r in enumerate(rows[:50])]}] ssr = (f"

    {_e(h1)} — {_n(n)} événements à venir

    " f"

    {_e(desc)}

    " "

    Prochains événements

    " + others_html + '

    Toutes les régions · ' 'Toutes les catégories · ' 'Toutes les sorties du Québec

    ') return _render(_std_head(title, desc, path, lds), ssr) @router.get("/regions", include_in_schema=False) def regions_page() -> HTMLResponse: regions = _regions_rows() total = sum(r["n"] for r in regions) title = (f"Sorties et événements par région au Québec " f"({len(regions)} régions) | {SITE_NAME}") desc = (f"{_n(total)} événements à venir dans {len(regions)} régions du " "Québec : concerts, festivals, spectacles, expositions et " "activités famille, région par région.") lds = [_breadcrumb([("Accueil", "/"), ("Régions", "/regions")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Événements par région au Québec", "numberOfItems": len(regions), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": f"Événements en {r['region']}", "url": f"{BASE_URL}/region/{r['slug']}"} for i, r in enumerate(regions)]}] ssr = (f"

    Sorties par région — {len(regions)} régions du " "Québec

    Par catégorie · ' 'Toutes les sorties du Québec

    ') return _render(_std_head(title, desc, "/regions", lds), ssr) @router.get("/region/{slug}", include_in_schema=False) def region_page(slug: str) -> HTMLResponse: regions = _regions_rows() match = next((r for r in regions if r["slug"] == slug), None) if match is None: raise HTTPException(404, "région inconnue") region, n = match["region"], match["n"] today = _today() con = db.connect() rows = con.execute( f"SELECT uid, title, venue, city, start_date FROM events " f"WHERE {_UP} AND region=? " "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 100", (today, today, region, today)).fetchall() con.close() path = f"/region/{slug}" title = f"Sorties et événements — {region} ({_n(n)} à venir) | {SITE_NAME}" desc = (f"{_n(n)} événements à venir en {region} : concerts, festivals, " "spectacles, expositions et activités famille, avec dates, lieux " "et lien direct vers la billetterie ou la source officielle.") others = [r for r in regions if r["slug"] != slug][:12] others_html = ("

    Autres régions

    ") if others else "" return _events_list_page( path=path, h1=f"Sorties et événements — {region}", title=title, desc=desc, rows=rows, n=n, crumbs=[("Accueil", "/"), ("Régions", "/regions"), (region, path)], others_html=others_html) @router.get("/categories", include_in_schema=False) def categories_page() -> HTMLResponse: cats = _cats_rows() total = sum(c["n"] for c in cats) title = f"Sorties par catégorie au Québec ({len(cats)} catégories) | {SITE_NAME}" desc = (f"{_n(total)} événements à venir au Québec classés en " f"{len(cats)} catégories : festivals, musique, arts de la scène, " "expositions, sport, plein air, famille et plus.") lds = [_breadcrumb([("Accueil", "/"), ("Catégories", "/categories")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Événements par catégorie au Québec", "numberOfItems": len(cats), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": c["label"], "url": f"{BASE_URL}/categorie/{c['cat']}"} for i, c in enumerate(cats)]}] ssr = (f"

    Sorties par catégorie — {len(cats)} catégories

    Par région · ' 'Toutes les sorties du Québec

    ') return _render(_std_head(title, desc, "/categories", lds), ssr) @router.get("/categorie/{cat}", include_in_schema=False) def categorie_page(cat: str) -> HTMLResponse: if cat not in _CAT_LABELS: raise HTTPException(404, "catégorie inconnue") label = _CAT_LABELS[cat] today = _today() con = db.connect() like = f'%"{cat}"%' n = con.execute( f"SELECT COUNT(*) FROM events WHERE {_UP} AND categories LIKE ?", (today, today, like)).fetchone()[0] rows = con.execute( f"SELECT uid, title, venue, city, start_date FROM events " f"WHERE {_UP} AND categories LIKE ? " "ORDER BY start_date IS NULL, MAX(start_date, ?) ASC, title LIMIT 100", (today, today, like, today)).fetchall() con.close() if n == 0: raise HTTPException(404, "aucun événement à venir dans cette catégorie") path = f"/categorie/{cat}" title = f"{label} au Québec — {_n(n)} événements à venir | {SITE_NAME}" desc = (f"{_n(n)} événements « {label} » à venir au Québec, avec dates, " "lieux et lien direct vers la billetterie ou la source officielle.") others = [c for c in _cats_rows() if c["cat"] != cat][:12] others_html = ("

    Autres catégories

    ") if others else "" return _events_list_page( path=path, h1=f"{label} au Québec", title=title, desc=desc, rows=rows, n=n, crumbs=[("Accueil", "/"), ("Catégories", "/categories"), (label, path)], others_html=others_html) @router.get("/robots.txt", include_in_schema=False) def robots() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\nAllow: /\n" "Disallow: /api/\n" f"Sitemap: {BASE_URL}/sitemap.xml\n") def _urlset(rows: list[tuple[str, str | None]]) -> Response: items = "".join( f"{_e(u)}" + (f"{m}" if m else "") + "" for u, m in rows) return Response( '' '' + items + "", media_type="application/xml") @router.get("/sitemap.xml", include_in_schema=False) def sitemap_index() -> Response: con = db.connect() n = con.execute("SELECT COUNT(*) FROM events WHERE active=1 " "AND quarantine IS NULL").fetchone()[0] con.close() chunks = max(1, -(-n // SITEMAP_CHUNK)) items = "".join( f"{BASE_URL}/sitemap-evenements-{i}.xml" for i in range(chunks)) return Response( '' '' f"{BASE_URL}/sitemap-pages.xml" + items + "", media_type="application/xml") @router.get("/sitemap-pages.xml", include_in_schema=False) def sitemap_pages() -> Response: today = _today() urls = [(f"{BASE_URL}/", None), (f"{BASE_URL}/regions", None), (f"{BASE_URL}/categories", None), (f"{BASE_URL}/stats", None), (f"{BASE_URL}/contact", None)] urls += [(f"{BASE_URL}/region/{r['slug']}", today) for r in _regions_rows()] urls += [(f"{BASE_URL}/categorie/{c['cat']}", today) for c in _cats_rows()] return _urlset(urls) @router.get("/sitemap-evenements-{num}.xml", include_in_schema=False) def sitemap_events(num: int) -> Response: from datetime import datetime, timezone con = db.connect() rows = con.execute( "SELECT uid, updated_at FROM events WHERE active=1 " "AND quarantine IS NULL ORDER BY uid LIMIT ? OFFSET ?", (SITEMAP_CHUNK, num * SITEMAP_CHUNK)).fetchall() con.close() def iso(ts): try: return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") except Exception: return None return _urlset([(f"{BASE_URL}/evenement/{r['uid']}", iso(r["updated_at"])) for r in rows])