# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/zeffy.py : connecteur Zeffy (zeffy.com) — billetterie OSBL # Source : billetterie gratuite très utilisée par les OSBL québécois # (galas, spectacles-bénéfice, marches, ateliers communautaires). # Extraction: 1) sitemap des organisations (api.zeffy.com/sitemap/ # organizations/sitemap-0.xml) → pages d'organisation fr-CA/en-CA # (~4 400, dont ~1 300 fr-CA) ; 2) chaque page d'organisation est # rendue serveur (Next.js __NEXT_DATA__ → organizationPage. # upcomingEvents : titre, description, adresse postale complète, # occurrences startUtc/endUtc, bannière, URL de billetterie) — # AUCUN rendu JS requis ; 3) fiches billetterie des événements QC # retenus : JSON-LD (offers[].price) → tarifs réels publiés. # Périmètre QUÉBEC : on ne garde que les événements datés dont # l'adresse porte « , QC » (les organisations fr-CA/en-CA # couvrent tout le Canada). Le sitemap des BILLETS (55 000+ URLs # fr-CA mêlant dons/adhésions/archives, sans dates) est ignoré : # la page d'organisation est la seule surface « à venir » fiable. # INCRÉMENTAL avec cache disque (data/zeffy_cache.json) : # nouvelles organisations d'abord, re-visite après 7 jours, # plafonds ZEFFY_ORG_MAX pages org et ZEFFY_DETAIL_MAX fiches # prix par sync. # Accès : robots.txt « User-agent: * / Disallow: /*=$ » (seules les URLs # finissant par « = » sont exclues — aucune de celles qu'on # visite) + sitemaps publiés ; GET throttlés, identification # honnête, pas d'anti-bot constaté (sondé 2026-08-25). # Prix : offers[].price du JSON-LD de la fiche (tarifs affichés par # l'organisme ; Zeffy suggère un pourboire plateforme au paiement, # non inclus). is_free=True si TOUS les tarifs publiés sont à 0 $. # Jamais inventé (événement sans fiche lisible → prix inconnu). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text, utc_to_local from ..schema import Event from .base import BaseConnector ORG_SITEMAP = "https://api.zeffy.com/sitemap/organizations/sitemap-0.xml" LOCALES = tuple(l.strip() for l in os.environ.get( "ZEFFY_LOCALES", "fr-CA,en-CA").split(",") if l.strip()) ORG_MAX = int(os.environ.get("ZEFFY_ORG_MAX", "400")) DETAIL_MAX = int(os.environ.get("ZEFFY_DETAIL_MAX", "80")) REFRESH_AFTER = 7 * 86400 _LOC_RE = re.compile(r"([^<]+)") _NEXT_RE = re.compile(r'__NEXT_DATA__"[^>]*>(\{.*?\})', re.S) _LD_RE = re.compile(r'', re.S) # adresse Zeffy : « 69 Rue Wellington N, Sherbrooke, QC J1H 5A9, Canada » _QC_PART_RE = re.compile(r"^(?:QC|Québec|Quebec)\b\s*(.*)$") def _split_address(raw: str) -> tuple[str, str, str, str] | None: """(rue, ville, code postal, adresse nettoyée) si l'adresse est au Québec, sinon None — le filtre de périmètre provincial du connecteur.""" parts = [p.strip() for p in (raw or "").split(",") if p.strip()] for i, part in enumerate(parts): m = _QC_PART_RE.match(part) if m and i >= 1: street = ", ".join(parts[:i - 1]) city = parts[i - 1] postal = m.group(1).strip() return street, city, postal, raw.strip() return None def _field_fr(fields: list[dict] | None) -> dict: """ticketingFields : privilégie la variante FR, sinon la première.""" fields = fields or [] for f in fields: if str(f.get("locale") or "").upper().startswith("FR"): return f return fields[0] if fields else {} class ZeffyConnector(BaseConnector): source_id = "zeffy" request_delay = 0.8 # -- découverte (sitemap des organisations) ------------------------------- def _org_urls(self) -> list[str]: xml = self.get(ORG_SITEMAP).text return sorted({ u for u in _LOC_RE.findall(xml) if any(f"/{loc}/organizations/" in u for loc in LOCALES)}) # -- page d'organisation --------------------------------------------------- def _parse_org(self, url: str, html: str) -> list[dict]: m = _NEXT_RE.search(html) if not m: return [] try: page = (json.loads(m.group(1)).get("props", {}) .get("pageProps", {}).get("organizationPage")) or {} except ValueError: return [] org_name = clean_text((page.get("organization") or {}).get("name") or "") rows: list[dict] = [] for ev in page.get("upcomingEvents") or []: if ev.get("formCategory") != "Event": continue # dons, adhésions, tirages : hors périmètre qc = _split_address(str(ev.get("address") or "")) if not qc: continue # sans adresse québécoise : hors périmètre street, city, postal, _ = qc field = _field_fr(ev.get("ticketingFields")) title = clean_text(field.get("title") or "") ticket_url = ev.get("url") or "" if not (title and ticket_url): continue for occ in ev.get("occurrences") or []: start_date, start_time = utc_to_local(occ.get("startUtc")) end_date, end_time = utc_to_local(occ.get("endUtc")) if not start_date: continue # occurrence sans date publiée : rejetée rows.append({ "external_id": f"{ev.get('id')}:{occ.get('id')}", "url": ticket_url, "title": title, "description": clean_text(field.get("description") or "")[:2000], "raw_categories": [title], "address": street, "city": city, "postal_code": postal, "start_date": start_date, "start_time": start_time, "end_date": end_date, "end_time": end_time, "organizer": org_name, "image": ev.get("bannerUrl") or "", }) return rows # -- fiche billetterie (tarifs publiés) ------------------------------------ def _parse_fiche_prices(self, html: str) -> dict | None: """JSON-LD de la fiche → {is_free, price_min, price_label}, ou None.""" for blob in _LD_RE.findall(html): try: d = json.loads(blob.strip()) except ValueError: continue offers = d.get("offers") if not isinstance(offers, list) or not offers: continue prices, labels = [], [] for o in offers: try: p = float(o.get("price")) except (TypeError, ValueError): continue prices.append(p) labels.append(f"{clean_text(o.get('name') or '')} {p:g} $") if not prices: continue 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} return None # -- pipeline --------------------------------------------------------------- def fetch(self) -> list[Event]: cache = self.load_cache() try: org_urls = self._org_urls() except Exception as exc: # sitemap indisponible → on garde le cache print(f"[sorti-ka] zeffy : sitemap organisations indisponible : {exc}") org_urls = [] if org_urls: current = {f"org::{u}" for u in org_urls} cache = {k: v for k, v in cache.items() if not k.startswith("org::") or k in current} else: org_urls = [k[5:] for k in cache if k.startswith("org::")] now = time.time() candidates = sorted( (u for u in org_urls if f"org::{u}" not in cache or now - cache[f"org::{u}"].get("fetched_at", 0) > REFRESH_AFTER), key=lambda u: cache.get(f"org::{u}", {}).get("fetched_at", 0) )[:ORG_MAX] def worker(url, session): return self._parse_org(url, session.get(url, timeout=30).text) for url, rows in self.fetch_many(candidates, worker, max_workers=4).items(): if rows is None: # page cassée : on garde l'état précédent rows = cache.get(f"org::{url}", {}).get("rows", []) cache[f"org::{url}"] = {"fetched_at": now, "rows": rows} # lignes courantes (dédupliquées inter-organisations) rows_by_id: dict[str, dict] = {} for key, entry in cache.items(): if not key.startswith("org::"): continue for row in entry.get("rows", []): rows_by_id.setdefault(row["external_id"], row) # enrichissement prix : fiches billetterie des événements QC retenus ticket_urls = sorted({r["url"] for r in rows_by_id.values()}) to_fetch = [u for u in ticket_urls if f"fiche::{u}" not in cache or now - cache[f"fiche::{u}"].get("fetched_at", 0) > REFRESH_AFTER][:DETAIL_MAX] def price_worker(url, session): return self._parse_fiche_prices(session.get(url, timeout=30).text) for url, prices in self.fetch_many(to_fetch, price_worker, max_workers=4).items(): if prices is None: # fiche illisible : on garde l'existant prices = cache.get(f"fiche::{url}", {}).get("prices") cache[f"fiche::{url}"] = {"fetched_at": now, "prices": prices} # purge des fiches prix orphelines (événements disparus) keep = {f"fiche::{u}" for u in ticket_urls} cache = {k: v for k, v in cache.items() if not k.startswith("fiche::") or k in keep} self.save_cache(cache) events: list[Event] = [] for row in rows_by_id.values(): prices = (cache.get(f"fiche::{row['url']}", {}).get("prices") or {}) events.append(Event( source=self.source_id, external_id=row["external_id"], url=row["url"], title=row["title"], description=row.get("description", ""), raw_categories=row.get("raw_categories") or [], address=row.get("address", ""), city=row.get("city", ""), postal_code=row.get("postal_code", ""), start_date=row.get("start_date"), start_time=row.get("start_time"), end_date=row.get("end_date"), end_time=row.get("end_time"), is_free=prices.get("is_free"), price_min=prices.get("price_min"), price_label=prices.get("price_label", ""), organizer=row.get("organizer", ""), image=row.get("image", ""), )) return events