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/granby.py : connecteur Ville de Granby — calendrier des événements5# Source : https://www.granby.ca/fr/calendrier-evenements (Drupal).6# Extraction: la Ville ne publie PAS de calendrier structuré (jsonapi7# désactivé, page « actualités/événements » vide côté serveur,8# vérifié 2026-08-25) — la page calendrier est une liste9# ÉDITORIALE rendue serveur : blocs riches <h5><strong>Titre10# </strong></h5> suivi d'un <p> de dates FR (« Les 22 et 23 mai11# 2026 », « Du 28 au 30 mai 2026 ») et d'un lien « Découvrez12# l'événement » (souvent externe, vers l'organisateur).13# Parsing tolérant : un bloc sans date parseable est ignoré.14# Volume faible (~15-25 événements saisonniers), 1 requête.15# Accès : site municipal public, GET direct throttlé, identification16# honnête ; robots.txt permissif.17# Prix : non publié par la source → jamais inventé. Lieu/heure non18# publiés dans la liste → laissés vides.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import html as _html23import re2425from ..normalize import _FR_MONTHS, clean_text, strip_accents # type: ignore26from ..schema import Event27from .base import BaseConnector2829LIST_URL = "https://www.granby.ca/fr/calendrier-evenements"3031_BLOCK_RE = re.compile(32 r"<h5><strong>(.*?)</strong></h5>(.*?)(?=<h5><strong>|</section>|$)", re.S)33_LINK_RE = re.compile(r'<a href="([^"]+)"[^>]*>\s*Découvrez|'34 r'<a href="([^"]+)"[^>]*>\s*Découvrez')35# « les 22 et 23 mai 2026 », « du 28 au 30 mai 2026 », « le 24 juin 2026 »,36# « les 4, 5 et 6 juin 2026 » → jours multiples, mois+année en fin37_DAYS_RE = re.compile(38 r"((?:\d{1,2}(?:er)?[,\s]*(?:et\s+|au\s+)?)+)\s*([a-z]+)\s+(\d{4})")394041def _range(txt: str) -> tuple[str | None, str | None]:42 """Dates éditoriales FR (jours multiples, un mois+année) → (début, fin)."""43 k = strip_accents(clean_text(txt)).lower()44 dates: list[str] = []45 for m in _DAYS_RE.finditer(k):46 mo, y = _FR_MONTHS.get(m.group(2)), int(m.group(3))47 if not mo or not (1990 <= y <= 2100):48 continue49 for dm in re.finditer(r"\d{1,2}", m.group(1)):50 d = int(dm.group(0))51 if 1 <= d <= 31:52 dates.append(f"{y:04d}-{mo:02d}-{d:02d}")53 if not dates:54 return None, None55 return min(dates), max(dates)565758class GranbyConnector(BaseConnector):59 source_id = "granby"6061 def _parse_page(self, html: str) -> list[dict]:62 rows: list[dict] = []63 for title_html, body in _BLOCK_RE.findall(html):64 title = clean_text(_html.unescape(65 re.sub(r"<[^>]+>", " ", title_html)))66 if not title:67 continue68 # 1er <p> avec une date parseable = la plage de l'événement69 start = end = None70 for p in re.findall(r"<p[^>]*>(.*?)</p>", body, re.S):71 start, end = _range(_html.unescape(re.sub(r"<[^>]+>", " ", p)))72 if start:73 break74 if not start:75 continue76 link = _LINK_RE.search(body)77 website = (link.group(1) or link.group(2)).strip() if link else ""78 rows.append({"title": title, "start": start, "end": end,79 "website": website})80 return rows8182 def fetch(self) -> list[Event]:83 rows = self._parse_page(self.get(LIST_URL).text)84 events: list[Event] = []85 seen: set[str] = set()86 for r in rows:87 slug = re.sub(r"[^a-z0-9]+", "-",88 strip_accents(r["title"]).lower()).strip("-")89 ext = f"{slug}-{r['start']}"90 if ext in seen:91 continue92 seen.add(ext)93 events.append(Event(94 source=self.source_id,95 external_id=ext,96 url=r["website"] or LIST_URL,97 title=r["title"],98 raw_categories=[r["title"]],99 city="Granby",100 region="Estrie",101 start_date=r["start"],102 end_date=r["end"] or r["start"],103 organizer="Ville de Granby",104 website=r["website"],105 ))106 return events107