# -----------------------------------------------------------------------------
# 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"
'
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'
{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.
'
)
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 = (
"
"
)
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 = ("
")
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 = (
"
'
""
)
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'
')
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
')
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 = ("
") 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"
')
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 = ("