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%
12.9 KB · 268 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/ticketacces.py : TicketAcces.net — billetterie régionale5#   québécoise (Phase 4). Gros levier régional : 47 organisations au Québec6#   dont l'Abitibi-Témiscamingue (14 !), le Bas-Saint-Laurent, la Côte-Nord,7#   Charlevoix, la Gaspésie… (sondé 2026-08-21).8#   Source    : https://www.ticketacces.net (ColdFusion, encodage cp1252).9#               L'annuaire COMPLET des organisations est dans le portail10#               (cartes data-nom/data-ville/data-region/data-domaine) ; le11#               calendrier AGRÉGÉ multi-organisations expose chaque12#               représentation datée en 4 requêtes (catégories 1=Spectacles,13#               2=Cinéma, 3=Sport, 4=Autre).14#   Extraction: portail (1 GET, annuaire + filtre Québec — le réseau compte15#               12 orgs au Nouveau-Brunswick et 1 en Ontario, EXCLUES) →16#               calendrier par catégorie (4 GET) : titre + sous-titre, date17#               et heure FR (« Samedi 22 août 2026 à 12 h 30 »), « Salle,18#               Ville », image, lien fiche. Fiches détail incrémentales19#               (cache data/ticketacces_cache.json, TA_DETAIL_MAX/sync —20#               47 par défaut = 1 fiche par organisation/hôte, re-visite21#               7 j, version de parse « v » bumpée quand le parseur22#               s'enrichit → re-visite progressive, patron lavitrine) :23#               og:description, adresse civique de la salle (bloc24#               « Salle | 315, rue X, Ville » des représentations, ville25#               finale retirée), prix plancher si les ventes sont26#               ouvertes. 1 Event par représentation (une même fiche27#               multi-dates = plusieurs séances, patron lepointdevente).28#   Accès     : billetterie publique. robots.txt (lu 2026-08-21, identique29#               sur tous les hôtes *.ticketacces.net) : Crawl-delay: 120 +30#               Disallow /achat/ et representations/prix.cfm (jamais31#               requêtés) → GET espacés de 120 s PAR HÔTE (www porte le32#               portail + les 4 calendriers = 5 GET ≈ 8 min ; les fiches33#               vivent sur ~47 sous-domaines distincts, ≤ 1 fiche par hôte34#               par passage). Le passage réseau complet n'a lieu qu'à35#               l'expiration d'un TTL (TA_TTL_HOURS, 6 h) ; entre deux36#               passages, le connecteur réémet son cache. Cookie de détection37#               ColdFusion géré par la session (302 suivi). Lien d'achat =38#               la fiche officielle chez la source.39# -----------------------------------------------------------------------------40from __future__ import annotations4142import os43import re44import time4546from ..normalize import clean_text, parse_date_fr, parse_time47from ..schema import Event48from .base import BaseConnector4950PORTAL = "https://www.ticketacces.net/fr/portail/index.cfm"51CALENDAR = ("https://www.ticketacces.net/fr/portail/evenements/calendrier.cfm"52            "?RepresentationCategoriePrincipaleID={cat}")53CATEGORIES = {1: "Spectacles", 2: "Cinéma", 3: "Sport", 4: "Autre"}54# 47 organisations/hôtes au Québec, ≤ 1 fiche par hôte par passage → 47 fiches55# par défaut = un passage complet sans jamais attendre le Crawl-delay par hôte56DETAIL_MAX = int(os.environ.get("TA_DETAIL_MAX", "47"))57REFRESH_AFTER = 7 * 8640058DETAIL_V = 2                       # version du parseur de fiche (bump = re-visite)59TTL = float(os.environ.get("TA_TTL_HOURS", "6")) * 360060HOST_DELAY = 120.0                 # robots.txt : Crawl-delay: 120 (par hôte)6162_ORG_RE = re.compile(63    r'data-nom="([^"]*)"\s+data-ville="([^"]*)"\s+data-region="([^"]*)"\s+'64    r'data-domaine="([a-z0-9-]+)\.ticketacces\.net"')65_ROW_RE = re.compile(66    r'<a href="https://([a-z0-9-]+)\.ticketacces\.net/fr/organisation/'67    r'representations/index\.cfm\?EvenementID=(\d+)">'68    r'([^<]+)(?:<br\s*/?>([^<]*))?</a>\s*</h3>\s*<p>\s*'69    r'<strong>([^<]+)</strong>\s*<br\s*/?>\s*([^<]+?)\s*</p>', re.S)70_IMG_RE = re.compile(71    r'EvenementID=(\d+)">\s*<div class="image-crop[^"]*">\s*'72    r'<img src="([^"]+)"', re.S)73_OG_DESC_RE = re.compile(r'property="og:description" content="([^"]*)"')74_PRICE_RE = re.compile(r"(\d+(?:,\d{2}))\s*\$")75# adresse civique de la salle dans les blocs de représentation (« Théâtre de76# la Bordée | 315, rue Saint-Joseph Est, Québec ») : segment commençant par77# un numéro civique ET contenant un mot de voirie — la version 1 du parseur78# ne testait que le PREMIER segment numérique de la page (souvent un titre79# « 5 BALLES… » ou une durée « 1h40 ») → 3 adresses sur 317 fiches.80_ADDR_SEG_RE = re.compile(r"\|\s*(\d{1,5}(?:,)?\s[^|]{3,90}?)\s*\|")81_STREET_RE = re.compile(82    r"\b(rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|route|rang|place|"83    r"mont[ée]e|c[oô]te|carr[ée]|promenade|all[ée]e)\b", re.I)84# hors Québec (constaté 2026-08-21) : 12 organisations au Nouveau-Brunswick85# (comtés) + 1 en Ontario (« Ottawa et sa campagne » = Hawkesbury) — les86# libellés de région du portail n'utilisent pas les noms de provinces.87_EXCLUDE_REGIONS = (88    "comte de gloucester", "comte de madawaska", "comte de restigouche",89    "kent", "peninsule acadienne", "ottawa et sa campagne",90    "nouveau brunswick", "ontario",91)929394class TicketAccesConnector(BaseConnector):95    source_id = "ticketacces"96    request_delay = 1.0                # politesse entre HÔTES différents9798    def __init__(self) -> None:99        super().__init__()100        self._host_last: dict[str, float] = {}101102    def _get_cp1252(self, url: str) -> str:103        """GET décodé cp1252 (ColdFusion), Crawl-delay: 120 honoré PAR HÔTE."""104        host = url.split("/")[2]105        wait = HOST_DELAY - (time.time() - self._host_last.get(host, 0.0))106        if wait > 0:107            time.sleep(wait)108        resp = self.get(url)109        self._host_last[host] = time.time()110        return resp.content.decode("cp1252", errors="replace")111112    def _orgs_quebec(self) -> dict[str, dict]:113        """{sous-domaine: {nom, ville, region}} des organisations AU QUÉBEC."""114        html = self._get_cp1252(PORTAL)115        orgs: dict[str, dict] = {}116        from ..regions import _k117        for nom, ville, region, dom in _ORG_RE.findall(html):118            if _k(region) in _EXCLUDE_REGIONS:119                continue120            orgs[dom] = {"nom": clean_text(nom), "ville": clean_text(ville),121                         "region": clean_text(region)}122        return orgs123124    def _parse_detail(self, html: str) -> dict:125        desc = _OG_DESC_RE.search(html)126        # bloc coordonnées de la salle : « |La Chapelle| 620, avenue Plante| … »127        plain = re.sub(r"<[^>]+>", "|", html)128        plain = re.sub(r"[\t\r\n ]+", " ", plain)129        addr = ""130        for m in _ADDR_SEG_RE.finditer(plain):131            seg = m.group(1)132            if not _STREET_RE.search(seg):133                continue                      # « 1h40 Récit… », « 5 BALLES… »134            seg = clean_text(seg)135            # la source suffixe souvent la ville (« …, Québec ») : on la136            # retire si la fin n'a ni chiffre ni mot de voirie — l'Event137            # porte déjà la ville, geocode.py préfère l'adresse nue138            head, _, tail = seg.rpartition(",")139            if head and not any(c.isdigit() for c in tail) \140                    and not _STREET_RE.search(tail):141                seg = head.strip()142            addr = seg143            break144        # « 0,00 $ » = ligne tarifaire vide (constaté live 2026-08-25, lsq) —145        # jamais un prix plancher réel, on l'écarte (patron ticketpro c > 0)146        prices = [float(p.replace(",", ".")) for p in _PRICE_RE.findall(plain)]147        prices = [p for p in prices if p > 0]148        return {149            "description": clean_text(desc.group(1)) if desc else "",150            "address": addr,151            "price_min": min(prices) if prices else None,152        }153154    def fetch(self) -> list[Event]:155        cache = self.load_cache()156        now = time.time()157        meta = cache.get("_meta") or {}158        if now - meta.get("ts", 0) <= TTL and cache.get("_rows"):159            return self._emit(cache)             # cache frais : aucun réseau160161        orgs = self._orgs_quebec()162        if not orgs:163            raise RuntimeError("ticketacces : annuaire du portail vide")164165        rows: dict[str, dict] = {}                # clé de séance → ligne166        for cat, label in CATEGORIES.items():167            try:168                html = self._get_cp1252(CALENDAR.format(cat=cat))169            except Exception:170                print(f"[sorti-ka] ticketacces : calendrier {label} inaccessible")171                continue172            images = dict(_IMG_RE.findall(html))173            for org, evid, title, subtitle, date_raw, place in _ROW_RE.findall(html):174                if org not in orgs:               # hors Québec ou org inconnue175                    continue176                date_iso = parse_date_fr(date_raw)177                heure = parse_time(date_raw)178                if not date_iso:179                    continue180                venue, city = "", orgs[org]["ville"]181                place = clean_text(place)182                if "," in place:183                    venue, city = (p.strip() for p in place.rsplit(",", 1))184                elif place:185                    venue = place186                key = f"{org}-{evid}-{date_iso}-{heure or ''}"187                if key in rows:                   # le HTML responsive duplique188                    continue189                rows[key] = {190                    "external_id": key,191                    "org": org, "evid": evid,192                    "url": (f"https://{org}.ticketacces.net/fr/organisation/"193                            f"representations/index.cfm?EvenementID={evid}"),194                    "title": clean_text(title),195                    "subtitle": clean_text(subtitle or ""),196                    "raw_category": label,197                    "venue": venue, "city": city,198                    "region": orgs[org]["region"],199                    "organizer": orgs[org]["nom"],200                    "start_date": date_iso, "start_time": heure,201                    "image": images.get(evid, ""),202                }203204        # fiches détail incrémentales (description, adresse, prix plancher) —205        # Crawl-delay par hôte : au plus UNE fiche par organisation par passage206        fiches = {f"{r['org']}-{r['evid']}": (r["org"], r["url"])207                  for r in rows.values()}208        details = {k: v for k, v in cache.items()209                   if k not in ("_meta", "_rows") and k in fiches}210        # jamais vue > parseur périmé (v < DETAIL_V, patron lavitrine) > plus211        # ancienne — le bump de DETAIL_V re-visite progressivement le stock212        stale = sorted(213            (k for k in fiches214             if k not in details215             or details[k].get("v", 1) < DETAIL_V216             or now - details[k].get("ts", 0) > REFRESH_AFTER),217            key=lambda k: (details.get(k, {}).get("v", 1) if k in details else 0,218                           details.get(k, {}).get("ts", 0)))219        seen_hosts: set[str] = set()220        picked: list[str] = []221        for k in stale:222            org = fiches[k][0]223            if org in seen_hosts:224                continue225            seen_hosts.add(org)226            picked.append(k)227            if len(picked) >= DETAIL_MAX:228                break229        for k in picked:230            try:231                detail = self._parse_detail(self._get_cp1252(fiches[k][1]))232            except Exception:233                detail = {}234            details[k] = {"ts": now, "v": DETAIL_V, "detail": detail}235236        cache = dict(details)237        cache["_meta"] = {"ts": now}238        cache["_rows"] = list(rows.values())239        self.save_cache(cache)240        return self._emit(cache)241242    def _emit(self, cache: dict) -> list[Event]:243        events: list[Event] = []244        for r in cache.get("_rows", []):245            detail = cache.get(f"{r['org']}-{r['evid']}", {}).get("detail") or {}246            title = r["title"]247            if r["subtitle"]:248                title = f"{title} — {r['subtitle']}"249            events.append(Event(250                source=self.source_id,251                external_id=r["external_id"],252                url=r["url"],253                title=title,254                description=detail.get("description", ""),255                raw_categories=[r["raw_category"]],256                venue=r["venue"],257                address=detail.get("address", ""),258                city=r["city"],259                region=r["region"],           # gardée si déjà administrative260                tourist_region=r["region"],   # sinon Charlevoix/Bellechasse…261                start_date=r["start_date"],262                start_time=r["start_time"],263                price_min=detail.get("price_min"),264                organizer=r["organizer"],265                image=r["image"],266            ))267        return events268