# ----------------------------------------------------------------------------- # 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