# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/placedesarts.py : Place des Arts — calendrier multi-salles (Phase 3) # Source : https://www.placedesarts.com/programmation (Craft CMS) — # ~97 spectacles à l'affiche sur les 6 salles (Wilfrid-Pelletier, # Maison symphonique, Théâtre Maisonneuve, Jean-Duceppe, # Cinquième Salle, Claude-Léveillée), vérifié 2026-08-19. # Extraction: liste /programmation (1 GET) → fiches /evenement/ # incrémentales (cache data/placedesarts_cache.json, # PDA_FICHE_MAX/sync, re-visite 7 j). Chaque fiche embarque : # · #eventbannerdata (JSON) : titre, url, image, plage de dates ; # · #eventGTM (JSON) : salle(s), producteur, discipline ; # · barre latérale #sb-performances : CHAQUE représentation avec # heure (« 19h30 »), date FR complète et PRIX RÉELS # (« 33 $ à 97,50 $ ») → une fiche Sorti-Ka PAR séance # (dedup_key par heure, patron Phase 2). # Accès : robots.txt n'interdit que /cpresources/ /vendor/ /cache/ — # GET throttlés 0,8 s, identification honnête, lien billetterie # officiel. Recoupement lavitrine/evenko géré par dedup_key. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text, parse_date_fr, parse_date_range_fr, parse_time from ..schema import Event from .base import BaseConnector BASE = "https://www.placedesarts.com" LIST_URL = BASE + "/programmation" FICHE_MAX = int(os.environ.get("PDA_FICHE_MAX", "25")) REFRESH_AFTER = 7 * 86400 _LINK_RE = re.compile(r'href="(?:https://www\.placedesarts\.com)?(/evenement/[a-z0-9-]+)"') _BANNER_RE = re.compile(r'id="eventbannerdata">\s*(\{.*?\})\s*', re.S) _GTM_RE = re.compile(r'id="eventGTM">\s*(\{.*?\})\s*', re.S) class PlaceDesArtsConnector(BaseConnector): source_id = "placedesarts" request_delay = 0.8 def _parse_fiche(self, html: str) -> dict | None: m = _BANNER_RE.search(html) if not m: return None try: banner = json.loads(m.group(1)) except Exception: return None gtm = {} mg = _GTM_RE.search(html) if mg: try: gtm = json.loads(mg.group(1)) except Exception: gtm = {} # représentations de la barre latérale (texte : heure, date, prix) i = html.find('id="sb-performances"') perfs = [] if i >= 0: seg = re.sub(r"\s+", " ", html[i:i + 60000]) txt = re.sub(r"<[^>]+>", "|", seg) for pm in re.finditer( r"(\d{1,2}h\d{2})[|\s]+" r"([A-Za-zÀ-ÿ]+ \d{1,2}(?:er)? [a-zà-ÿ]+ \d{4})" r"(?:[|\s]+([^|]*\$[^|]*))?", txt): heure = parse_time(pm.group(1)) date_iso = parse_date_fr(pm.group(2)) if date_iso: perfs.append({"date": date_iso, "time": heure, "prix": clean_text(pm.group(3) or "")}) start, end = parse_date_range_fr(banner.get("dates") or "") return { "title": clean_text(banner.get("title") or ""), "url": banner.get("url") or "", "image": banner.get("mainImage") or "", "start": start, "end": end, "passed": bool(banner.get("hasPassed")), "venue": clean_text((gtm.get("eventPlace") or [""])[0]), "organizer": clean_text(gtm.get("eventProducer") or ""), "cats": [clean_text(c) for c in (gtm.get("eventDiscipline") or []) + (gtm.get("eventSubCategory") or [])], "perfs": perfs, } def fetch(self) -> list[Event]: html = self.get(LIST_URL).text paths = sorted(set(_LINK_RE.findall(html))) cache = self.load_cache() now = time.time() current = set(paths) cache = {p: c for p, c in cache.items() if p in current} to_fetch = sorted( (p for p in paths if p not in cache or now - cache[p].get("ts", 0) > REFRESH_AFTER), key=lambda p: cache.get(p, {}).get("ts", 0))[:FICHE_MAX] def worker(path, session): return self._parse_fiche( session.get(BASE + path, timeout=25).text) for path, row in self.fetch_many(to_fetch, worker, max_workers=4).items(): if row is None: print(f"[sorti-ka] placedesarts : fiche ignorée {path}") continue cache[path] = {"ts": now, "row": row} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for path, entry in cache.items(): row = entry.get("row") if not row or not row.get("title") or row.get("passed"): continue slug = path.strip("/").split("/")[-1] base_kwargs = dict( source=self.source_id, url=row["url"] or BASE + path, title=row["title"], raw_categories=row["cats"] or [row["title"]], venue=row["venue"], city="Montréal", region="Montréal", organizer=row["organizer"], image=row["image"], ) if row["perfs"]: for p in row["perfs"]: ext = f"{slug}-{p['date']}-{p['time'] or 'nd'}" if ext in seen: continue seen.add(ext) events.append(Event( external_id=ext, start_date=p["date"], start_time=p["time"], end_date=p["date"], price_label=p["prix"], **base_kwargs)) else: if slug in seen: continue seen.add(slug) events.append(Event( external_id=slug, start_date=row["start"], end_date=row["end"], **base_kwargs)) return events