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%

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 1 mo ago (Aug 23, 2026) parent a4b8dc4

3 changed files +244 −4

modified frontend/index.html +22 −0
@@ -1380,11 +1380,32 @@ function showContact(){
1380 1380 }
1381 1381
1382 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 1389 function route(){
1384 1390 const m = location.pathname.match(/^\/evenement\/(.+)$/);
1391 + const r = location.pathname.match(/^\/region\/([^/]+)$/);
1392 + const c = location.pathname.match(/^\/categorie\/([^/]+)$/);
1385 1393 if(m) showDetail(decodeURIComponent(m[1]));
1386 1394 else if(location.pathname === "/contact") showContact();
1387 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 1409 else showList();
1389 1410 }
1390 1411 function go(path){
@@ -1403,6 +1424,7 @@ $("back").onclick = () => go("/");
1403 1424 /* ---------- init ---------- */
1404 1425 async function init(){
1405 1426 const regions = await (await fetch("/api/regions")).json();
1427 + allRegions = regions;
1406 1428 $("region").insertAdjacentHTML("beforeend",
1407 1429 regions.map(r=>`<option>${esc(r)}</option>`).join(""));
1408 1430
modified frontend/ka-agent.js +2 −0
@@ -454,3 +454,5 @@
454 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 324 "<h2>Prochains événements</h2><ul>"
325 325 + "".join(_ev_li(r) for r in nexts) + "</ul>"
326 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 329 for r in regions) + "</ul>"
330 + '<p><a href="/regions">Toutes les régions</a></p>'
329 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 333 for c, v in sorted(cats.items(), key=lambda kv: -kv[1])
332 334 if c in _CAT_LABELS) + "</p>"
335 + '<p><a href="/categories">Toutes les catégories</a></p>'
333 336 '<p><a href="/stats">Statistiques Sorti-Ka</a> · '
334 337 '<a href="/contact">Contact</a></p></article>'
335 338 )
@@ -408,6 +411,214 @@ def stats_page() -> HTMLResponse:
408 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 622 @router.get("/robots.txt", include_in_schema=False)
412 623 def robots() -> PlainTextResponse:
413 624 return PlainTextResponse(
@@ -445,8 +656,13 @@ def sitemap_index() -> Response:
445 656
446 657 @router.get("/sitemap-pages.xml", include_in_schema=False)
447 658 def sitemap_pages() -> Response:
448 − return _urlset([(f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None),
449 − (f"{BASE_URL}/contact", None)])
659 + today = _today()
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 668 @router.get("/sitemap-evenements-{num}.xml", include_in_schema=False)
453 669