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%
10.9 KB · 228 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/lepointdevente.py : connecteur Le point de vente (lepointdevente.com)5#   Source    : billetterie québécoise indépendante — concerts, humour, théâtre,6#               festivals, partout au Québec (~1 500 événements à venir).7#   Extraction: pages de liste HTML rendues serveur (/billets?page=N, paginé),8#               parse regex des cartes (titre, date FR, salle + ville, image).9#               Aucun rendu JS nécessaire.10#   Accès     : robots.txt = « User-Agent: * / Allow: * » (scraping permis) ;11#               GET throttlés 0,6 s, identification honnête, ~65 requêtes/sync.12#   Détail    : enrichissement incrémental par fiches /billets/<id> (modèle13#               lavitrine, version de parse « v ») — cache disque14#               data/lepointdevente_cache.json, plafond LPDV_DETAIL_MAX15#               fiches/sync (défaut 150), re-visite après 7 jours, throttle16#               poli 0,8 s. Chaque fiche donne : description (og:description),17#               heure précise (data-event-name), ADRESSE CIVIQUE du lieu18#               (microdonnées schema.org PostalAddress : streetAddress +19#               addressLocality, vérifié 2026-08-25 → géocodage aval, la20#               liste ne donnait ni adresse ni GPS) et l'id interne du widget21#               billetterie (data-reference) → tarifs JSON réels via22#               /plugins/rates/<id> (prix TTC affichés par la billetterie23#               elle-même : price_min / price_label / is_free si tous les24#               tarifs sont à 0 $). Jamais de prix inventé.25# -----------------------------------------------------------------------------26from __future__ import annotations2728import os29import re30import time3132from ..normalize import artists_from_title, clean_text, parse_date_fr, parse_time33from ..schema import Event34from .base import BaseConnector3536BASE = "https://lepointdevente.com"3738DETAIL_MAX = int(os.environ.get("LPDV_DETAIL_MAX", "150"))  # fiches/sync39REFRESH_AFTER = 7 * 86400     # re-visite d'une fiche (les tarifs évoluent)40PARSE_V = 2                   # bump → re-visite prioritaire des fiches < v24142_OG_DESC_RE = re.compile(r'property="og:description" content="([^"]*)"')43_META_DESC_RE = re.compile(r'<meta name="description" content="([^"]*)"')44# adresse civique du lieu (microdonnées schema.org Place/PostalAddress de la45# fiche, bloc .event-header-venue — vérifié 2026-08-25) : le géocodage aval46# (sortika/geocode.py) privilégie l'adresse civique.47_MD_STREET_RE = re.compile(r'itemprop="streetAddress">([^<]*)<')48_MD_CITY_RE = re.compile(r'itemprop="addressLocality">([^<]*)<')49_REFERENCE_RE = re.compile(r'data-model="event" data-reference="(\d+)"')50_REFERENCE_ALT_RE = re.compile(r'data-event="(\d+)"')51_EVENT_TIME_RE = re.compile(r'data-event-name="[^"]*\((\d{1,2}\s+\S+\s+\d{4})'52                            r'\s+(\d{1,2}h\d{2})\)"')5354_CARD_RE = re.compile(55    r'<a href="(/billets/[^"]+)"[^>]*class="feature-link[^"]*".*?'56    r'(?:<img[^>]*src="([^"]+)"[^>]*>.*?)?'57    r'<h3 class="feature-title">(?:<div[^>]*></div>)?([^<]+)</h3>\s*'58    r'<div class="feature-date">([^<]*)</div>\s*'59    r'<div class="feature-city">([^<]*)</div>',60    re.S)6162_PAGE_RE = re.compile(r'/billets\?page=(\d+)')636465class LePointDeVenteConnector(BaseConnector):66    source_id = "lepointdevente"67    request_delay = 0.86869    def _parse_page(self, html: str) -> list[Event]:70        events = []71        for m in _CARD_RE.finditer(html):72            path, image, title, date_txt, where = m.groups()73            # « Bar le Jockey, Montréal, QC » → salle = tout sauf ville + prov.74            parts = [p.strip() for p in (where or "").split(",")]75            city, venue = "", ""76            if parts:77                prov = parts[-1].upper()78                if prov in ("QC", "QUÉBEC", "QUEBEC"):79                    parts = parts[:-1]80                elif prov in ("ON", "NB", "NS", "MB", "AB", "BC", "SK",81                              "PE", "NL", "YT", "NT", "NU"):82                    continue          # hors Québec : hors périmètre Sorti-Ka83            if parts:84                city = parts[-1]85                venue = ", ".join(parts[:-1])86            events.append(Event(87                source=self.source_id,88                external_id=path.rsplit("/", 1)[-1],89                url=BASE + path,90                title=title,91                # la liste ne donne pas la catégorie : heuristique sur le titre92                # (« Festival… », « …en concert ») ; « autre » si rien ne matche93                raw_categories=[title],94                venue=venue,95                city=city,96                start_date=parse_date_fr(date_txt),97                # la carte de liste donne déjà l'heure : « 17 août 2026, 20h00 »98                start_time=parse_time(date_txt),99                # patrons sûrs seulement (« Artiste - Salle », « X en concert »)100                artists=artists_from_title(title, venue),101                image=image or "",102            ))103        return events104105    # -- enrichissement par fiches détail (prix réels, description, heure) ----106    def _parse_detail(self, html: str) -> dict:107        """Extrait d'une fiche /billets/<id> : description, heure précise et108        id interne du widget billetterie (pour les tarifs)."""109        info: dict = {}110        m = _OG_DESC_RE.search(html) or _META_DESC_RE.search(html)111        if m:112            desc = clean_text(m.group(1))113            # meta générique du site (fiche sans description propre) : ignorer114            if not desc.startswith("Découvrez une multitude d'événements"):115                info["description"] = desc116        m = _EVENT_TIME_RE.search(html)117        if m:                     # ex. « (29 août 2026 19h30) » → "19h30"118            info["time"] = m.group(2)119        m = _MD_STREET_RE.search(html)120        if m and clean_text(m.group(1)):121            info["address"] = clean_text(m.group(1))122        m = _MD_CITY_RE.search(html)123        if m and clean_text(m.group(1)):124            info["city"] = clean_text(m.group(1))125        m = _REFERENCE_RE.search(html) or _REFERENCE_ALT_RE.search(html)126        if m:127            info["reference"] = m.group(1)128        return info129130    def _fetch_rates(self, reference: str) -> dict:131        """Tarifs réels affichés par la billetterie (/plugins/rates/<id>).132133        price_min = plus bas tarif payant ; is_free=True seulement si TOUS134        les tarifs sont à 0 $ ; les extras (addon: stationnement, etc.) sont135        exclus. price_label conserve le détail pour la traçabilité.136        """137        data = self.get_json(f"{BASE}/plugins/rates/{reference}")138        rates = [r for r in data.get("rates") or [] if not r.get("addon")]139        prices = [float(r.get("price") or 0) for r in rates]140        if not prices:141            return {}142        labels = [f"{clean_text(r.get('name') or '')} "143                  f"{float(r.get('price') or 0):g} $" for r in rates]144        label = " · ".join(labels[:5]) + (" · …" if len(labels) > 5 else "")145        if max(prices) == 0:146            return {"is_free": True, "price_min": None, "price_label": label}147        return {"is_free": False, "price_min": min(p for p in prices if p > 0),148                "price_label": label}149150    def _enrich_details(self, events: list[Event]) -> None:151        """Fiches détail — incrémental (modèle lavitrine) : nouvelles fiches152        d'abord, puis fiches parsées avant la v2 (sans adresse civique —153        backfill progressif), puis re-visite roulante après 7 jours, plafonné154        à LPDV_DETAIL_MAX fiches/sync, séquentiel throttlé 0,8 s (2 GET par155        fiche : HTML + tarifs JSON). Événements à venir seulement."""156        from datetime import date157        today = date.today().isoformat()158        cache = self.load_cache()159        current = {ev.url for ev in events}160        cache = {u: c for u, c in cache.items() if u in current}161162        now = time.time()163        candidates = sorted(164            (ev.url for ev in events165             if (ev.start_date or today) >= today166             and (ev.url not in cache167                  or cache[ev.url].get("v", 1) < PARSE_V168                  or now - cache[ev.url].get("fetched_at", 0) > REFRESH_AFTER)),169            key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,170                           cache.get(u, {}).get("fetched_at", 0)))[:DETAIL_MAX]171172        for url in candidates:173            try:174                info = self._parse_detail(self.get(url).text)175                if info.get("reference"):176                    try:177                        info.update(self._fetch_rates(info["reference"]))178                    except Exception:179                        pass      # tarifs indisponibles : la fiche reste utile180            except Exception:     # fiche cassée : on ne bloque pas la source181                print(f"[sorti-ka] lepointdevente : fiche ignorée {url}")182                info = {}183            info["fetched_at"] = now184            info["v"] = PARSE_V185            cache[url] = info186        self.save_cache(cache)187188        for ev in events:189            info = cache.get(ev.url) or {}190            if info.get("description") and not ev.description:191                ev.description = info["description"]192            if info.get("time"):      # heure précise de la fiche ("19h30")193                ev.start_time = info["time"]194            if info.get("address"):   # adresse civique du lieu (microdonnées)195                ev.address = info["address"]196            if info.get("city") and not ev.city:197                ev.city = info["city"]198            if "is_free" in info:     # tarifs réels de la billetterie199                ev.is_free = info["is_free"]200                ev.price_min = info.get("price_min")201                ev.price_label = info.get("price_label", "")202203    def fetch(self) -> list[Event]:204        events: list[Event] = []205        html = self.get(f"{BASE}/billets").text206        events += self._parse_page(html)207        pages = [int(p) for p in _PAGE_RE.findall(html)]208        last = max(pages) if pages else 1209        for page in range(2, last + 1):210            html = self.get(f"{BASE}/billets?page={page}").text211            batch = self._parse_page(html)212            if not batch:        # fin réelle de la pagination213                break214            events += batch215            # la pagination affichée peut grandir au fil des pages216            pages = [int(p) for p in _PAGE_RE.findall(html)]217            if pages:218                last = max(last, max(pages))219        # dédup interne (un événement vedette apparaît aussi dans la liste)220        seen, unique = set(), []221        for ev in events:222            if ev.external_id in seen:223                continue224            seen.add(ev.external_id)225            unique.append(ev)226        self._enrich_details(unique)227        return unique228