# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lepointdevente.py : connecteur Le point de vente (lepointdevente.com) # Source : billetterie québécoise indépendante — concerts, humour, théâtre, # festivals, partout au Québec (~1 500 événements à venir). # Extraction: pages de liste HTML rendues serveur (/billets?page=N, paginé), # parse regex des cartes (titre, date FR, salle + ville, image). # Aucun rendu JS nécessaire. # Accès : robots.txt = « User-Agent: * / Allow: * » (scraping permis) ; # GET throttlés 0,6 s, identification honnête, ~65 requêtes/sync. # Détail : enrichissement incrémental par fiches /billets/ (modèle # lavitrine, version de parse « v ») — cache disque # data/lepointdevente_cache.json, plafond LPDV_DETAIL_MAX # fiches/sync (défaut 150), re-visite après 7 jours, throttle # poli 0,8 s. Chaque fiche donne : description (og:description), # heure précise (data-event-name), ADRESSE CIVIQUE du lieu # (microdonnées schema.org PostalAddress : streetAddress + # addressLocality, vérifié 2026-08-25 → géocodage aval, la # liste ne donnait ni adresse ni GPS) et l'id interne du widget # billetterie (data-reference) → tarifs JSON réels via # /plugins/rates/ (prix TTC affichés par la billetterie # elle-même : price_min / price_label / is_free si tous les # tarifs sont à 0 $). Jamais de prix inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from ..normalize import artists_from_title, clean_text, parse_date_fr, parse_time from ..schema import Event from .base import BaseConnector BASE = "https://lepointdevente.com" DETAIL_MAX = int(os.environ.get("LPDV_DETAIL_MAX", "150")) # fiches/sync REFRESH_AFTER = 7 * 86400 # re-visite d'une fiche (les tarifs évoluent) PARSE_V = 2 # bump → re-visite prioritaire des fiches < v2 _OG_DESC_RE = re.compile(r'property="og:description" content="([^"]*)"') _META_DESC_RE = re.compile(r'([^<]*)<') _MD_CITY_RE = re.compile(r'itemprop="addressLocality">([^<]*)<') _REFERENCE_RE = re.compile(r'data-model="event" data-reference="(\d+)"') _REFERENCE_ALT_RE = re.compile(r'data-event="(\d+)"') _EVENT_TIME_RE = re.compile(r'data-event-name="[^"]*\((\d{1,2}\s+\S+\s+\d{4})' r'\s+(\d{1,2}h\d{2})\)"') _CARD_RE = re.compile( r']*class="feature-link[^"]*".*?' r'(?:]*src="([^"]+)"[^>]*>.*?)?' r'

(?:]*>)?([^<]+)

