# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/granby.py : connecteur Ville de Granby — calendrier des événements # Source : https://www.granby.ca/fr/calendrier-evenements (Drupal). # Extraction: la Ville ne publie PAS de calendrier structuré (jsonapi # désactivé, page « actualités/événements » vide côté serveur, # vérifié 2026-08-25) — la page calendrier est une liste # ÉDITORIALE rendue serveur : blocs riches
Titre #
suivi d'un

de dates FR (« Les 22 et 23 mai # 2026 », « Du 28 au 30 mai 2026 ») et d'un lien « Découvrez # l'événement » (souvent externe, vers l'organisateur). # Parsing tolérant : un bloc sans date parseable est ignoré. # Volume faible (~15-25 événements saisonniers), 1 requête. # Accès : site municipal public, GET direct throttlé, identification # honnête ; robots.txt permissif. # Prix : non publié par la source → jamais inventé. Lieu/heure non # publiés dans la liste → laissés vides. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re from ..normalize import _FR_MONTHS, clean_text, strip_accents # type: ignore from ..schema import Event from .base import BaseConnector LIST_URL = "https://www.granby.ca/fr/calendrier-evenements" _BLOCK_RE = re.compile( r"

(.*?)
(.*?)(?=
||$)", re.S) _LINK_RE = re.compile(r']*>\s*Découvrez|' r']*>\s*Découvrez') # « les 22 et 23 mai 2026 », « du 28 au 30 mai 2026 », « le 24 juin 2026 », # « les 4, 5 et 6 juin 2026 » → jours multiples, mois+année en fin _DAYS_RE = re.compile( r"((?:\d{1,2}(?:er)?[,\s]*(?:et\s+|au\s+)?)+)\s*([a-z]+)\s+(\d{4})") def _range(txt: str) -> tuple[str | None, str | None]: """Dates éditoriales FR (jours multiples, un mois+année) → (début, fin).""" k = strip_accents(clean_text(txt)).lower() dates: list[str] = [] for m in _DAYS_RE.finditer(k): mo, y = _FR_MONTHS.get(m.group(2)), int(m.group(3)) if not mo or not (1990 <= y <= 2100): continue for dm in re.finditer(r"\d{1,2}", m.group(1)): d = int(dm.group(0)) if 1 <= d <= 31: dates.append(f"{y:04d}-{mo:02d}-{d:02d}") if not dates: return None, None return min(dates), max(dates) class GranbyConnector(BaseConnector): source_id = "granby" def _parse_page(self, html: str) -> list[dict]: rows: list[dict] = [] for title_html, body in _BLOCK_RE.findall(html): title = clean_text(_html.unescape( re.sub(r"<[^>]+>", " ", title_html))) if not title: continue # 1er

avec une date parseable = la plage de l'événement start = end = None for p in re.findall(r"]*>(.*?)

", body, re.S): start, end = _range(_html.unescape(re.sub(r"<[^>]+>", " ", p))) if start: break if not start: continue link = _LINK_RE.search(body) website = (link.group(1) or link.group(2)).strip() if link else "" rows.append({"title": title, "start": start, "end": end, "website": website}) return rows def fetch(self) -> list[Event]: rows = self._parse_page(self.get(LIST_URL).text) events: list[Event] = [] seen: set[str] = set() for r in rows: slug = re.sub(r"[^a-z0-9]+", "-", strip_accents(r["title"]).lower()).strip("-") ext = f"{slug}-{r['start']}" if ext in seen: continue seen.add(ext) events.append(Event( source=self.source_id, external_id=ext, url=r["website"] or LIST_URL, title=r["title"], raw_categories=[r["title"]], city="Granby", region="Estrie", start_date=r["start"], end_date=r["end"] or r["start"], organizer="Ville de Granby", website=r["website"], )) return events