SPB Git

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.

13.5 KB · 323 lines python
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}484950def _tz_offset(day: str) -> str:51    """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00")."""52    from datetime import datetime53    from zoneinfo import ZoneInfo54    try:55        dt = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("America/Montreal"))56        off = dt.utcoffset()57        sign = "-" if off.total_seconds() < 0 else "+"58        secs = abs(int(off.total_seconds()))59        return f"{sign}{secs // 3600:02d}:{(secs % 3600) // 60:02d}"60    except Exception:61        return "-05:00"626364def _e(s) -> str:65    return H.escape(str(s or ""), quote=True)666768def _render(head: str, ssr_body: str) -> HTMLResponse:69    """index.html avec <head> réécrit et contenu SSR injecté dans #ssr."""70    import re71    tpl = FRONTEND_INDEX.read_text(encoding="utf-8")72    # retirer title/description/canonical par défaut (remplacés par le SSR)73    tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, count=1, flags=re.S)74    tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl, count=1)75    tpl = re.sub(r'<meta (?:property="og:image[^"]*"|name="twitter:[^"]*")[^>]*>\s*',76                 "", tpl)77    tpl = tpl.replace("<!--SSR_HEAD-->", head, 1)78    tpl = tpl.replace("<!--SSR_BODY-->", ssr_body, 1)79    return HTMLResponse(tpl)808182def _event_or_404(uid: str) -> dict:83    con = db.connect()84    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()85    con.close()86    if row is None:87        raise HTTPException(404, "événement introuvable")88    d = dict(row)89    d["categories"] = json.loads(d.get("categories") or "[]")90    return d919293# statut interne → eventStatus schema.org (soldout n'existe pas comme94# eventStatus : il se déclare via offers.availability SoldOut)95_LD_STATUS = {"cancelled": "EventCancelled", "postponed": "EventPostponed"}969798def _jsonld(ev: dict) -> dict:99    ld: dict = {100        "@context": "https://schema.org",101        "@type": "Event",102        "name": ev["title"],103        "eventStatus": "https://schema.org/"104                       + _LD_STATUS.get(ev.get("status") or "", "EventScheduled"),105        "url": f"{BASE_URL}/evenement/{ev['uid']}",106    }107    if ev.get("start_date"):108        ld["startDate"] = ev["start_date"]109        if ev.get("start_time"):  # heure précise connue → ISO 8601 complet110            ld["startDate"] += f"T{ev['start_time']}:00{_tz_offset(ev['start_date'])}"111    if ev.get("end_date"):112        ld["endDate"] = ev["end_date"]113        if ev.get("end_time"):    # heure de fin connue → ISO 8601 complet114            ld["endDate"] += f"T{ev['end_time']}:00{_tz_offset(ev['end_date'])}"115    artists = json.loads(ev.get("artists") or "[]") \116        if isinstance(ev.get("artists"), str) else (ev.get("artists") or [])117    if artists:118        ld["performer"] = [{"@type": "PerformingGroup", "name": a}119                           for a in artists]120    if ev.get("description"):121        ld["description"] = ev["description"][:400]122    if ev.get("image"):123        ld["image"] = [ev["image"]]124    loc: dict = {"@type": "Place", "name": ev.get("venue") or ev.get("city") or "Québec"}125    addr = {"@type": "PostalAddress", "addressRegion": "QC", "addressCountry": "CA"}126    if ev.get("address"):127        addr["streetAddress"] = ev["address"]128    if ev.get("city"):129        addr["addressLocality"] = ev["city"]130    if ev.get("postal_code"):131        addr["postalCode"] = ev["postal_code"]132    loc["address"] = addr133    if ev.get("lat") and ev.get("lng"):134        loc["geo"] = {"@type": "GeoCoordinates",135                      "latitude": ev["lat"], "longitude": ev["lng"]}136    ld["location"] = loc137    if ev.get("is_free"):138        ld["isAccessibleForFree"] = True139    if ev.get("price_min") is not None:140        ld["offers"] = {"@type": "Offer", "price": f"{ev['price_min']:.2f}",141                        "priceCurrency": "CAD", "url": ev.get("url") or ""}142        if (ev.get("status") or "") == "soldout":143            ld["offers"]["availability"] = "https://schema.org/SoldOut"144    if ev.get("organizer"):145        ld["organizer"] = {"@type": "Organization", "name": ev["organizer"]}146    return ld147148149_STATUS_LABELS = {"cancelled": "Annulé", "postponed": "Reporté",150                  "soldout": "Complet"}151152153@router.get("/evenement/{uid}", include_in_schema=False)154def event_page(uid: str) -> HTMLResponse:155    ev = _event_or_404(uid)156    where = " · ".join(p for p in (ev.get("venue"), ev.get("city"),157                                   ev.get("region")) if p)158    when = ev.get("start_date") or ""159    if ev.get("end_date") and ev["end_date"] != ev.get("start_date"):160        when += f" au {ev['end_date']}"161    elif ev.get("start_time"):162        when += f" à {ev['start_time'].replace(':', ' h ')}"163        if ev.get("end_time"):164            when += f" – {ev['end_time'].replace(':', ' h ')}"165    status_label = _STATUS_LABELS.get(ev.get("status") or "", "")166    title = f"{ev['title']} — {ev.get('city') or 'Québec'} | {SITE_NAME}"167    desc = (ev.get("description") or168            f"{ev['title']}, {where}. Dates, lieu et lien vers la billetterie "169            f"officielle sur {SITE_NAME}.")[:300]170    canonical = f"{BASE_URL}/evenement/{_e(uid)}"171    head = (172        f"<title>{_e(title)}</title>\n"173        f'<meta name="description" content="{_e(desc)}">\n'174        f'<link rel="canonical" href="{canonical}">\n'175        f'<meta property="og:title" content="{_e(title)}">\n'176        f'<meta property="og:description" content="{_e(desc)}">\n'177        f'<meta property="og:type" content="event">\n'178        f'<meta property="og:url" content="{canonical}">\n'179        + (f'<meta property="og:image" content="{_e(ev["image"])}">\n'180           '<meta name="twitter:card" content="summary_large_image">\n'181           f'<meta name="twitter:image" content="{_e(ev["image"])}">\n'182           if ev.get("image") else OG_IMAGE_TAGS + "\n")183        + '<script type="application/ld+json">'184        + json.dumps(_jsonld(ev), ensure_ascii=False)185        + "</script>"186    )187    src = _SRC_LABELS.get(ev["source"], ev["source"])188    ssr = (189        f"<article><h1>{_e(ev['title'])}</h1>"190        + (f"<p><strong>{_e(status_label)}</strong></p>" if status_label else "")191        + f"<p>{_e(when)} — {_e(where)}</p>"192        + (f"<p>{_e(ev['description'][:500])}</p>" if ev.get("description") else "")193        + f'<p>Source : {_e(src)} — <a href="{_e(ev["url"])}" rel="noopener">'194          f"fiche originale et billets</a></p>"195        f'<p><a href="/">Toutes les sorties et tous les événements du Québec</a></p>'196        f"</article>"197    )198    return _render(head, ssr)199200201@router.get("/contact", include_in_schema=False)202def contact_page() -> HTMLResponse:203    """Page /contact (SPA) — head SEO + contenu SSR pour les robots.204    Contenu aligné sur ka-ui/ecosystem.json (contacts Groupe KA)."""205    title = f"Contact — {SITE_NAME} | Un service Groupe KA"206    desc = ("Écrire au Groupe KA : contact@groupe-ka.com (projets, partenariats "207            "et données), info@groupe-ka.com (médias et questions générales), "208            "admin@groupe-ka.com (légal, vie privée et Loi 25).")209    canonical = f"{BASE_URL}/contact"210    head = (211        f"<title>{_e(title)}</title>\n"212        f'<meta name="description" content="{_e(desc)}">\n'213        f'<link rel="canonical" href="{canonical}">\n'214        f'<meta property="og:title" content="{_e(title)}">\n'215        f'<meta property="og:description" content="{_e(desc)}">\n'216        f'<meta property="og:type" content="website">\n'217        f'<meta property="og:url" content="{canonical}">\n'218        + OG_IMAGE_TAGS219    )220    ssr = (221        "<article><h1>Contact — Groupe KA</h1>"222        "<p>Sorti-Ka est un service du Groupe KA.</p>"223        '<p><a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> — '224        "projets, partenariats et données.</p>"225        '<p><a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a> — '226        "médias et questions générales.</p>"227        '<p><a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a> — '228        "légal, vie privée et Loi 25.</p>"229        '<p><a href="https://www.groupe-ka.com" rel="noopener">Groupe KA — '230        "le portail de l'écosystème</a></p></article>"231    )232    return _render(head, ssr)233234235@router.get("/stats", include_in_schema=False)236def stats_page() -> HTMLResponse:237    """Page /stats (SPA) — head SEO + contenu SSR pour les robots."""238    con = db.connect()239    n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]240    con.close()241    title = f"Statistiques — {SITE_NAME} | Un service Groupe KA"242    desc = (f"Tableau de bord analytique de {SITE_NAME} : {n:,} événements actifs"243            .replace(",", " ") +244            " — ajouts par jour, catégories, gratuits vs payants, régions, "245            "calendrier des jours les plus chargés et rapport PDF Groupe KA.")246    canonical = f"{BASE_URL}/stats"247    head = (248        f"<title>{_e(title)}</title>\n"249        f'<meta name="description" content="{_e(desc)}">\n'250        f'<link rel="canonical" href="{canonical}">\n'251        f'<meta property="og:title" content="{_e(title)}">\n'252        f'<meta property="og:description" content="{_e(desc)}">\n'253        f'<meta property="og:type" content="website">\n'254        f'<meta property="og:url" content="{canonical}">\n'255        + OG_IMAGE_TAGS256    )257    ssr = (258        "<article><h1>Statistiques Sorti-Ka</h1>"259        f"<p>{_e(desc)}</p>"260        '<p><a href="/">Toutes les sorties et tous les événements du Québec</a></p>'261        "</article>"262    )263    return _render(head, ssr)264265266@router.get("/robots.txt", include_in_schema=False)267def robots() -> PlainTextResponse:268    return PlainTextResponse(269        "User-agent: *\nAllow: /\n"270        "Disallow: /api/\n"271        f"Sitemap: {BASE_URL}/sitemap.xml\n")272273274def _urlset(rows: list[tuple[str, str | None]]) -> Response:275    items = "".join(276        f"<url><loc>{_e(u)}</loc>" + (f"<lastmod>{m}</lastmod>" if m else "")277        + "</url>" for u, m in rows)278    return Response(279        '<?xml version="1.0" encoding="UTF-8"?>'280        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'281        + items + "</urlset>", media_type="application/xml")282283284@router.get("/sitemap.xml", include_in_schema=False)285def sitemap_index() -> Response:286    con = db.connect()287    n = con.execute("SELECT COUNT(*) FROM events WHERE active=1 "288                    "AND quarantine IS NULL").fetchone()[0]289    con.close()290    chunks = max(1, -(-n // SITEMAP_CHUNK))291    items = "".join(292        f"<sitemap><loc>{BASE_URL}/sitemap-evenements-{i}.xml</loc></sitemap>"293        for i in range(chunks))294    return Response(295        '<?xml version="1.0" encoding="UTF-8"?>'296        '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'297        f"<sitemap><loc>{BASE_URL}/sitemap-pages.xml</loc></sitemap>"298        + items + "</sitemapindex>", media_type="application/xml")299300301@router.get("/sitemap-pages.xml", include_in_schema=False)302def sitemap_pages() -> Response:303    return _urlset([(f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None),304                    (f"{BASE_URL}/contact", None)])305306307@router.get("/sitemap-evenements-{num}.xml", include_in_schema=False)308def sitemap_events(num: int) -> Response:309    from datetime import datetime, timezone310    con = db.connect()311    rows = con.execute(312        "SELECT uid, updated_at FROM events WHERE active=1 "313        "AND quarantine IS NULL ORDER BY uid LIMIT ? OFFSET ?",314        (SITEMAP_CHUNK, num * SITEMAP_CHUNK)).fetchall()315    con.close()316    def iso(ts):317        try:318            return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")319        except Exception:320            return None321    return _urlset([(f"{BASE_URL}/evenement/{r['uid']}", iso(r["updated_at"]))322                    for r in rows])323
Files · 653ebdd5bc0a69f74fb9bc5ada7cebf58c7a0465
Menu

Theme