# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/laval.py : connecteur Ville de Laval — calendrier des activités # Source : donneesquebec.ca, jeu « Calendrier des activités » (laval.ca) # Extraction: fichier JSON officiel (données ouvertes, licence CC 4.0) # Couverture: ~300 activités municipales (bibliothèques, culture, famille, # plein air) avec dates, catégories, clientèles, lieux, images. # https://www.donneesquebec.ca/recherche/dataset/calendrier-des-activites # ----------------------------------------------------------------------------- from __future__ import annotations import re from ..normalize import utc_to_local from ..schema import Event from .base import BaseConnector # la Ville publie l'annulation DANS le titre : « ANNULÉ - Rencontre avec … » _CANCELLED_RE = re.compile(r"^\s*annul[eé]e?\s*[-–—:]\s*", re.I) FEED = ("https://www.donneesquebec.ca/recherche/dataset/" "df7add74-a741-4374-b3bd-22da8921c8b3/resource/" "b51a25de-bd06-4247-87ba-2b1ea8228005/download/calendrier-activites.json") SITE = "https://www.laval.ca" def _labels(items) -> list[str]: return [i.get("Label", "") for i in items or [] if i and i.get("Label")] class LavalConnector(BaseConnector): source_id = "laval" def fetch(self) -> list[Event]: events: list[Event] = [] for rec in self.get_json(FEED): title = (rec.get("Title") or "").strip() page = (rec.get("PageUrl") or "").strip() if not title or not page: continue # statut : préfixe « ANNULÉ - » du titre → cancelled (titre nettoyé) status = "" if _CANCELLED_RE.match(title): status = "cancelled" title = _CANCELLED_RE.sub("", title).strip() image = rec.get("ImageUrl") or "" # une activité récurrente = une entrée par occurrence, même PageUrl # → l'id externe inclut la date pour ne pas écraser les occurrences slug = page.rsplit("/", 1)[-1].removesuffix(".aspx") day = (rec.get("EventDate") or "")[:10] # EventDate/EndDate sont publiés en UTC (vérifié 2026-08 : les # activités d'été tombent à 18h30 locales une fois converties) → # date + heure locales Québec ; minuit local = « toute la journée ». start_local, time_local = utc_to_local(rec.get("EventDate")) end_local, _ = utc_to_local(rec.get("EndDate")) events.append(Event( source=self.source_id, external_id=f"{slug}-{day}" if day else slug, url=SITE + page, title=title, description=rec.get("Excerpt") or "", raw_categories=(_labels(rec.get("Categories")) + _labels(rec.get("Subcategories")) + _labels(rec.get("Tags"))), audience=", ".join(_labels(rec.get("Clienteles"))), venue=", ".join(_labels(rec.get("Locations"))), city="Laval", region="Laval", start_date=start_local or rec.get("EventDate"), start_time=time_local, end_date=end_local or rec.get("EndDate"), status=status, organizer="Ville de Laval", image=(SITE + image) if image.startswith("/") else image, )) return events