SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
5.3 KB · 123 lines python
Raw Blame History
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