# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/signelaval.py : connecteur Signé Laval (signelaval.com)
# Source : calendrier culturel officiel de Laval (Culture Laval) —
# bibliothèques, spectacles, expositions, plein air (~400 fiches).
# Extraction: sitemap public /sitemap/events/N.xml puis fiches rendues
# serveur : JSON-LD Event complet (CMS Footlight / artsdata.ca)
# avec dates ISO horodatées (fuseau -04:00), lieu, adresse,
# ville, image IIIF, organisateur. Certains champs Footlight
# sont des LISTES multilingues (["…", {"@language": "fr",
# "@value": "…"}]) → _val privilégie la variante française
# (bug corrigé à la validation live 2026-08-25).
# GET directs, pas d'anti-bot.
# INCRÉMENTAL avec cache disque (data/signelaval_cache.json) :
# nouvelles fiches d'abord, re-visite roulante après 7 jours,
# plafonné à SIGNELAVAL_MAX_FETCH fiches/sync.
# Accès : robots.txt « User-agent: * / Disallow: (vide) » + sitemap
# public → scraping permis, identification honnête.
# Prix : offers = AggregateOffer sans montant sur les fiches vérifiées
# → is_free inconnu (jamais inventé).
# -----------------------------------------------------------------------------
from __future__ import annotations
import json
import re
import time
from ..normalize import clean_text, parse_time
from ..schema import Event
from .base import BaseConnector
BASE = "https://signelaval.com"
SITEMAP_INDEX = f"{BASE}/sitemap.xml"
MAX_FETCH = int(__import__("os").environ.get("SIGNELAVAL_MAX_FETCH", "200"))
REFRESH_AFTER = 7 * 86400 # re-visite roulante d'une fiche (secondes)
_LOC_RE = re.compile(r"([^<]+)")
_LD_RE = re.compile(r'',
re.S)
def _val(x) -> str:
"""Valeur d'un champ JSON-LD Footlight : littéral, {"@value": …} ou LISTE
multilingue (ex. ["Rythme latin", {"@language": "fr", "@value": "…"}]) —
on privilégie la variante française, sinon la première non vide."""
if isinstance(x, list):
fallback = ""
for item in x:
if (isinstance(item, dict)
and str(item.get("@language", "")).startswith("fr")):
v = _val(item)
if v:
return v
fallback = fallback or _val(item)
return fallback
if isinstance(x, dict):
return str(x.get("@value") or "")
return str(x or "")
class SigneLavalConnector(BaseConnector):
source_id = "signelaval"
request_delay = 0.8
# -- sitemap ----------------------------------------------------------
def _fiches(self) -> list[str]:
idx = self.get(SITEMAP_INDEX).text
shards = [u for u in _LOC_RE.findall(idx) if "/sitemap/events/" in u]
urls: list[str] = []
for shard in shards:
urls += [u for u in _LOC_RE.findall(self.get(shard).text)
if "/evenements/" in u]
return urls
# -- fiche ------------------------------------------------------------
def _parse_fiche(self, url: str, html: str) -> dict | None:
"""JSON-LD Event (Footlight) → ligne normalisable, ou None."""
data = None
for blob in _LD_RE.findall(html):
try:
d = json.loads(blob)
except ValueError:
continue
if d.get("@type") == "Event":
data = d
break
if not data:
return None
title = clean_text(_val(data.get("name")))
if not title:
return None
start = _val(data.get("startDate"))
end = _val(data.get("endDate"))
loc = data.get("location") or {}
addr = loc.get("address") or {}
img = data.get("image") or {}
image = (img.get("http://schema.org/url")
or img.get("thumbnailUrl") or "") if isinstance(img, dict) else ""
org = data.get("organizer") or {}
# /fr/evenements// → id stable
parts = url.rstrip("/").split("/")
ext = parts[-2] if len(parts) >= 2 else parts[-1]
return {
"external_id": ext, "url": url, "title": title,
"description": clean_text(_val(data.get("description")))[:2000],
"raw_categories": [title],
"venue": clean_text(_val(loc.get("name"))),
"address": clean_text(_val(addr.get("streetAddress"))),
"city": clean_text(_val(addr.get("addressLocality"))) or "Laval",
"postal_code": _val(addr.get("postalCode")),
"start_date": start[:10] if start else None,
"start_time": parse_time(start),
"end_date": end[:10] if end else None,
"end_time": parse_time(end),
"status": ("cancelled"
if "cancel" in str(data.get("eventStatus", "")).lower()
else ""),
"organizer": clean_text(_val(org.get("name"))),
"website": data.get("url") or "",
"image": image,
}
# -- pipeline ---------------------------------------------------------
def fetch(self) -> list[Event]:
cache = self.load_cache()
urls = self._fiches()
current = set(urls)
cache = {u: c for u, c in cache.items() if u in current}
now = time.time()
candidates = sorted(
(u for u in urls
if u not in cache
or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),
key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:MAX_FETCH]
def worker(url, session):
return self._parse_fiche(url, session.get(url, timeout=20).text)
for url, row in self.fetch_many(candidates, worker, max_workers=4).items():
cache[url] = {"fetched_at": now, "row": row}
self.save_cache(cache)
events: list[Event] = []
seen: set[str] = set()
for entry in cache.values():
row = entry.get("row")
if not row or row["external_id"] in seen:
continue
seen.add(row["external_id"])
events.append(Event(
source=self.source_id,
external_id=row["external_id"],
url=row["url"], title=row["title"],
description=row.get("description", ""),
raw_categories=row.get("raw_categories") or [],
venue=row.get("venue", ""),
address=row.get("address", ""),
city=row.get("city", ""),
postal_code=row.get("postal_code", ""),
start_date=row.get("start_date"),
start_time=row.get("start_time"),
end_date=row.get("end_date"),
end_time=row.get("end_time"),
status=row.get("status", ""),
organizer=row.get("organizer", ""),
website=row.get("website", ""),
image=row.get("image", ""),
))
return events