# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gatineau.py : connecteur Ville de Gatineau — calendrier officiel # Source : https://calendrier.gatineau.cloud — application calendrier # dédiée de la Ville (gatineau.ca/calendrier y redirige). # Extraction: API JSON interne POST /calendrier/json/evenements.aspx # (même appel que le frontend, découvert dans scripts.js) — # ~160 événements / ~250 occurrences en 1 requête : titre, # description, image, lien officiel, occurrences horodatées # (UTC → heure locale via utc_to_local), emplacements # (nom, adresse, secteur). Vérifié 2026-08-25. # Accès : application municipale publique, robots.txt « robots: all » # sur la page, GET/POST directs, identification honnête. # Prix : non publié par l'API → jamais inventé (les liens « Acheter # des billets » sont conservés dans website). # ----------------------------------------------------------------------------- from __future__ import annotations import os import time from ..normalize import clean_text, utc_to_local from ..schema import Event from .base import BaseConnector BASE = "https://calendrier.gatineau.cloud" API = BASE + "/calendrier/json/evenements.aspx" FICHE = BASE + "/calendrier/evenement.aspx?langue=fr-ca&id_evenement={id}" LIGNES = int(os.environ.get("GATINEAU_LIGNES", "500")) # plafond de lignes class GatineauConnector(BaseConnector): source_id = "gatineau" request_delay = 0.8 def _post_json(self, data: dict): self._throttle() resp = self.session.post(API, data=data, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json() def fetch(self) -> list[Event]: records = self._post_json({"langue": "fr-ca", "lignes": str(LIGNES)}) events: list[Event] = [] seen: set[str] = set() for rec in records: ev_id = str(rec.get("id") or "") title = clean_text(rec.get("evenement") or "") if not ev_id or not title: continue description = clean_text(rec.get("description") or "") image = rec.get("image") or "" website = rec.get("url") or "" # billets éventuels (« Acheter des billets ») → site officiel if not website: for act in rec.get("actions") or []: if act.get("url"): website = act["url"] break cats = [clean_text(e.get("etiquette") or str(e)) for e in rec.get("etiquettes") or [] if e] for occ in rec.get("occurrences") or []: ext = f"{ev_id}-{occ.get('id')}" if ext in seen: continue seen.add(ext) # horodatages publiés en UTC (« ...Z ») → date + heure locales start, start_time = utc_to_local(occ.get("date_heure_debut")) end, end_time = utc_to_local(occ.get("date_heure_fin")) if not start: continue venue = address = "" emp = (occ.get("emplacements") or [{}])[0] venue = clean_text(emp.get("emplacement_primaire") or emp.get("emplacement") or "") address = clean_text(emp.get("adresse") or "") desc = description or clean_text(occ.get("description") or "") mention = clean_text(occ.get("mention_speciale") or "") if mention: desc = f"{desc}\n{mention}".strip() events.append(Event( source=self.source_id, external_id=ext, url=FICHE.format(id=ev_id), title=title, description=desc, raw_categories=[c for c in cats if c] or [title], venue=venue, address=address, city="Gatineau", region="Outaouais", start_date=start, start_time=start_time, end_date=end or start, end_time=end_time, organizer="Ville de Gatineau", website=website, image=image, )) return events