# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/montreal.py : connecteur Ville de Montréal — événements publics # Source : donnees.montreal.ca, jeu « Événements publics » (montreal.ca) # Extraction: API CKAN datastore_search (JSON, paginée) — données ouvertes, # Licence CC BY 4.0. Aucune restriction de scraping. # Couverture: ~5 600 événements municipaux (culture, conseils, famille, # sport, marchés) avec dates, coût, arrondissement, lat/lng. # Images : absentes du jeu de données → enrichissement incrémental par # og:image des fiches montreal.ca (cache disque, plafonné par # sync via MTL_IMG_MAX, défaut 600 — doublé 2026-08-25 pour un # rattrapage plus rapide ; candidats collectés en entier PUIS # triés — jamais visités d'abord, ensuite par date de début la # plus proche — avant d'appliquer le plafond). # https://donnees.montreal.ca/dataset/evenements-publics # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from ..schema import Event from .base import BaseConnector API = "https://donnees.montreal.ca/api/3/action/datastore_search" RESOURCE_ID = "6decf611-6f11-4f34-bb36-324d804c9bad" PAGE = 1000 IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "600")) _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') # heures locales de la fiche montreal.ca (JSON-LD Event, offset Québec inclus) : # "startDate":"2026-10-14T19:30:00-04:00" → "19:30" ; idem "endDate" → heure de fin _LD_TIME_RE = re.compile(r'"startDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):') _LD_END_TIME_RE = re.compile(r'"endDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):') # Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom # de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières # valeurs existent). On ne les met jamais dans venue ; si la ville publie un # jour de vrais noms de lieux dans ce champ, ils seront captés d'office. _EMPLACEMENT_GENERIQUE = {"en salle", "en ligne", "a l'exterieur", "à l'extérieur", "en salle et en ligne", "hybride"} def _s(v) -> str: """Le datastore renvoie parfois la chaîne 'None' — la traiter comme vide.""" s = str(v or "").strip() return "" if s.lower() in ("none", "null", "nan") else s class MontrealConnector(BaseConnector): source_id = "montreal" request_delay = 0.4 def _enrich_fiches(self, events: list[Event]) -> None: """og:image + heure précise des fiches montreal.ca — incrémental, cache disque, pool de threads (6). Le jeu de données ouvert ne publie pas l'heure, mais chaque fiche montreal.ca l'expose en JSON-LD ("startDate":"…T19:30:00-04:00"). Le cache mémorise aussi les fiches sans image/heure pour ne pas les re-visiter ; seuls les événements à venir sont visités (pas les archives). Les entrées héritées (chaîne image seule, avant la vague 2) sont re-visitées en priorité basse pour acquérir l'heure, dans le même plafond MTL_IMG_MAX. """ from datetime import date today = date.today().isoformat() cache = self.load_cache() to_fetch = [] for ev in events: entry = cache.get(ev.url) if isinstance(entry, dict) and "end_time" in entry: continue # déjà au format Phase 2 last_day = (ev.end_date or ev.start_date or "")[:10] if last_day >= today: # collecte COMPLÈTE puis tri (avant 2026-08-25 le plafond # s'appliquait à la collecte : la priorisation ne jouait pas) : # jamais visités d'abord, puis les événements les plus proches to_fetch.append((entry is not None, ev.start_date or "9999", ev.url)) to_fetch = [u for *_, u in sorted(to_fetch)][:IMG_MAX] def worker(url, session): html = session.get(url, timeout=15).text img = _OG_IMG_RE.search(html) tm = _LD_TIME_RE.search(html) end_tm = _LD_END_TIME_RE.search(html) return {"image": img.group(1) if img else "", "time": tm.group(1) if tm else None, "end_time": end_tm.group(1) if end_tm else None} for url, info in self.fetch_many(to_fetch, worker, max_workers=6).items(): cache[url] = info or {"image": "", "time": None, "end_time": None} self.save_cache(cache) for ev in events: entry = cache.get(ev.url) if isinstance(entry, str): # cache hérité : image seule entry = {"image": entry, "time": None} if not isinstance(entry, dict): continue if not ev.image: ev.image = entry.get("image") or "" if entry.get("time") and entry["time"] != "00:00": ev.start_time = entry["time"] if entry.get("end_time") and entry["end_time"] != "00:00": ev.end_time = entry["end_time"] def fetch(self) -> list[Event]: events: list[Event] = [] offset = 0 while True: data = self.get_json(API, params={ "resource_id": RESOURCE_ID, "limit": PAGE, "offset": offset}) result = data.get("result", {}) records = result.get("records", []) if not records: break for rec in records: title = _s(rec.get("titre")) url = _s(rec.get("url_fiche")) if not title or not url: continue # id stable : la fiche montreal.ca (le _id CKAN change à chaque # republication du jeu de données, l'URL de fiche non). ext_id = url.rstrip("/").rsplit("/", 1)[-1] cost = _s(rec.get("cout")) inscription = _s(rec.get("inscription")) # venue : emplacement (si c'est un vrai nom de lieu et non un # mode « En salle »/« En ligne ») sinon titre_adresse ; # address : adresse principale + complément (adresse_secondaire). emplacement = _s(rec.get("emplacement")) if emplacement.lower() in _EMPLACEMENT_GENERIQUE: emplacement = "" address = ", ".join(p for p in ( _s(rec.get("adresse_principale")), _s(rec.get("adresse_secondaire"))) if p) events.append(Event( source=self.source_id, external_id=ext_id, url=url, title=title, description=_s(rec.get("description")), raw_categories=[_s(rec.get("type_evenement"))], audience=_s(rec.get("public_cible")), venue=emplacement or _s(rec.get("titre_adresse")), address=address, city="Montréal", region="Montréal", postal_code=_s(rec.get("code_postal")), lat=_s(rec.get("lat")) or None, lng=_s(rec.get("long")) or None, start_date=_s(rec.get("date_debut")) or None, end_date=_s(rec.get("date_fin")) or None, price_label=" — ".join(p for p in (cost, inscription) if p), organizer="Ville de Montréal — " + (_s(rec.get("arrondissement")) or "ville-centre"), )) offset += PAGE if offset >= int(result.get("total", 0)): break self._enrich_fiches(events) return events