Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sallesjsonld.py : moteur générique « schema.org/Event » (Phase 3)5# La LONGUE TRAÎNE des salles de spectacle : beaucoup de sites de salles6# embarquent un bloc JSON-LD Event complet (dates horodatées, salle, offres,7# statut) sur chaque fiche. Ce connecteur prend une LISTE DE SALLES8# configurable (data/salles_jsonld.json, patron stores.json de fabri-ka) :9# chaque entrée = page(s) de programmation + motif des liens de fiche +10# ville/salle par défaut. Ajouter une salle = 1 bloc de config, 0 code.11# Extraction: page liste (1 GET/salle) → fiches incrémentales (cache12# data/sallesjsonld_cache.json, SALLES_FICHE_MAX/sync partagé,13# re-visite 7 j) → TOUS les blocs JSON-LD @type Event de la14# fiche (une fiche multi-dates = plusieurs séances).15# Salles d'amorce validées 2026-08-19 (Event JSON-LD réel constaté) :16# Grand Théâtre de Québec, Théâtre du Trident, Théâtre Capitole (Gestev).17# Accès : robots.txt vérifié PAR SALLE avant l'ajout à la config18# (consigné dans docs/connecteurs/sallesjsonld.md) ; GET19# throttlés 0,8 s, identification honnête, lien fiche originale.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import os25import re26import time27from pathlib import Path28from urllib.parse import urljoin2930from ..normalize import clean_text31from ..schema import Event, normalize_status32from .base import BaseConnector3334CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "salles_jsonld.json"35FICHE_MAX = int(os.environ.get("SALLES_FICHE_MAX", "40")) # total, toutes salles36REFRESH_AFTER = 7 * 864003738_LD_RE = re.compile(r'<script[^>]*type="application/ld\+json"[^>]*>')39_HREF_RE = re.compile(r'href="([^"#]+)"')404142def ld_events(html: str) -> list[dict]:43 """Tous les blocs JSON-LD de @type Event d'une page (listes et @graph44 déballés), tolérant aux JSON malformés voisins."""45 out: list[dict] = []46 for m in _LD_RE.finditer(html):47 try:48 d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip())49 except Exception:50 continue51 docs = d if isinstance(d, list) else (52 d.get("@graph") if isinstance(d, dict) and "@graph" in d else [d])53 for doc in docs or []:54 if isinstance(doc, dict) and "Event" in str(doc.get("@type", "")):55 out.append(doc)56 return out575859def _first(v):60 if isinstance(v, list):61 return v[0] if v else None62 return v636465def _local_dt(iso: str) -> tuple[str | None, str | None]:66 """Horodatage JSON-LD → (date, heure) locales Québec, sans inventer.6768 Un décalage EXPLICITE (« +00:00 », « Z », « -05:00 ») est converti en69 heure de Montréal (le Grand Théâtre publie ses heures en UTC — vérifié70 2026-08-19 : « 2026-08-19T23:30:00+00:00 » = 19 h 30 locales). Sans71 décalage, l'heure est prise littérale. Minuit = placeholder « toute la72 journée » (constaté sur les fiches Trident) → heure inconnue.73 """74 from ..normalize import parse_date_iso, parse_time, utc_to_local75 if not iso:76 return None, None77 if re.search(r"(Z|[+-]\d{2}:\d{2})$", iso) and "T" in iso:78 return utc_to_local(iso)79 t = parse_time(iso) if "T" in iso else None80 return parse_date_iso(iso), (None if t == "00:00" else t)818283class SallesJsonLdConnector(BaseConnector):84 source_id = "sallesjsonld"85 request_delay = 0.88687 def _load_config(self) -> list[dict]:88 try:89 return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))["salles"]90 except Exception:91 return []9293 def _fiche_events(self, salle: dict, url: str, html: str) -> list[dict]:94 rows = []95 for ld in ld_events(html):96 title = clean_text(str(ld.get("name") or ""))97 start = str(ld.get("startDate") or "")98 if not title or not start:99 continue100 loc = ld.get("location")101 if isinstance(loc, list):102 loc = loc[0] if loc else {}103 loc = loc if isinstance(loc, dict) else {}104 addr = loc.get("address")105 addr = addr if isinstance(addr, dict) else {}106 offers = ld.get("offers")107 if isinstance(offers, list):108 offers = offers[0] if offers else {}109 offers = offers if isinstance(offers, dict) else {}110 price_label = ""111 low = offers.get("lowPrice") or offers.get("price")112 if low not in (None, "", 0, "0"):113 high = offers.get("highPrice")114 price_label = (f"{low} $ à {high} $" if high and high != low115 else f"{low} $")116 img = _first(ld.get("image")) or ""117 slug = url.rstrip("/").split("/")[-1]118 date_part = start[:16].replace(":", "").replace("T", "-")119 rows.append({120 "external_id": f"{salle['id']}-{slug}-{date_part}",121 "url": url,122 "title": title,123 "description": clean_text(str(ld.get("description") or "")),124 "venue": clean_text(str(loc.get("name") or "")) or salle.get("salle", ""),125 "address": clean_text(str(addr.get("streetAddress") or "")),126 "city": clean_text(str(addr.get("addressLocality") or "")) or salle.get("ville", ""),127 "postal_code": str(addr.get("postalCode") or "").strip(),128 "start": start,129 "end": str(ld.get("endDate") or "") or start,130 "status": normalize_status(str(ld.get("eventStatus") or "")),131 "price_label": price_label,132 "image": img if isinstance(img, str) else "",133 "website": str(offers.get("url") or "") or url,134 })135 return rows136137 def fetch(self) -> list[Event]:138 cache = self.load_cache()139 now = time.time()140 budget = FICHE_MAX141 all_urls: set[str] = set()142 for salle in self._load_config():143 motif = re.compile(salle["motif"])144 urls: set[str] = set()145 for liste in salle.get("listes", []):146 try:147 html = self.get(liste).text148 except Exception:149 print(f"[sorti-ka] sallesjsonld : liste inaccessible {liste}")150 continue151 for href in _HREF_RE.findall(html):152 full = urljoin(liste, href)153 if motif.search(full):154 urls.add(full.split("?")[0])155 all_urls |= urls156 to_fetch = sorted(157 (u for u in urls158 if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER),159 key=lambda u: cache.get(u, {}).get("ts", 0))[:max(0, budget)]160 budget -= len(to_fetch)161162 def worker(url, session, _salle=salle):163 return self._fiche_events(164 _salle, url, session.get(url, timeout=25).text)165166 for url, rows in self.fetch_many(to_fetch, worker,167 max_workers=3).items():168 cache[url] = {"ts": now, "rows": rows or []}169 # on ne garde en cache que les fiches encore listées quelque part170 cache = {u: c for u, c in cache.items() if u in all_urls}171 self.save_cache(cache)172173 events: list[Event] = []174 seen: set[str] = set()175 for entry in cache.values():176 for r in entry.get("rows") or []:177 if r["external_id"] in seen:178 continue179 seen.add(r["external_id"])180 sd, st = _local_dt(r["start"])181 ed, et = (_local_dt(r["end"])182 if r["end"] != r["start"] else (sd, None))183 events.append(Event(184 source=self.source_id,185 external_id=r["external_id"],186 url=r["url"],187 title=r["title"],188 description=r["description"],189 raw_categories=[r["title"]],190 venue=r["venue"],191 address=r["address"],192 city=r["city"],193 postal_code=r["postal_code"],194 start_date=sd,195 start_time=st,196 end_date=ed,197 end_time=et,198 status=r["status"],199 price_label=r["price_label"],200 image=r["image"],201 website=r["website"],202 ))203 return events204