SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
6.4 KB · 154 lines python
Raw Blame History
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