Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sainthyacinthe.py : connecteur Ville de Saint-Hyacinthe —5# calendrier des événements (généré par les organismes accrédités).6# Source : https://www.st-hyacinthe.ca/loisirs-et-culture/evenement/7# calendrier-des-evenements (CMS municipal maison, rendu8# serveur Bootstrap).9# Extraction: GET ?ms=2 (« Tous » les mois, découvert dans le formulaire de10# recherche) → toutes les cartes en 1 requête : id + titre11# (fShowInfo), plage de dates FR, image, organisateur,12# catégorie. Enrichissement par fiche modale13# /php/load-modal-info.php?evenement=<id> (lieu, clientèle,14# gratuit/payant, site web) — CACHE INCRÉMENTAL disque : seules15# les fiches jamais vues sont requêtées (plafond STH_FICHES).16# Accès : site municipal public, GET directs throttlés, identification17# honnête ; robots.txt ne restreint pas ces chemins.18# Prix : la fiche publie « Gratuit »/« Payant » → is_free fidèle,19# jamais de montant inventé.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as _html24import os25import re2627from ..normalize import clean_text, parse_date_range_fr28from ..schema import Event29from .base import BaseConnector3031BASE = "https://www.st-hyacinthe.ca"32LIST_URL = BASE + "/loisirs-et-culture/evenement/calendrier-des-evenements"33MODAL_URL = BASE + "/php/load-modal-info.php?evenement={id}"34FICHES_MAX = int(os.environ.get("STH_FICHES", "60"))3536_CARD_RE = re.compile(r'<div class="card h-100">(.*?)</div>\s*</div>\s*</div>',37 re.S)38_INFO_RE = re.compile(r"fShowInfo\('(\d+)','(.*?)'\)")39_DATES_RE = re.compile(r"<strong>(.*?)</strong>", re.S)40_IMG_RE = re.compile(r'background:url\(([^)]+)\)')41_ORG_RE = re.compile(r"Organisateur de l'événement\"></i>\s*([^<]+)</p>")42_CAT_RE = re.compile(r"Type\(s\) de l'événement\"></i>\s*([^<]+)</p>")43# fiche modale44_M_VENUE_RE = re.compile(r"Lieu de l'événement\"></i>\s*([^<]+)</p>")45_M_AUD_RE = re.compile(r"Clientèle\(s\) de l'événement\"></i>\s*([^<]+)</p>")46_M_PRICE_RE = re.compile(47 r'data-content="Cette activité (?:est gratuite|n\'est pas gratuite)"></i>\s*([^<]+)</p>')48_M_SITE_RE = re.compile(r'<a href="([^"]+)"[^>]*>Consulter le site internet')49_M_HOURS_RE = re.compile(r'fa-clock[^>]*"></i>\s*([^<]+)</p>')505152class SaintHyacintheConnector(BaseConnector):53 source_id = "sainthyacinthe"54 request_delay = 0.85556 def _parse_list(self, html: str) -> list[dict]:57 rows: list[dict] = []58 for block in _CARD_RE.findall(html):59 m = _INFO_RE.search(block)60 d = _DATES_RE.search(block)61 if not m or not d:62 continue63 start, end = parse_date_range_fr(clean_text(d.group(1)))64 if not start and not end:65 continue66 img = _IMG_RE.search(block)67 org = _ORG_RE.search(block)68 cat = _CAT_RE.search(block)69 rows.append({70 "id": m.group(1),71 "title": clean_text(_html.unescape(m.group(2))),72 "start": start or end,73 "end": end or start,74 "image": (BASE + img.group(1)) if img75 and img.group(1).startswith("/") else76 (img.group(1) if img else ""),77 "organizer": clean_text(org.group(1)) if org else "",78 "category": clean_text(cat.group(1)) if cat else "",79 })80 return rows8182 def _parse_modal(self, html: str) -> dict:83 out: dict = {}84 m = _M_VENUE_RE.search(html)85 if m:86 out["venue"] = clean_text(m.group(1))87 m = _M_AUD_RE.search(html)88 if m:89 out["audience"] = clean_text(m.group(1))90 m = _M_PRICE_RE.search(html)91 if m:92 out["price_label"] = clean_text(m.group(1)) # Gratuit | Payant93 m = _M_SITE_RE.search(html)94 if m:95 out["website"] = m.group(1).strip()96 m = _M_HOURS_RE.search(html)97 if m:98 out["hours"] = clean_text(m.group(1))99 return out100101 def fetch(self) -> list[Event]:102 rows = self._parse_list(self.get(LIST_URL, params={"ms": "2"}).text)103 cache = self.load_cache()104 fetched = 0105 for r in rows:106 if r["id"] not in cache and fetched < FICHES_MAX:107 try:108 cache[r["id"]] = self._parse_modal(109 self.get(MODAL_URL.format(id=r["id"])).text)110 fetched += 1111 except Exception as exc: # fiche cassée ≠ source cassée112 print(f"[sorti-ka] sainthyacinthe : fiche {r['id']} "113 f"ignorée : {exc}")114 if fetched:115 self.save_cache(cache)116 events: list[Event] = []117 seen: set[str] = set()118 for r in rows:119 ext = f"{r['id']}-{r['start']}"120 if ext in seen:121 continue122 seen.add(ext)123 extra = cache.get(r["id"]) or {}124 label = extra.get("price_label", "")125 events.append(Event(126 source=self.source_id,127 external_id=ext,128 url=LIST_URL + "?ms=2", # pas de fiche pérenne (modale)129 title=r["title"],130 raw_categories=[r["category"]] if r["category"] else [r["title"]],131 audience=extra.get("audience", ""),132 venue=extra.get("venue", ""),133 city="Saint-Hyacinthe",134 region="Montérégie",135 start_date=r["start"],136 end_date=r["end"],137 is_free=(True if label == "Gratuit"138 else False if label == "Payant" else None),139 price_label=label,140 organizer=r["organizer"],141 website=extra.get("website", ""),142 image=r["image"],143 ))144 return events145