# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mtlorg.py : Tourisme Montréal — mtl.org (ATR, vague ATR 2026-08) # Source : mtl.org/fr/quoi-faire/festivals-et-evenements — répertoire # officiel de Tourisme Montréal (frontend SvelteKit + Drupal, # listing servi par le même index Algolia que le site). # Extraction: index Algolia « prod_fr » (appId/clé de recherche publiques, # embarquées dans le JS du site — vérifié 2026-08-25), filtre # facettes _sections:« Festivals et événements » + # content_type:product → ~650 fiches : titre, surtitre, # description, plages de dates STRUCTURÉES _open_dates # [{start,end}], adresse complète (_address), GPS (_geoloc), # sous-catégories, image CDN, URL. 1 requête/sync # (hitsPerPage=1000). Les fiches SANS _open_dates (salles / # attraits permanents listés dans la section) sont écartées : # un événement sans date publiée n'est pas émis (jamais inventé). # Accès : clé Algolia « search-only » publiée par le site lui-même — # requêtes throttlées, identification honnête, lien vers la # fiche originale. Ni prix ni heure publiés → jamais inventés. # ----------------------------------------------------------------------------- from __future__ import annotations import os import time from datetime import date from ..normalize import clean_text from ..schema import Event from .base import BaseConnector BASE = "https://www.mtl.org" ALGOLIA_URL = "https://XGNQMFEPVY-dsn.algolia.net/1/indexes/prod_fr/query" ALGOLIA_HEADERS = { "X-Algolia-Application-Id": "XGNQMFEPVY", "X-Algolia-API-Key": "ca0b7ec6d7560ae093455e13208291b6", "Content-Type": "application/json", } FACETS = ('[["_sections:Festivals et événements"],' '["content_type:product"]]') MTL_PAGE_MAX = int(os.environ.get("MTL_PAGE_MAX", "5")) def _pick_range(open_dates: list, today: str) -> tuple[str | None, str | None]: """Première plage encore active/à venir, sinon la dernière publiée.""" ranges = [(d.get("start"), d.get("end")) for d in open_dates if isinstance(d, dict) and d.get("start")] if not ranges: return None, None ranges.sort() for start, end in ranges: if (end or start) >= today: return start, end or start start, end = ranges[-1] return start, end or start def _image_url(hit: dict) -> str: img = ((hit.get("_image") or {}).get("image") or {}) for size in ("medium", "large", "small"): entries = img.get(size) or [] if entries and isinstance(entries[0], dict) and entries[0].get("url"): return entries[0]["url"] return "" class MtlOrgConnector(BaseConnector): source_id = "mtlorg" request_delay = 0.8 def _hits(self) -> list[dict]: import json as _json from urllib.parse import quote hits: list[dict] = [] for page in range(MTL_PAGE_MAX): self._throttle() resp = self.session.post(ALGOLIA_URL, json={ "params": f"hitsPerPage=1000&page={page}" f"&facetFilters={quote(FACETS)}"}, headers=ALGOLIA_HEADERS, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() data = resp.json() hits.extend(data.get("hits") or []) if page + 1 >= int(data.get("nbPages") or 1): break return hits def fetch(self) -> list[Event]: today = date.today().isoformat() events: list[Event] = [] seen: set[str] = set() for h in self._hits(): eid = str(h.get("nid") or h.get("uuid") or "") title = clean_text(h.get("title") or "") start, end = _pick_range(h.get("_open_dates") or [], today) if not eid or eid in seen or not title or not start: continue # salle/attrait permanent sans date → pas un événement seen.add(eid) addr = h.get("_address") or {} geo = h.get("_geoloc") or {} cats = ((h.get("_filter_events_sub_categories") or []) + (h.get("_filter_global") or [])) events.append(Event( source=self.source_id, external_id=eid, url=BASE + (h.get("_url") or ""), title=title, description=clean_text(h.get("description") or "") or clean_text(h.get("surtitle") or ""), raw_categories=[clean_text(c) for c in cats if c] + [title], venue=clean_text(addr.get("name") or ""), address=clean_text(addr.get("address") or ""), city=clean_text(addr.get("city") or ""), postal_code=(addr.get("postalCode") or "").strip(), tourist_region="Montréal", lat=geo.get("lat"), lng=geo.get("lng"), start_date=start, end_date=end, image=_image_url(h), )) return events