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/lavitrine.py : connecteur La Vitrine culturelle (lavitrine.com)5# Source : billetterie/calendrier culturel — spectacles, expositions,6# concerts (Grand Montréal + Québec + tournées).7# Extraction: sitemap.xml public (~2 000 fiches /fr/evenement/ + 1858# /fr/exposition/, lastmod par URL) puis pages fiche rendues9# serveur (h1, bloc .event_calendar : jour/mois/année, HEURE10# (« 15:00 HNE » dans la slide), salle, ville ; og:image ;11# description = event_tabs_description-short + about-description).12# GET directs — pas d'anti-bot.13# INCRÉMENTAL avec cache disque (data/lavitrine_cache.json) :14# nouvelles fiches d'abord, re-visite roulante après 7 jours,15# plafonné à LV_MAX_FETCH fiches/sync.16# Accès : robots.txt « User-Agent: * / Allow: / » + Content-Signal17# « search=yes, use=reference » → usage index + lien vers la18# fiche originale, conforme aux signaux publiés (CLAUDE.md §15).19# Prix/GPS : les pages FICHE ne publient ni tarif, ni JSON-LD, ni adresse.20# MAIS le sitemap contient aussi ~1 000 pages « représentation »21# (/fr/evenement/<slug>/<ficheId>/<dateId>) qui publient un22# JSON-LD Event complet (vérifié live 2026-08-25, 14/14 pages :23# Place name + PostalAddress + GeoCoordinates 100 %, offers.price24# ~80 %) → parseur v3 : adresse civique, code postal, lat/lng et25# prix billetterie attachés à LA représentation correspondante26# (identifier = dateId de la slide), sans AUCUN GET de plus (ces27# pages étaient déjà téléchargées et ignorées). Les autres28# représentations restent au géocodage aval salle+ville29# (sortika/venues.py + geocode.py). isAccessibleForFree vaut30# false par défaut chez la source → jamais utilisé seul ;31# is_free=False seulement si un prix > 0 est publié.32# -----------------------------------------------------------------------------33from __future__ import annotations3435import re36import time3738from ..normalize import clean_text39from ..schema import Event40from .base import BaseConnector4142SITEMAP = "https://www.lavitrine.com/sitemap.xml"43LV_MAX_FETCH = int(__import__("os").environ.get("LV_MAX_FETCH", "400")) # fiches/sync (GET directs polis)44REFRESH_AFTER = 7 * 86400 # re-visite roulante d'une fiche (secondes)45PARSE_V = 3 # v2 : heure + description ; v3 : JSON-LD des pages représentation46 # (adresse/GPS/prix) → backfill prioritaire des entrées < v34748_LOC_RE = re.compile(r"<loc>([^<]+)</loc>\s*(?:<lastmod>([^<]+)</lastmod>)?")49_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.S)50_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)51_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')5253_MONTHS = {"jan": 1, "fév": 2, "fev": 2, "mar": 3, "avr": 4, "mai": 5,54 "juin": 6, "juil": 7, "aoû": 8, "aou": 8, "août": 8, "sep": 9,55 "oct": 10, "nov": 11, "déc": 12, "dec": 12}5657# une « slide » du calendrier : jour / mois / année, puis salle / ville58_DATE_RE = re.compile(59 r'calender-date-text-large">(\d{1,2})</div>'60 r'<div class="event_calendar_slide-calender-date-text-small">([^<]+)</div>'61 r'<div class="event_calendar_slide-calender-date-text-small">(\d{4})</div>')62_PLACE_RE = re.compile(r'class="text-style-2lines">([^<]*)</div><div>([^<]*)</div>')63# l'heure de la représentation, dans la même slide, entre la date et la salle :64# <div class="text-size-small text-height-1-2">15:00 HNE</div>65_TIME_RE = re.compile(r'class="text-size-small[^"]*">\s*(\d{1,2}[:h]\d{2})')66# description de la fiche : accroche courte + début du texte long (onglet À propos)67_DESC_SHORT_RE = re.compile(r'event_tabs_description-short">(.*?)</div>', re.S)68_DESC_LONG_RE = re.compile(r'id="about-description">(.*?)</div>', re.S)697071_SLIDE_A_RE = re.compile(72 r'<a[^>]*href="([^"]*)"[^>]*class="event_calendar_slide-content(.*?)</a>', re.S)7374_LD_SCRIPT_RE = re.compile(75 r'<script type="application/ld\+json">(.*?)</script>', re.S)767778def _ld_representations(html: str) -> dict[str, dict]:79 """JSON-LD Event des pages « représentation » du sitemap80 (…/<ficheId>/<dateId>) : {dateId → salle, adresse civique, code postal,81 lat/lng, prix billetterie}. `identifier` = le dateId de la slide82 correspondante du carrousel. Les pages fiche n'en publient pas (vérifié83 live 2026-08-25) → dict vide, aucun impact."""84 import json85 out: dict[str, dict] = {}86 for m in _LD_SCRIPT_RE.finditer(html):87 raw = m.group(1)88 try:89 data = json.loads(raw)90 except Exception:91 try: # caractères de contrôle bruts dans les descriptions92 data = json.loads(re.sub(r"[\x00-\x1f]", " ", raw))93 except Exception:94 continue95 for d in (data if isinstance(data, list) else [data]):96 if not isinstance(d, dict) or d.get("@type") != "Event":97 continue98 ident = str(d.get("identifier") or "")99 if not ident:100 continue101 loc = d.get("location") or {}102 adr = loc.get("address") or {}103 geo = loc.get("geo") or {}104 off = d.get("offers") or {}105 if isinstance(off, list):106 off = off[0] if off else {}107 try:108 price = float(off.get("price"))109 except (TypeError, ValueError):110 price = None111 out[ident] = {112 "venue": clean_text(loc.get("name") or ""),113 "address": clean_text(adr.get("streetAddress") or ""),114 "city": clean_text(adr.get("addressLocality") or ""),115 "postal_code": clean_text(adr.get("postalCode") or ""),116 "lat": geo.get("latitude"), "lng": geo.get("longitude"),117 # prix publié par la billetterie elle-même (souvent null tant118 # que la vente n'est pas ouverte : jamais inventé)119 "price": price if price and price > 0 else None,120 }121 return out122123124# date + heure LOCALES de la représentation courante, publiées dans le fil125# d'Ariane JSON-LD (« 17 sept 2026 | 20:00 HAE ») — le startDate de l'Event126# est en UTC et peut donc changer de jour, on ne l'utilise JAMAIS.127_CRUMB_DATE_RE = re.compile(128 r'"name":\s*"(\d{1,2})\s+([a-zA-Zûéà]+)\.?\s+(\d{4})'129 r'(?:\s*\|\s*(\d{1,2}:\d{2}))?[^"]*"')130131132def _month_num(month_txt: str) -> int | None:133 t = clean_text(month_txt).lower()134 return _MONTHS.get(t[:4].rstrip(".")) or _MONTHS.get(t[:3])135136137def _slides(html: str) -> list[tuple]:138 """Slides du calendrier : (id de fiche de la slide, id de représentation139 « dateId », url canonique, jour, mois, année, salle, ville, heure).140141 Le carrousel d'une fiche de tournée liste TOUTES les dates de la tournée,142 chacune avec l'id de SA fiche (/fr/evenement/<slug>/<ficheId>/<dateId>).143 On garde tout mais on identifie chaque représentation par l'id de sa144 slide : la même date vue depuis plusieurs fiches → même identifiant →145 aucun doublon à l'ingestion, aucune date perdue. Le dateId (dernier146 segment) sert de clé de jointure vers le JSON-LD Event des pages147 représentation (identifier = dateId — v3, jamais utilisé dans148 external_id : zéro churn).149 """150 out = []151 for m in _SLIDE_A_RE.finditer(html):152 href, block = m.group(1), m.group(2)153 parts = href.strip("/").split("/")154 # …/evenement/<slug>/<ficheId>/<dateId>155 slide_id = parts[-2] if len(parts) >= 2 and parts[-2].isdigit() else ""156 date_id = parts[-1] if slide_id and parts[-1].isdigit() else ""157 canon = "https://www.lavitrine.com/" + "/".join(parts[:-1]) if slide_id else ""158 d = _DATE_RE.search(block)159 if not d:160 continue161 p = _PLACE_RE.search(block)162 t = _TIME_RE.search(block)163 out.append((slide_id, date_id, canon, d.group(1), d.group(2), d.group(3),164 p.group(1) if p else "", p.group(2) if p else "",165 t.group(1).replace("h", ":") if t else None))166 return out167168169class LaVitrineConnector(BaseConnector):170 source_id = "lavitrine"171 request_delay = 0.8172173 def _fiches(self) -> list[str]:174 xml = self.get(SITEMAP).text175 urls = []176 for block in _URL_BLOCK_RE.findall(xml):177 m = _LOC_RE.search(block)178 if not m:179 continue180 url = m.group(1)181 if "/fr/evenement/" in url or "/fr/exposition/" in url:182 urls.append(url)183 return urls184185 def _parse_fiche(self, url: str, html: str) -> list[dict]:186 h1 = _H1_RE.search(html)187 title = clean_text(h1.group(1)) if h1 else ""188 if not title:189 return []190 img = _OG_IMG_RE.search(html)191 image = img.group(1) if img else ""192 # description : accroche courte de la fiche + début du texte long193 # (déjà dans le HTML téléchargé — Phase 2, gisement n° 1 de l'audit)194 d_short = _DESC_SHORT_RE.search(html)195 d_long = _DESC_LONG_RE.search(html)196 description = " ".join(p for p in (197 clean_text(d_short.group(1)) if d_short else "",198 clean_text(d_long.group(1)) if d_long else "") if p)[:2000]199 ext = url.rstrip("/").rsplit("/", 1)[-1] # id numérique stable200 parts = url.rstrip("/").split("/")201 # dernier segment non numérique = slug lisible (les URLs représentation202 # …/<slug>/<ficheId>/<dateId> donnaient « 22040 » en catégorie brute)203 slug = next((p for p in reversed(parts) if not p.isdigit()), "")204 rows = []205 for slide_id, date_id, canon, day, month_txt, year, venue, city, hhmm \206 in _slides(html)[:60]:207 mo = _month_num(month_txt)208 if not mo:209 continue210 iso = f"{int(year):04d}-{mo:02d}-{int(day):02d}"211 rows.append({212 # id de la slide (fiche propre à cette représentation) : la213 # même date vue depuis plusieurs fiches → même uid, zéro doublon214 "external_id": f"{slide_id or ext}-{iso}",215 "url": canon or url, "title": title, "image": image,216 "description": description,217 "raw_categories": [title, slug.replace("-", " ")],218 "venue": clean_text(venue or ""), "city": clean_text(city or ""),219 "start_date": iso, "end_date": iso,220 "start_time": hhmm, # heure de la slide (« 15:00 HNE »)221 })222 # page « représentation » du sitemap (…/<ficheId>/<dateId>) : son223 # JSON-LD Event décrit la représentation COURANTE — jamais listée224 # dans son propre carrousel (vérifié live 2026-08-25) → on émet son225 # occurrence à part : adresse/GPS/prix du JSON-LD, date+heure LOCALES226 # du fil d'Ariane. À l'émission, cette version enrichie remplace la227 # slide nue vue depuis les autres fiches (même external_id).228 info = (_ld_representations(html).get(parts[-1])229 if len(parts) >= 2 and parts[-1].isdigit()230 and parts[-2].isdigit() else None)231 crumb = _CRUMB_DATE_RE.search(html) if info else None232 mo = _month_num(crumb.group(2)) if crumb else None233 if info and crumb and mo:234 iso = f"{int(crumb.group(3)):04d}-{mo:02d}-{int(crumb.group(1)):02d}"235 price = info.get("price")236 rows.append({237 "external_id": f"{parts[-2]}-{iso}", # même schéma que les slides238 "url": "/".join(parts[:-1]), # fiche canonique239 "title": title, "image": image,240 "description": description,241 "raw_categories": [title, slug.replace("-", " ")],242 "venue": info.get("venue", ""), "city": info.get("city", ""),243 "address": info.get("address", ""),244 "postal_code": info.get("postal_code", ""),245 "lat": info.get("lat"), "lng": info.get("lng"),246 "start_date": iso, "end_date": iso,247 "start_time": crumb.group(4),248 "price_min": price,249 "price_label": (f"{price:.2f} $ (billetterie lavitrine.com)"250 if price else ""),251 })252 return rows253254 def fetch(self) -> list[Event]:255 cache = self.load_cache()256 urls = self._fiches()257 current = set(urls)258 cache = {u: c for u, c in cache.items() if u in current}259260 now = time.time()261 # nouvelles fiches d'abord, puis celles parsées avant PARSE_V262 # (v2 sans adresse/GPS/prix — montée progressive), puis les263 # plus anciennes visites (roulant)264 candidates = sorted(265 (u for u in urls266 if u not in cache or cache[u].get("v", 1) < PARSE_V267 or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),268 key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,269 # pages représentation d'abord : seules à livrer270 # adresse/GPS/prix (JSON-LD v3)271 0 if re.search(r"/\d+/\d+/?$", u) else 1,272 cache.get(u, {}).get("fetched_at", 0)))[:LV_MAX_FETCH]273274 def worker(url, session):275 return self._parse_fiche(url, session.get(url, timeout=20).text)276277 for url, rows in self.fetch_many(candidates, worker, max_workers=4).items():278 if rows is None: # fiche cassée : on ne bloque pas la source279 print(f"[sorti-ka] lavitrine : fiche ignorée {url}")280 continue281 cache[url] = {"fetched_at": now, "rows": rows, "v": PARSE_V}282 self.save_cache(cache)283284 # même slide vue depuis plusieurs pages (fiche de tournée + page285 # représentation) : la version enrichie JSON-LD (adresse) l'emporte286 best: dict[str, dict] = {}287 for entry in cache.values():288 for row in entry.get("rows", []):289 cur = best.get(row["external_id"])290 if cur is None or (not cur.get("address") and row.get("address")):291 best[row["external_id"]] = row292 events: list[Event] = []293 for row in best.values():294 events.append(Event(295 source=self.source_id,296 external_id=row["external_id"],297 url=row["url"], title=row["title"],298 description=row.get("description", ""),299 raw_categories=row.get("raw_categories") or [],300 venue=row.get("venue", ""), city=row.get("city", ""),301 address=row.get("address", ""),302 postal_code=row.get("postal_code", ""),303 lat=row.get("lat"), lng=row.get("lng"),304 start_date=row.get("start_date"),305 start_time=row.get("start_time"),306 end_date=row.get("end_date"),307 price_min=row.get("price_min"),308 price_label=row.get("price_label", ""),309 is_free=False if row.get("price_min") else None,310 image=row.get("image", ""),311 ))312 return events313