\s*' r'
([^<]*)
\s*' r'
([^<]*)
', re.S) _PAGE_RE = re.compile(r'/billets\?page=(\d+)') class LePointDeVenteConnector(BaseConnector): source_id = "lepointdevente" request_delay = 0.8 def _parse_page(self, html: str) -> list[Event]: events = [] for m in _CARD_RE.finditer(html): path, image, title, date_txt, where = m.groups() # « Bar le Jockey, Montréal, QC » → salle = tout sauf ville + prov. parts = [p.strip() for p in (where or "").split(",")] city, venue = "", "" if parts: prov = parts[-1].upper() if prov in ("QC", "QUÉBEC", "QUEBEC"): parts = parts[:-1] elif prov in ("ON", "NB", "NS", "MB", "AB", "BC", "SK", "PE", "NL", "YT", "NT", "NU"): continue # hors Québec : hors périmètre Sorti-Ka if parts: city = parts[-1] venue = ", ".join(parts[:-1]) events.append(Event( source=self.source_id, external_id=path.rsplit("/", 1)[-1], url=BASE + path, title=title, # la liste ne donne pas la catégorie : heuristique sur le titre # (« Festival… », « …en concert ») ; « autre » si rien ne matche raw_categories=[title], venue=venue, city=city, start_date=parse_date_fr(date_txt), # la carte de liste donne déjà l'heure : « 17 août 2026, 20h00 » start_time=parse_time(date_txt), # patrons sûrs seulement (« Artiste - Salle », « X en concert ») artists=artists_from_title(title, venue), image=image or "", )) return events # -- enrichissement par fiches détail (prix réels, description, heure) ---- def _parse_detail(self, html: str) -> dict: """Extrait d'une fiche /billets/ : description, heure précise et id interne du widget billetterie (pour les tarifs).""" info: dict = {} m = _OG_DESC_RE.search(html) or _META_DESC_RE.search(html) if m: desc = clean_text(m.group(1)) # meta générique du site (fiche sans description propre) : ignorer if not desc.startswith("Découvrez une multitude d'événements"): info["description"] = desc m = _EVENT_TIME_RE.search(html) if m: # ex. « (29 août 2026 19h30) » → "19h30" info["time"] = m.group(2) m = _MD_STREET_RE.search(html) if m and clean_text(m.group(1)): info["address"] = clean_text(m.group(1)) m = _MD_CITY_RE.search(html) if m and clean_text(m.group(1)): info["city"] = clean_text(m.group(1)) m = _REFERENCE_RE.search(html) or _REFERENCE_ALT_RE.search(html) if m: info["reference"] = m.group(1) return info def _fetch_rates(self, reference: str) -> dict: """Tarifs réels affichés par la billetterie (/plugins/rates/). price_min = plus bas tarif payant ; is_free=True seulement si TOUS les tarifs sont à 0 $ ; les extras (addon: stationnement, etc.) sont exclus. price_label conserve le détail pour la traçabilité. """ data = self.get_json(f"{BASE}/plugins/rates/{reference}") rates = [r for r in data.get("rates") or [] if not r.get("addon")] prices = [float(r.get("price") or 0) for r in rates] if not prices: return {} labels = [f"{clean_text(r.get('name') or '')} " f"{float(r.get('price') or 0):g} $" for r in rates] label = " · ".join(labels[:5]) + (" · …" if len(labels) > 5 else "") if max(prices) == 0: return {"is_free": True, "price_min": None, "price_label": label} return {"is_free": False, "price_min": min(p for p in prices if p > 0), "price_label": label} def _enrich_details(self, events: list[Event]) -> None: """Fiches détail — incrémental (modèle lavitrine) : nouvelles fiches d'abord, puis fiches parsées avant la v2 (sans adresse civique — backfill progressif), puis re-visite roulante après 7 jours, plafonné à LPDV_DETAIL_MAX fiches/sync, séquentiel throttlé 0,8 s (2 GET par fiche : HTML + tarifs JSON). Événements à venir seulement.""" from datetime import date today = date.today().isoformat() cache = self.load_cache() current = {ev.url for ev in events} cache = {u: c for u, c in cache.items() if u in current} now = time.time() candidates = sorted( (ev.url for ev in events if (ev.start_date or today) >= today and (ev.url not in cache or cache[ev.url].get("v", 1) < PARSE_V or now - cache[ev.url].get("fetched_at", 0) > REFRESH_AFTER)), key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0, cache.get(u, {}).get("fetched_at", 0)))[:DETAIL_MAX] for url in candidates: try: info = self._parse_detail(self.get(url).text) if info.get("reference"): try: info.update(self._fetch_rates(info["reference"])) except Exception: pass # tarifs indisponibles : la fiche reste utile except Exception: # fiche cassée : on ne bloque pas la source print(f"[sorti-ka] lepointdevente : fiche ignorée {url}") info = {} info["fetched_at"] = now info["v"] = PARSE_V cache[url] = info self.save_cache(cache) for ev in events: info = cache.get(ev.url) or {} if info.get("description") and not ev.description: ev.description = info["description"] if info.get("time"): # heure précise de la fiche ("19h30") ev.start_time = info["time"] if info.get("address"): # adresse civique du lieu (microdonnées) ev.address = info["address"] if info.get("city") and not ev.city: ev.city = info["city"] if "is_free" in info: # tarifs réels de la billetterie ev.is_free = info["is_free"] ev.price_min = info.get("price_min") ev.price_label = info.get("price_label", "") def fetch(self) -> list[Event]: events: list[Event] = [] html = self.get(f"{BASE}/billets").text events += self._parse_page(html) pages = [int(p) for p in _PAGE_RE.findall(html)] last = max(pages) if pages else 1 for page in range(2, last + 1): html = self.get(f"{BASE}/billets?page={page}").text batch = self._parse_page(html) if not batch: # fin réelle de la pagination break events += batch # la pagination affichée peut grandir au fil des pages pages = [int(p) for p in _PAGE_RE.findall(html)] if pages: last = max(last, max(pages)) # dédup interne (un événement vedette apparaît aussi dans la liste) seen, unique = set(), [] for ev in events: if ev.external_id in seen: continue seen.add(ev.external_id) unique.append(ev) self._enrich_details(unique) return unique