# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sallesjsonld.py : moteur générique « schema.org/Event » (Phase 3) # La LONGUE TRAÎNE des salles de spectacle : beaucoup de sites de salles # embarquent un bloc JSON-LD Event complet (dates horodatées, salle, offres, # statut) sur chaque fiche. Ce connecteur prend une LISTE DE SALLES # configurable (data/salles_jsonld.json, patron stores.json de fabri-ka) : # chaque entrée = page(s) de programmation + motif des liens de fiche + # ville/salle par défaut. Ajouter une salle = 1 bloc de config, 0 code. # Extraction: page liste (1 GET/salle) → fiches incrémentales (cache # data/sallesjsonld_cache.json, SALLES_FICHE_MAX/sync partagé, # re-visite 7 j) → TOUS les blocs JSON-LD @type Event de la # fiche (une fiche multi-dates = plusieurs séances). # Salles d'amorce validées 2026-08-19 (Event JSON-LD réel constaté) : # Grand Théâtre de Québec, Théâtre du Trident, Théâtre Capitole (Gestev). # Accès : robots.txt vérifié PAR SALLE avant l'ajout à la config # (consigné dans docs/connecteurs/sallesjsonld.md) ; GET # throttlés 0,8 s, identification honnête, lien fiche originale. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from pathlib import Path from urllib.parse import urljoin from ..normalize import clean_text from ..schema import Event, normalize_status from .base import BaseConnector CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "salles_jsonld.json" FICHE_MAX = int(os.environ.get("SALLES_FICHE_MAX", "40")) # total, toutes salles REFRESH_AFTER = 7 * 86400 _LD_RE = re.compile(r']*type="application/ld\+json"[^>]*>') _HREF_RE = re.compile(r'href="([^"#]+)"') def ld_events(html: str) -> list[dict]: """Tous les blocs JSON-LD de @type Event d'une page (listes et @graph déballés), tolérant aux JSON malformés voisins.""" out: list[dict] = [] for m in _LD_RE.finditer(html): try: d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip()) except Exception: continue docs = d if isinstance(d, list) else ( d.get("@graph") if isinstance(d, dict) and "@graph" in d else [d]) for doc in docs or []: if isinstance(doc, dict) and "Event" in str(doc.get("@type", "")): out.append(doc) return out def _first(v): if isinstance(v, list): return v[0] if v else None return v def _local_dt(iso: str) -> tuple[str | None, str | None]: """Horodatage JSON-LD → (date, heure) locales Québec, sans inventer. Un décalage EXPLICITE (« +00:00 », « Z », « -05:00 ») est converti en heure de Montréal (le Grand Théâtre publie ses heures en UTC — vérifié 2026-08-19 : « 2026-08-19T23:30:00+00:00 » = 19 h 30 locales). Sans décalage, l'heure est prise littérale. Minuit = placeholder « toute la journée » (constaté sur les fiches Trident) → heure inconnue. """ from ..normalize import parse_date_iso, parse_time, utc_to_local if not iso: return None, None if re.search(r"(Z|[+-]\d{2}:\d{2})$", iso) and "T" in iso: return utc_to_local(iso) t = parse_time(iso) if "T" in iso else None return parse_date_iso(iso), (None if t == "00:00" else t) class SallesJsonLdConnector(BaseConnector): source_id = "sallesjsonld" request_delay = 0.8 def _load_config(self) -> list[dict]: try: return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))["salles"] except Exception: return [] def _fiche_events(self, salle: dict, url: str, html: str) -> list[dict]: rows = [] for ld in ld_events(html): title = clean_text(str(ld.get("name") or "")) start = str(ld.get("startDate") or "") if not title or not start: continue loc = ld.get("location") if isinstance(loc, list): loc = loc[0] if loc else {} loc = loc if isinstance(loc, dict) else {} addr = loc.get("address") addr = addr if isinstance(addr, dict) else {} offers = ld.get("offers") if isinstance(offers, list): offers = offers[0] if offers else {} offers = offers if isinstance(offers, dict) else {} price_label = "" low = offers.get("lowPrice") or offers.get("price") if low not in (None, "", 0, "0"): high = offers.get("highPrice") price_label = (f"{low} $ à {high} $" if high and high != low else f"{low} $") img = _first(ld.get("image")) or "" slug = url.rstrip("/").split("/")[-1] date_part = start[:16].replace(":", "").replace("T", "-") rows.append({ "external_id": f"{salle['id']}-{slug}-{date_part}", "url": url, "title": title, "description": clean_text(str(ld.get("description") or "")), "venue": clean_text(str(loc.get("name") or "")) or salle.get("salle", ""), "address": clean_text(str(addr.get("streetAddress") or "")), "city": clean_text(str(addr.get("addressLocality") or "")) or salle.get("ville", ""), "postal_code": str(addr.get("postalCode") or "").strip(), "start": start, "end": str(ld.get("endDate") or "") or start, "status": normalize_status(str(ld.get("eventStatus") or "")), "price_label": price_label, "image": img if isinstance(img, str) else "", "website": str(offers.get("url") or "") or url, }) return rows def fetch(self) -> list[Event]: cache = self.load_cache() now = time.time() budget = FICHE_MAX all_urls: set[str] = set() for salle in self._load_config(): motif = re.compile(salle["motif"]) urls: set[str] = set() for liste in salle.get("listes", []): try: html = self.get(liste).text except Exception: print(f"[sorti-ka] sallesjsonld : liste inaccessible {liste}") continue for href in _HREF_RE.findall(html): full = urljoin(liste, href) if motif.search(full): urls.add(full.split("?")[0]) all_urls |= urls to_fetch = sorted( (u for u in urls if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), key=lambda u: cache.get(u, {}).get("ts", 0))[:max(0, budget)] budget -= len(to_fetch) def worker(url, session, _salle=salle): return self._fiche_events( _salle, url, session.get(url, timeout=25).text) for url, rows in self.fetch_many(to_fetch, worker, max_workers=3).items(): cache[url] = {"ts": now, "rows": rows or []} # on ne garde en cache que les fiches encore listées quelque part cache = {u: c for u, c in cache.items() if u in all_urls} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for entry in cache.values(): for r in entry.get("rows") or []: if r["external_id"] in seen: continue seen.add(r["external_id"]) sd, st = _local_dt(r["start"]) ed, et = (_local_dt(r["end"]) if r["end"] != r["start"] else (sd, None)) events.append(Event( source=self.source_id, external_id=r["external_id"], url=r["url"], title=r["title"], description=r["description"], raw_categories=[r["title"]], venue=r["venue"], address=r["address"], city=r["city"], postal_code=r["postal_code"], start_date=sd, start_time=st, end_date=ed, end_time=et, status=r["status"], price_label=r["price_label"], image=r["image"], website=r["website"], )) return events