# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/sainthyacinthe.py : connecteur Ville de Saint-Hyacinthe —
# calendrier des événements (généré par les organismes accrédités).
# Source : https://www.st-hyacinthe.ca/loisirs-et-culture/evenement/
# calendrier-des-evenements (CMS municipal maison, rendu
# serveur Bootstrap).
# Extraction: GET ?ms=2 (« Tous » les mois, découvert dans le formulaire de
# recherche) → toutes les cartes en 1 requête : id + titre
# (fShowInfo), plage de dates FR, image, organisateur,
# catégorie. Enrichissement par fiche modale
# /php/load-modal-info.php?evenement= (lieu, clientèle,
# gratuit/payant, site web) — CACHE INCRÉMENTAL disque : seules
# les fiches jamais vues sont requêtées (plafond STH_FICHES).
# Accès : site municipal public, GET directs throttlés, identification
# honnête ; robots.txt ne restreint pas ces chemins.
# Prix : la fiche publie « Gratuit »/« Payant » → is_free fidèle,
# jamais de montant inventé.
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import os
import re
from ..normalize import clean_text, parse_date_range_fr
from ..schema import Event
from .base import BaseConnector
BASE = "https://www.st-hyacinthe.ca"
LIST_URL = BASE + "/loisirs-et-culture/evenement/calendrier-des-evenements"
MODAL_URL = BASE + "/php/load-modal-info.php?evenement={id}"
FICHES_MAX = int(os.environ.get("STH_FICHES", "60"))
_CARD_RE = re.compile(r'(.*?)
\s*\s*',
re.S)
_INFO_RE = re.compile(r"fShowInfo\('(\d+)','(.*?)'\)")
_DATES_RE = re.compile(r"(.*?)", re.S)
_IMG_RE = re.compile(r'background:url\(([^)]+)\)')
_ORG_RE = re.compile(r"Organisateur de l'événement\">\s*([^<]+)
")
_CAT_RE = re.compile(r"Type\(s\) de l'événement\">\s*([^<]+)")
# fiche modale
_M_VENUE_RE = re.compile(r"Lieu de l'événement\">\s*([^<]+)")
_M_AUD_RE = re.compile(r"Clientèle\(s\) de l'événement\">\s*([^<]+)")
_M_PRICE_RE = re.compile(
r'data-content="Cette activité (?:est gratuite|n\'est pas gratuite)">\s*([^<]+)')
_M_SITE_RE = re.compile(r']*>Consulter le site internet')
_M_HOURS_RE = re.compile(r'fa-clock[^>]*">\s*([^<]+)')
class SaintHyacintheConnector(BaseConnector):
source_id = "sainthyacinthe"
request_delay = 0.8
def _parse_list(self, html: str) -> list[dict]:
rows: list[dict] = []
for block in _CARD_RE.findall(html):
m = _INFO_RE.search(block)
d = _DATES_RE.search(block)
if not m or not d:
continue
start, end = parse_date_range_fr(clean_text(d.group(1)))
if not start and not end:
continue
img = _IMG_RE.search(block)
org = _ORG_RE.search(block)
cat = _CAT_RE.search(block)
rows.append({
"id": m.group(1),
"title": clean_text(_html.unescape(m.group(2))),
"start": start or end,
"end": end or start,
"image": (BASE + img.group(1)) if img
and img.group(1).startswith("/") else
(img.group(1) if img else ""),
"organizer": clean_text(org.group(1)) if org else "",
"category": clean_text(cat.group(1)) if cat else "",
})
return rows
def _parse_modal(self, html: str) -> dict:
out: dict = {}
m = _M_VENUE_RE.search(html)
if m:
out["venue"] = clean_text(m.group(1))
m = _M_AUD_RE.search(html)
if m:
out["audience"] = clean_text(m.group(1))
m = _M_PRICE_RE.search(html)
if m:
out["price_label"] = clean_text(m.group(1)) # Gratuit | Payant
m = _M_SITE_RE.search(html)
if m:
out["website"] = m.group(1).strip()
m = _M_HOURS_RE.search(html)
if m:
out["hours"] = clean_text(m.group(1))
return out
def fetch(self) -> list[Event]:
rows = self._parse_list(self.get(LIST_URL, params={"ms": "2"}).text)
cache = self.load_cache()
fetched = 0
for r in rows:
if r["id"] not in cache and fetched < FICHES_MAX:
try:
cache[r["id"]] = self._parse_modal(
self.get(MODAL_URL.format(id=r["id"])).text)
fetched += 1
except Exception as exc: # fiche cassée ≠ source cassée
print(f"[sorti-ka] sainthyacinthe : fiche {r['id']} "
f"ignorée : {exc}")
if fetched:
self.save_cache(cache)
events: list[Event] = []
seen: set[str] = set()
for r in rows:
ext = f"{r['id']}-{r['start']}"
if ext in seen:
continue
seen.add(ext)
extra = cache.get(r["id"]) or {}
label = extra.get("price_label", "")
events.append(Event(
source=self.source_id,
external_id=ext,
url=LIST_URL + "?ms=2", # pas de fiche pérenne (modale)
title=r["title"],
raw_categories=[r["category"]] if r["category"] else [r["title"]],
audience=extra.get("audience", ""),
venue=extra.get("venue", ""),
city="Saint-Hyacinthe",
region="Montérégie",
start_date=r["start"],
end_date=r["end"],
is_free=(True if label == "Gratuit"
else False if label == "Payant" else None),
price_label=label,
organizer=r["organizer"],
website=extra.get("website", ""),
image=r["image"],
))
return events