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/mtlorg.py : Tourisme Montréal — mtl.org (ATR, vague ATR 2026-08)5# Source : mtl.org/fr/quoi-faire/festivals-et-evenements — répertoire6# officiel de Tourisme Montréal (frontend SvelteKit + Drupal,7# listing servi par le même index Algolia que le site).8# Extraction: index Algolia « prod_fr » (appId/clé de recherche publiques,9# embarquées dans le JS du site — vérifié 2026-08-25), filtre10# facettes _sections:« Festivals et événements » +11# content_type:product → ~650 fiches : titre, surtitre,12# description, plages de dates STRUCTURÉES _open_dates13# [{start,end}], adresse complète (_address), GPS (_geoloc),14# sous-catégories, image CDN, URL. 1 requête/sync15# (hitsPerPage=1000). Les fiches SANS _open_dates (salles /16# attraits permanents listés dans la section) sont écartées :17# un événement sans date publiée n'est pas émis (jamais inventé).18# Accès : clé Algolia « search-only » publiée par le site lui-même —19# requêtes throttlées, identification honnête, lien vers la20# fiche originale. Ni prix ni heure publiés → jamais inventés.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import os25import time26from datetime import date2728from ..normalize import clean_text29from ..schema import Event30from .base import BaseConnector3132BASE = "https://www.mtl.org"33ALGOLIA_URL = "https://XGNQMFEPVY-dsn.algolia.net/1/indexes/prod_fr/query"34ALGOLIA_HEADERS = {35 "X-Algolia-Application-Id": "XGNQMFEPVY",36 "X-Algolia-API-Key": "ca0b7ec6d7560ae093455e13208291b6",37 "Content-Type": "application/json",38}39FACETS = ('[["_sections:Festivals et événements"],'40 '["content_type:product"]]')41MTL_PAGE_MAX = int(os.environ.get("MTL_PAGE_MAX", "5"))424344def _pick_range(open_dates: list, today: str) -> tuple[str | None, str | None]:45 """Première plage encore active/à venir, sinon la dernière publiée."""46 ranges = [(d.get("start"), d.get("end")) for d in open_dates47 if isinstance(d, dict) and d.get("start")]48 if not ranges:49 return None, None50 ranges.sort()51 for start, end in ranges:52 if (end or start) >= today:53 return start, end or start54 start, end = ranges[-1]55 return start, end or start565758def _image_url(hit: dict) -> str:59 img = ((hit.get("_image") or {}).get("image") or {})60 for size in ("medium", "large", "small"):61 entries = img.get(size) or []62 if entries and isinstance(entries[0], dict) and entries[0].get("url"):63 return entries[0]["url"]64 return ""656667class MtlOrgConnector(BaseConnector):68 source_id = "mtlorg"69 request_delay = 0.87071 def _hits(self) -> list[dict]:72 import json as _json73 from urllib.parse import quote74 hits: list[dict] = []75 for page in range(MTL_PAGE_MAX):76 self._throttle()77 resp = self.session.post(ALGOLIA_URL, json={78 "params": f"hitsPerPage=1000&page={page}"79 f"&facetFilters={quote(FACETS)}"},80 headers=ALGOLIA_HEADERS, timeout=self.timeout)81 self._last_request = time.time()82 resp.raise_for_status()83 data = resp.json()84 hits.extend(data.get("hits") or [])85 if page + 1 >= int(data.get("nbPages") or 1):86 break87 return hits8889 def fetch(self) -> list[Event]:90 today = date.today().isoformat()91 events: list[Event] = []92 seen: set[str] = set()93 for h in self._hits():94 eid = str(h.get("nid") or h.get("uuid") or "")95 title = clean_text(h.get("title") or "")96 start, end = _pick_range(h.get("_open_dates") or [], today)97 if not eid or eid in seen or not title or not start:98 continue # salle/attrait permanent sans date → pas un événement99 seen.add(eid)100 addr = h.get("_address") or {}101 geo = h.get("_geoloc") or {}102 cats = ((h.get("_filter_events_sub_categories") or [])103 + (h.get("_filter_global") or []))104 events.append(Event(105 source=self.source_id,106 external_id=eid,107 url=BASE + (h.get("_url") or ""),108 title=title,109 description=clean_text(h.get("description") or "")110 or clean_text(h.get("surtitle") or ""),111 raw_categories=[clean_text(c) for c in cats if c] + [title],112 venue=clean_text(addr.get("name") or ""),113 address=clean_text(addr.get("address") or ""),114 city=clean_text(addr.get("city") or ""),115 postal_code=(addr.get("postalCode") or "").strip(),116 tourist_region="Montréal",117 lat=geo.get("lat"), lng=geo.get("lng"),118 start_date=start,119 end_date=end,120 image=_image_url(h),121 ))122 return events123