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%
7.9 KB · 162 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/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