# ----------------------------------------------------------------------------- # 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