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%
8.7 KB · 204 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/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