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%
11.0 KB · 244 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/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