# -----------------------------------------------------------------------------
# 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