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/montreal.py : connecteur Ville de Montréal — événements publics5# Source : donnees.montreal.ca, jeu « Événements publics » (montreal.ca)6# Extraction: API CKAN datastore_search (JSON, paginée) — données ouvertes,7# Licence CC BY 4.0. Aucune restriction de scraping.8# Couverture: ~5 600 événements municipaux (culture, conseils, famille,9# sport, marchés) avec dates, coût, arrondissement, lat/lng.10# Images : absentes du jeu de données → enrichissement incrémental par11# og:image des fiches montreal.ca (cache disque, plafonné par12# sync via MTL_IMG_MAX, défaut 600 — doublé 2026-08-25 pour un13# rattrapage plus rapide ; candidats collectés en entier PUIS14# triés — jamais visités d'abord, ensuite par date de début la15# plus proche — avant d'appliquer le plafond).16# https://donnees.montreal.ca/dataset/evenements-publics17# -----------------------------------------------------------------------------18from __future__ import annotations1920import os21import re2223from ..schema import Event24from .base import BaseConnector2526API = "https://donnees.montreal.ca/api/3/action/datastore_search"27RESOURCE_ID = "6decf611-6f11-4f34-bb36-324d804c9bad"28PAGE = 100029IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "600"))3031_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')32# heures locales de la fiche montreal.ca (JSON-LD Event, offset Québec inclus) :33# "startDate":"2026-10-14T19:30:00-04:00" → "19:30" ; idem "endDate" → heure de fin34_LD_TIME_RE = re.compile(r'"startDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):')35_LD_END_TIME_RE = re.compile(r'"endDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):')3637# Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom38# de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières39# valeurs existent). On ne les met jamais dans venue ; si la ville publie un40# jour de vrais noms de lieux dans ce champ, ils seront captés d'office.41_EMPLACEMENT_GENERIQUE = {"en salle", "en ligne", "a l'exterieur",42 "à l'extérieur", "en salle et en ligne", "hybride"}434445def _s(v) -> str:46 """Le datastore renvoie parfois la chaîne 'None' — la traiter comme vide."""47 s = str(v or "").strip()48 return "" if s.lower() in ("none", "null", "nan") else s495051class MontrealConnector(BaseConnector):52 source_id = "montreal"53 request_delay = 0.45455 def _enrich_fiches(self, events: list[Event]) -> None:56 """og:image + heure précise des fiches montreal.ca — incrémental,57 cache disque, pool de threads (6). Le jeu de données ouvert ne publie58 pas l'heure, mais chaque fiche montreal.ca l'expose en JSON-LD59 ("startDate":"…T19:30:00-04:00"). Le cache mémorise aussi les fiches60 sans image/heure pour ne pas les re-visiter ; seuls les événements à61 venir sont visités (pas les archives). Les entrées héritées (chaîne62 image seule, avant la vague 2) sont re-visitées en priorité basse pour63 acquérir l'heure, dans le même plafond MTL_IMG_MAX.64 """65 from datetime import date66 today = date.today().isoformat()67 cache = self.load_cache()68 to_fetch = []69 for ev in events:70 entry = cache.get(ev.url)71 if isinstance(entry, dict) and "end_time" in entry:72 continue # déjà au format Phase 273 last_day = (ev.end_date or ev.start_date or "")[:10]74 if last_day >= today:75 # collecte COMPLÈTE puis tri (avant 2026-08-25 le plafond76 # s'appliquait à la collecte : la priorisation ne jouait pas) :77 # jamais visités d'abord, puis les événements les plus proches78 to_fetch.append((entry is not None,79 ev.start_date or "9999", ev.url))80 to_fetch = [u for *_, u in sorted(to_fetch)][:IMG_MAX]8182 def worker(url, session):83 html = session.get(url, timeout=15).text84 img = _OG_IMG_RE.search(html)85 tm = _LD_TIME_RE.search(html)86 end_tm = _LD_END_TIME_RE.search(html)87 return {"image": img.group(1) if img else "",88 "time": tm.group(1) if tm else None,89 "end_time": end_tm.group(1) if end_tm else None}9091 for url, info in self.fetch_many(to_fetch, worker, max_workers=6).items():92 cache[url] = info or {"image": "", "time": None, "end_time": None}93 self.save_cache(cache)94 for ev in events:95 entry = cache.get(ev.url)96 if isinstance(entry, str): # cache hérité : image seule97 entry = {"image": entry, "time": None}98 if not isinstance(entry, dict):99 continue100 if not ev.image:101 ev.image = entry.get("image") or ""102 if entry.get("time") and entry["time"] != "00:00":103 ev.start_time = entry["time"]104 if entry.get("end_time") and entry["end_time"] != "00:00":105 ev.end_time = entry["end_time"]106107 def fetch(self) -> list[Event]:108 events: list[Event] = []109 offset = 0110 while True:111 data = self.get_json(API, params={112 "resource_id": RESOURCE_ID, "limit": PAGE, "offset": offset})113 result = data.get("result", {})114 records = result.get("records", [])115 if not records:116 break117 for rec in records:118 title = _s(rec.get("titre"))119 url = _s(rec.get("url_fiche"))120 if not title or not url:121 continue122 # id stable : la fiche montreal.ca (le _id CKAN change à chaque123 # republication du jeu de données, l'URL de fiche non).124 ext_id = url.rstrip("/").rsplit("/", 1)[-1]125 cost = _s(rec.get("cout"))126 inscription = _s(rec.get("inscription"))127 # venue : emplacement (si c'est un vrai nom de lieu et non un128 # mode « En salle »/« En ligne ») sinon titre_adresse ;129 # address : adresse principale + complément (adresse_secondaire).130 emplacement = _s(rec.get("emplacement"))131 if emplacement.lower() in _EMPLACEMENT_GENERIQUE:132 emplacement = ""133 address = ", ".join(p for p in (134 _s(rec.get("adresse_principale")),135 _s(rec.get("adresse_secondaire"))) if p)136 events.append(Event(137 source=self.source_id,138 external_id=ext_id,139 url=url,140 title=title,141 description=_s(rec.get("description")),142 raw_categories=[_s(rec.get("type_evenement"))],143 audience=_s(rec.get("public_cible")),144 venue=emplacement or _s(rec.get("titre_adresse")),145 address=address,146 city="Montréal",147 region="Montréal",148 postal_code=_s(rec.get("code_postal")),149 lat=_s(rec.get("lat")) or None,150 lng=_s(rec.get("long")) or None,151 start_date=_s(rec.get("date_debut")) or None,152 end_date=_s(rec.get("date_fin")) or None,153 price_label=" — ".join(p for p in (cost, inscription) if p),154 organizer="Ville de Montréal — "155 + (_s(rec.get("arrondissement")) or "ville-centre"),156 ))157 offset += PAGE158 if offset >= int(result.get("total", 0)):159 break160 self._enrich_fiches(events)161 return events162