Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
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