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/placedesarts.py : Place des Arts — calendrier multi-salles (Phase 3)5# Source : https://www.placedesarts.com/programmation (Craft CMS) —6# ~97 spectacles à l'affiche sur les 6 salles (Wilfrid-Pelletier,7# Maison symphonique, Théâtre Maisonneuve, Jean-Duceppe,8# Cinquième Salle, Claude-Léveillée), vérifié 2026-08-19.9# Extraction: liste /programmation (1 GET) → fiches /evenement/<slug>10# incrémentales (cache data/placedesarts_cache.json,11# PDA_FICHE_MAX/sync, re-visite 7 j). Chaque fiche embarque :12# · #eventbannerdata (JSON) : titre, url, image, plage de dates ;13# · #eventGTM (JSON) : salle(s), producteur, discipline ;14# · barre latérale #sb-performances : CHAQUE représentation avec15# heure (« 19h30 »), date FR complète et PRIX RÉELS16# (« 33 $ à 97,50 $ ») → une fiche Sorti-Ka PAR séance17# (dedup_key par heure, patron Phase 2).18# Accès : robots.txt n'interdit que /cpresources/ /vendor/ /cache/ —19# GET throttlés 0,8 s, identification honnête, lien billetterie20# officiel. Recoupement lavitrine/evenko géré par dedup_key.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import os26import re27import time2829from ..normalize import clean_text, parse_date_fr, parse_date_range_fr, parse_time30from ..schema import Event31from .base import BaseConnector3233BASE = "https://www.placedesarts.com"34LIST_URL = BASE + "/programmation"35FICHE_MAX = int(os.environ.get("PDA_FICHE_MAX", "25"))36REFRESH_AFTER = 7 * 864003738_LINK_RE = re.compile(r'href="(?:https://www\.placedesarts\.com)?(/evenement/[a-z0-9-]+)"')39_BANNER_RE = re.compile(r'id="eventbannerdata">\s*(\{.*?\})\s*</script>', re.S)40_GTM_RE = re.compile(r'id="eventGTM">\s*(\{.*?\})\s*</script>', re.S)414243class PlaceDesArtsConnector(BaseConnector):44 source_id = "placedesarts"45 request_delay = 0.84647 def _parse_fiche(self, html: str) -> dict | None:48 m = _BANNER_RE.search(html)49 if not m:50 return None51 try:52 banner = json.loads(m.group(1))53 except Exception:54 return None55 gtm = {}56 mg = _GTM_RE.search(html)57 if mg:58 try:59 gtm = json.loads(mg.group(1))60 except Exception:61 gtm = {}62 # représentations de la barre latérale (texte : heure, date, prix)63 i = html.find('id="sb-performances"')64 perfs = []65 if i >= 0:66 seg = re.sub(r"\s+", " ", html[i:i + 60000])67 txt = re.sub(r"<[^>]+>", "|", seg)68 for pm in re.finditer(69 r"(\d{1,2}h\d{2})[|\s]+"70 r"([A-Za-zÀ-ÿ]+ \d{1,2}(?:er)? [a-zà-ÿ]+ \d{4})"71 r"(?:[|\s]+([^|]*\$[^|]*))?", txt):72 heure = parse_time(pm.group(1))73 date_iso = parse_date_fr(pm.group(2))74 if date_iso:75 perfs.append({"date": date_iso, "time": heure,76 "prix": clean_text(pm.group(3) or "")})77 start, end = parse_date_range_fr(banner.get("dates") or "")78 return {79 "title": clean_text(banner.get("title") or ""),80 "url": banner.get("url") or "",81 "image": banner.get("mainImage") or "",82 "start": start, "end": end,83 "passed": bool(banner.get("hasPassed")),84 "venue": clean_text((gtm.get("eventPlace") or [""])[0]),85 "organizer": clean_text(gtm.get("eventProducer") or ""),86 "cats": [clean_text(c) for c in87 (gtm.get("eventDiscipline") or []) +88 (gtm.get("eventSubCategory") or [])],89 "perfs": perfs,90 }9192 def fetch(self) -> list[Event]:93 html = self.get(LIST_URL).text94 paths = sorted(set(_LINK_RE.findall(html)))95 cache = self.load_cache()96 now = time.time()97 current = set(paths)98 cache = {p: c for p, c in cache.items() if p in current}99 to_fetch = sorted(100 (p for p in paths101 if p not in cache or now - cache[p].get("ts", 0) > REFRESH_AFTER),102 key=lambda p: cache.get(p, {}).get("ts", 0))[:FICHE_MAX]103104 def worker(path, session):105 return self._parse_fiche(106 session.get(BASE + path, timeout=25).text)107108 for path, row in self.fetch_many(to_fetch, worker, max_workers=4).items():109 if row is None:110 print(f"[sorti-ka] placedesarts : fiche ignorée {path}")111 continue112 cache[path] = {"ts": now, "row": row}113 self.save_cache(cache)114115 events: list[Event] = []116 seen: set[str] = set()117 for path, entry in cache.items():118 row = entry.get("row")119 if not row or not row.get("title") or row.get("passed"):120 continue121 slug = path.strip("/").split("/")[-1]122 base_kwargs = dict(123 source=self.source_id,124 url=row["url"] or BASE + path,125 title=row["title"],126 raw_categories=row["cats"] or [row["title"]],127 venue=row["venue"],128 city="Montréal",129 region="Montréal",130 organizer=row["organizer"],131 image=row["image"],132 )133 if row["perfs"]:134 for p in row["perfs"]:135 ext = f"{slug}-{p['date']}-{p['time'] or 'nd'}"136 if ext in seen:137 continue138 seen.add(ext)139 events.append(Event(140 external_id=ext,141 start_date=p["date"], start_time=p["time"],142 end_date=p["date"],143 price_label=p["prix"],144 **base_kwargs))145 else:146 if slug in seen:147 continue148 seen.add(slug)149 events.append(Event(150 external_id=slug,151 start_date=row["start"], end_date=row["end"],152 **base_kwargs))153 return events154