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.

SEO : pages programmatiques régions + catégories rendues en HTML

- sortika/seo.py : /regions, /region/{slug}, /categories, /categorie/{cat}
  (SSR complet : ItemList + BreadcrumbList, 100 fiches liées par page),
  liens régions/catégories dans l'accueil SSR, sitemap-pages enrichi
- frontend/index.html : routeur SPA étendu — /region/{slug} et
  /categorie/{cat} ouvrent la recherche filtrée correspondante (kaSlug
  miroir du slugify serveur)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent a4b8dc4 Browse files

3 changed files +244 −4

modified frontend/index.html +22 −0
@@ -1380,11 +1380,32 @@ function showContact(){
1380 } 1380 }
1381 1381
1382 /* ---------- routeur ---------- */ 1382 /* ---------- routeur ---------- */
1383+/* slug ASCII identique à sortika/seo.py (pages /region/… et /categorie/…) */
1384+function kaSlug(s){
1385+ return s.normalize("NFKD").replace(/[̀-ͯ]/g,"")
1386+ .toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"");
1387+}
1388+let allRegions = [];
1383 function route(){ 1389 function route(){
1384 const m = location.pathname.match(/^\/evenement\/(.+)$/); 1390 const m = location.pathname.match(/^\/evenement\/(.+)$/);
1391+ const r = location.pathname.match(/^\/region\/([^/]+)$/);
1392+ const c = location.pathname.match(/^\/categorie\/([^/]+)$/);
1385 if(m) showDetail(decodeURIComponent(m[1])); 1393 if(m) showDetail(decodeURIComponent(m[1]));
1386 else if(location.pathname === "/contact") showContact(); 1394 else if(location.pathname === "/contact") showContact();
1387 else if(location.pathname === "/stats") showStats(); 1395 else if(location.pathname === "/stats") showStats();
1396+ else if(r){
1397+ // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région
1398+ const name = allRegions.find(x => kaSlug(x) === decodeURIComponent(r[1]));
1399+ if(name){ state.region = name; $("region").value = name; }
1400+ showList(); load();
1401+ }
1402+ else if(c){
1403+ // page SSR /categorie/{cat} : recherche filtrée sur la catégorie
1404+ const cat = decodeURIComponent(c[1]);
1405+ state.category = cat;
1406+ document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===cat));
1407+ showList(); load();
1408+ }
1388 else showList(); 1409 else showList();
1389 } 1410 }
1390 function go(path){ 1411 function go(path){
@@ -1403,6 +1424,7 @@ $("back").onclick = () => go("/");
1403 /* ---------- init ---------- */ 1424 /* ---------- init ---------- */
1404 async function init(){ 1425 async function init(){
1405 const regions = await (await fetch("/api/regions")).json(); 1426 const regions = await (await fetch("/api/regions")).json();
1427+ allRegions = regions;
1406 $("region").insertAdjacentHTML("beforeend", 1428 $("region").insertAdjacentHTML("beforeend",
1407 regions.map(r=>`<option>${esc(r)}</option>`).join("")); 1429 regions.map(r=>`<option>${esc(r)}</option>`).join(""));
1408 1430
modified frontend/ka-agent.js +2 −0
@@ -454,3 +454,5 @@
454 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); } 454 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
455 }); 455 });
456 })(); 456 })();
457+
458+;/*KA_ANALYTICS*/(function(){try{if(window.__kaAn)return;window.__kaAn=1;var k="KA_VID",v=localStorage.getItem(k);if(!v){v=Date.now().toString(36)+Math.random().toString(36).slice(2,8);localStorage.setItem(k,v);}var b=function(){var i=new Image();i.src="https://www.administration-ka.com/collect?site=sorti-ka&vid="+v+"&p="+encodeURIComponent(location.pathname)+"&t="+Date.now();};b();setInterval(b,60000);}catch(e){}})();
modified sortika/seo.py +220 −4
@@ -324,12 +324,15 @@ def home_page() -> HTMLResponse:
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,6 +411,214 @@ def stats_page() -> HTMLResponse:
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,8 +656,13 @@ def sitemap_index() -> Response:
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
Menu

Theme