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/eventbrite.py : connecteur Eventbrite (Québec) — SANS clé5# Source : eventbrite.ca — billetterie libre-service (communautaire,6# ateliers, festivals, affaires, vie nocturne...).7# Extraction: pages de découverte publiques /d/<lieu>/all-events/?page=N —8# chaque page embarque window.__SERVER_DATA__ avec les résultats9# de recherche COMPLETS (20 événements/page) : nom, résumé,10# date + heure locales, salle géocodée (lat/lng, adresse, code11# postal), image, catégories (tags Eventbrite*), URL. Vérifié12# 2026-08-18 : Montréal 3 341 événements (49 pages exposées).13# Prix : fiches détail /e/<slug> incrémentales (JSON-LD14# AggregateOffer lowPrice/highPrice CAD) avec cache disque15# data/eventbrite_cache.json, plafond EB_DETAIL_MAX fiches/sync16# (défaut 150), re-visite 7 jours — patron La Vitrine.17# Accès : robots.txt « User-agent: * » n'interdit ni /d/ ni /e/ (seuls18# /directory/, checkout et endpoints privés le sont) — GET19# directs throttlés, identification honnête, lien direct vers20# chaque fiche Eventbrite. Filtre region=QC ; en ligne exclus.21# Prix : uniquement l'AggregateOffer publié par Eventbrite — jamais22# inventé (gratuit seulement si high == 0).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import json27import os28import re29import time3031from ..schema import Event32from .base import BaseConnector3334# lieux QC crawlés (slug découverte, budget de pages de liste par sync) —35# pondération par taille de marché ; ~30 GET de liste/sync ≈ 600 fiches36PLACES: tuple[tuple[str, int], ...] = (37 ("canada--montreal", 10),38 ("canada--qu%C3%A9bec", 6), # ville de Québec39 ("canada--laval", 3),40 ("canada--gatineau", 3),41 ("canada--sherbrooke", 3),42 ("canada--longueuil", 2),43 ("canada--trois-rivi%C3%A8res", 2),44)45LIST_URL = "https://www.eventbrite.ca/d/{place}/all-events/?page={page}"46EB_DETAIL_MAX = int(os.environ.get("EB_DETAIL_MAX", "150")) # fiches prix/sync47REFRESH_AFTER = 7 * 86400 # re-visite d'une fiche prix (secondes)4849_LD_JSON_RE = re.compile(r"application/ld\+json[^>]*>")505152def _server_data(html: str) -> dict:53 i = html.find("__SERVER_DATA__")54 if i < 0:55 raise ValueError("__SERVER_DATA__ introuvable")56 start = html.find("{", i)57 data, _ = json.JSONDecoder().raw_decode(html[start:])58 return data596061def _parse_detail(html: str) -> dict:62 """JSON-LD d'une fiche : AggregateOffer (lowPrice/highPrice/priceCurrency)63 + organisateur (organizer.name) + description complète — même requête."""64 price, organizer, description = None, "", ""65 for m in _LD_JSON_RE.finditer(html):66 try:67 d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip())68 except Exception:69 continue70 for doc in (d if isinstance(d, list) else [d]):71 if not isinstance(doc, dict):72 continue73 org = doc.get("organizer")74 if isinstance(org, dict) and org.get("name") and not organizer:75 organizer = str(org["name"])76 desc = doc.get("description")77 if isinstance(desc, str) and len(desc) > len(description):78 description = desc[:2000]79 offers = doc.get("offers")80 if not offers or price is not None:81 continue82 for off in (offers if isinstance(offers, list) else [offers]):83 if not isinstance(off, dict) or off.get("@type") != "AggregateOffer":84 continue85 try:86 low = float(off.get("lowPrice"))87 high = float(off.get("highPrice") or off.get("lowPrice"))88 except (TypeError, ValueError):89 continue90 price = {"low": low, "high": high,91 "cur": off.get("priceCurrency") or ""}92 break93 return {"price": price, "organizer": organizer, "description": description}949596class EventbriteConnector(BaseConnector):97 source_id = "eventbrite"98 request_delay = 0.899100 # -- pages de liste --------------------------------------------------------101 def _list_results(self) -> list[dict]:102 results: list[dict] = []103 for place, budget in PLACES:104 page_count = budget105 for page in range(1, budget + 1):106 if page > page_count:107 break108 try:109 data = _server_data(self.get(110 LIST_URL.format(place=place, page=page)).text)111 ev = ((data.get("search_data") or {}).get("events") or {})112 page_count = min(budget, int(113 (ev.get("pagination") or {}).get("page_count") or 0))114 results.extend(ev.get("results") or [])115 except Exception as exc: # un lieu cassé ne bloque pas la source116 print(f"[sorti-ka] eventbrite : {place} page {page} "117 f"ignorée : {exc}")118 break119 return results120121 # -- fiche → Event ---------------------------------------------------------122 def _result_to_event(self, r: dict, detail: dict | None) -> Event | None:123 ext_id = str(r.get("id") or r.get("eventbrite_event_id") or "")124 title = r.get("name") or ""125 if not ext_id or not title:126 return None127 if r.get("is_online_event"):128 return None129 venue = r.get("primary_venue") or {}130 addr = venue.get("address") or {}131 if (addr.get("region") or "") != "QC": # filtre Québec strict132 return None133 # statut billetterie (Phase 2) : annulé (is_cancelled — l'événement est134 # conservé et affiché barré/bandeau) ; complet via urgency_signals135 # (catégorie/message « sold out » explicite seulement, jamais déduit).136 status = "cancelled" if r.get("is_cancelled") else ""137 if not status:138 us = r.get("urgency_signals") or {}139 signals = " ".join([*(us.get("messages") or []),140 *(us.get("categories") or [])]).lower()141 if "sold" in signals and "out" in signals:142 status = "soldout"143 price = (detail or {}).get("price")144 organizer = (detail or {}).get("organizer") or ""145 # description : full_description (vide dans le flux de liste, constaté146 # 2026-08-19) > JSON-LD de la fiche détail > summary (140 caractères)147 description = (r.get("full_description")148 or (detail or {}).get("description")149 or r.get("summary") or "")150 cats: list[str] = []151 for t in r.get("tags") or []:152 if (t.get("prefix") or "").startswith("Eventbrite"):153 dn = ((t.get("localized") or {}).get("display_name")154 or t.get("display_name") or "")155 if dn and dn.lower() != "other" and dn not in cats:156 cats.append(dn)157 img = (((r.get("image") or {}).get("original") or {}).get("url")158 or (r.get("image") or {}).get("url") or "")159160 is_free, price_min, label = None, None, ""161 if price and price.get("cur") in ("CAD", ""):162 low, high = price["low"], price["high"]163 if high <= 0:164 is_free, label = True, "Gratuit (Eventbrite)"165 elif low <= 0: # options gratuites ET payantes166 label = f"0 $ à {high:g} $ (Eventbrite)"167 else:168 is_free, price_min = False, low169 label = (f"{low:g} $ à {high:g} $ (Eventbrite)"170 if high > low else f"{low:g} $ (Eventbrite)")171172 return Event(173 source=self.source_id,174 external_id=ext_id,175 url=r.get("url") or "",176 title=title,177 description=description,178 raw_categories=cats,179 venue=venue.get("name") or "",180 address=addr.get("address_1") or "",181 city=addr.get("city") or "",182 postal_code=addr.get("postal_code") or "",183 lat=addr.get("latitude"),184 lng=addr.get("longitude"),185 start_date=r.get("start_date"),186 start_time=r.get("start_time"),187 end_date=r.get("end_date"),188 end_time=r.get("end_time"),189 status=status,190 is_free=is_free,191 price_min=price_min,192 price_label=label,193 organizer=organizer,194 image=img,195 )196197 # -- enrichissement prix + organisateur (incrémental, plafonné) ------------198 def _enrich_details(self, rows: list[dict]) -> dict:199 """Cache {event_id: {fetched_at, price|None, organizer}} ; fiches200 jamais visitées d'abord, puis celles d'avant la Phase 2 (sans clé201 organizer), puis re-visite roulante après 7 jours."""202 cache = self.load_cache()203 now = time.time()204 current = {str(r.get("id")): r for r in rows if r.get("id")}205 cache = {k: v for k, v in cache.items() if k in current}206207 candidates = sorted(208 (eid for eid, r in current.items()209 if r.get("url")210 and (eid not in cache211 or "organizer" not in cache[eid]212 or "description" not in cache[eid]213 or now - cache[eid].get("fetched_at", 0) > REFRESH_AFTER)),214 key=lambda eid: cache.get(eid, {}).get("fetched_at", 0))[:EB_DETAIL_MAX]215 url_to_id = {current[eid]["url"]: eid for eid in candidates}216217 def worker(url, session):218 return _parse_detail(session.get(url, timeout=20).text)219220 for url, detail in self.fetch_many(list(url_to_id), worker,221 max_workers=4).items():222 entry = {"fetched_at": now} | (detail or {223 "price": None, "organizer": "", "description": ""})224 cache[url_to_id[url]] = entry225 self.save_cache(cache)226 return cache227228 # -- interface --------------------------------------------------------------229 def fetch(self) -> list[Event]:230 rows = self._list_results()231 # dédoublonnage inter-lieux (un même événement sort dans plusieurs villes)232 uniq: dict[str, dict] = {}233 for r in rows:234 eid = str(r.get("id") or "")235 if eid:236 uniq.setdefault(eid, r)237 details = self._enrich_details(list(uniq.values()))238 events: list[Event] = []239 for eid, r in uniq.items():240 ev = self._result_to_event(r, details.get(eid))241 if ev is not None:242 events.append(ev)243 return events244