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/zeffy.py : connecteur Zeffy (zeffy.com) — billetterie OSBL5# Source : billetterie gratuite très utilisée par les OSBL québécois6# (galas, spectacles-bénéfice, marches, ateliers communautaires).7# Extraction: 1) sitemap des organisations (api.zeffy.com/sitemap/8# organizations/sitemap-0.xml) → pages d'organisation fr-CA/en-CA9# (~4 400, dont ~1 300 fr-CA) ; 2) chaque page d'organisation est10# rendue serveur (Next.js __NEXT_DATA__ → organizationPage.11# upcomingEvents : titre, description, adresse postale complète,12# occurrences startUtc/endUtc, bannière, URL de billetterie) —13# AUCUN rendu JS requis ; 3) fiches billetterie des événements QC14# retenus : JSON-LD (offers[].price) → tarifs réels publiés.15# Périmètre QUÉBEC : on ne garde que les événements datés dont16# l'adresse porte « , QC » (les organisations fr-CA/en-CA17# couvrent tout le Canada). Le sitemap des BILLETS (55 000+ URLs18# fr-CA mêlant dons/adhésions/archives, sans dates) est ignoré :19# la page d'organisation est la seule surface « à venir » fiable.20# INCRÉMENTAL avec cache disque (data/zeffy_cache.json) :21# nouvelles organisations d'abord, re-visite après 7 jours,22# plafonds ZEFFY_ORG_MAX pages org et ZEFFY_DETAIL_MAX fiches23# prix par sync.24# Accès : robots.txt « User-agent: * / Disallow: /*=$ » (seules les URLs25# finissant par « = » sont exclues — aucune de celles qu'on26# visite) + sitemaps publiés ; GET throttlés, identification27# honnête, pas d'anti-bot constaté (sondé 2026-08-25).28# Prix : offers[].price du JSON-LD de la fiche (tarifs affichés par29# l'organisme ; Zeffy suggère un pourboire plateforme au paiement,30# non inclus). is_free=True si TOUS les tarifs publiés sont à 0 $.31# Jamais inventé (événement sans fiche lisible → prix inconnu).32# -----------------------------------------------------------------------------33from __future__ import annotations3435import json36import os37import re38import time3940from ..normalize import clean_text, utc_to_local41from ..schema import Event42from .base import BaseConnector4344ORG_SITEMAP = "https://api.zeffy.com/sitemap/organizations/sitemap-0.xml"4546LOCALES = tuple(l.strip() for l in os.environ.get(47 "ZEFFY_LOCALES", "fr-CA,en-CA").split(",") if l.strip())48ORG_MAX = int(os.environ.get("ZEFFY_ORG_MAX", "400"))49DETAIL_MAX = int(os.environ.get("ZEFFY_DETAIL_MAX", "80"))50REFRESH_AFTER = 7 * 864005152_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")53_NEXT_RE = re.compile(r'__NEXT_DATA__"[^>]*>(\{.*?\})</script>', re.S)54_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',55 re.S)56# adresse Zeffy : « 69 Rue Wellington N, Sherbrooke, QC J1H 5A9, Canada »57_QC_PART_RE = re.compile(r"^(?:QC|Québec|Quebec)\b\s*(.*)$")585960def _split_address(raw: str) -> tuple[str, str, str, str] | None:61 """(rue, ville, code postal, adresse nettoyée) si l'adresse est au Québec,62 sinon None — le filtre de périmètre provincial du connecteur."""63 parts = [p.strip() for p in (raw or "").split(",") if p.strip()]64 for i, part in enumerate(parts):65 m = _QC_PART_RE.match(part)66 if m and i >= 1:67 street = ", ".join(parts[:i - 1])68 city = parts[i - 1]69 postal = m.group(1).strip()70 return street, city, postal, raw.strip()71 return None727374def _field_fr(fields: list[dict] | None) -> dict:75 """ticketingFields : privilégie la variante FR, sinon la première."""76 fields = fields or []77 for f in fields:78 if str(f.get("locale") or "").upper().startswith("FR"):79 return f80 return fields[0] if fields else {}818283class ZeffyConnector(BaseConnector):84 source_id = "zeffy"85 request_delay = 0.88687 # -- découverte (sitemap des organisations) -------------------------------88 def _org_urls(self) -> list[str]:89 xml = self.get(ORG_SITEMAP).text90 return sorted({91 u for u in _LOC_RE.findall(xml)92 if any(f"/{loc}/organizations/" in u for loc in LOCALES)})9394 # -- page d'organisation ---------------------------------------------------95 def _parse_org(self, url: str, html: str) -> list[dict]:96 m = _NEXT_RE.search(html)97 if not m:98 return []99 try:100 page = (json.loads(m.group(1)).get("props", {})101 .get("pageProps", {}).get("organizationPage")) or {}102 except ValueError:103 return []104 org_name = clean_text((page.get("organization") or {}).get("name")105 or "")106 rows: list[dict] = []107 for ev in page.get("upcomingEvents") or []:108 if ev.get("formCategory") != "Event":109 continue # dons, adhésions, tirages : hors périmètre110 qc = _split_address(str(ev.get("address") or ""))111 if not qc:112 continue # sans adresse québécoise : hors périmètre113 street, city, postal, _ = qc114 field = _field_fr(ev.get("ticketingFields"))115 title = clean_text(field.get("title") or "")116 ticket_url = ev.get("url") or ""117 if not (title and ticket_url):118 continue119 for occ in ev.get("occurrences") or []:120 start_date, start_time = utc_to_local(occ.get("startUtc"))121 end_date, end_time = utc_to_local(occ.get("endUtc"))122 if not start_date:123 continue # occurrence sans date publiée : rejetée124 rows.append({125 "external_id": f"{ev.get('id')}:{occ.get('id')}",126 "url": ticket_url, "title": title,127 "description": clean_text(field.get("description")128 or "")[:2000],129 "raw_categories": [title],130 "address": street, "city": city, "postal_code": postal,131 "start_date": start_date, "start_time": start_time,132 "end_date": end_date, "end_time": end_time,133 "organizer": org_name,134 "image": ev.get("bannerUrl") or "",135 })136 return rows137138 # -- fiche billetterie (tarifs publiés) ------------------------------------139 def _parse_fiche_prices(self, html: str) -> dict | None:140 """JSON-LD de la fiche → {is_free, price_min, price_label}, ou None."""141 for blob in _LD_RE.findall(html):142 try:143 d = json.loads(blob.strip())144 except ValueError:145 continue146 offers = d.get("offers")147 if not isinstance(offers, list) or not offers:148 continue149 prices, labels = [], []150 for o in offers:151 try:152 p = float(o.get("price"))153 except (TypeError, ValueError):154 continue155 prices.append(p)156 labels.append(f"{clean_text(o.get('name') or '')} {p:g} $")157 if not prices:158 continue159 label = " · ".join(labels[:5]) + (" · …" if len(labels) > 5 else "")160 if max(prices) == 0:161 return {"is_free": True, "price_min": None,162 "price_label": label}163 return {"is_free": False,164 "price_min": min(p for p in prices if p > 0),165 "price_label": label}166 return None167168 # -- pipeline ---------------------------------------------------------------169 def fetch(self) -> list[Event]:170 cache = self.load_cache()171 try:172 org_urls = self._org_urls()173 except Exception as exc: # sitemap indisponible → on garde le cache174 print(f"[sorti-ka] zeffy : sitemap organisations indisponible : {exc}")175 org_urls = []176 if org_urls:177 current = {f"org::{u}" for u in org_urls}178 cache = {k: v for k, v in cache.items()179 if not k.startswith("org::") or k in current}180 else:181 org_urls = [k[5:] for k in cache if k.startswith("org::")]182183 now = time.time()184 candidates = sorted(185 (u for u in org_urls186 if f"org::{u}" not in cache187 or now - cache[f"org::{u}"].get("fetched_at", 0) > REFRESH_AFTER),188 key=lambda u: cache.get(f"org::{u}", {}).get("fetched_at", 0)189 )[:ORG_MAX]190191 def worker(url, session):192 return self._parse_org(url, session.get(url, timeout=30).text)193194 for url, rows in self.fetch_many(candidates, worker,195 max_workers=4).items():196 if rows is None: # page cassée : on garde l'état précédent197 rows = cache.get(f"org::{url}", {}).get("rows", [])198 cache[f"org::{url}"] = {"fetched_at": now, "rows": rows}199200 # lignes courantes (dédupliquées inter-organisations)201 rows_by_id: dict[str, dict] = {}202 for key, entry in cache.items():203 if not key.startswith("org::"):204 continue205 for row in entry.get("rows", []):206 rows_by_id.setdefault(row["external_id"], row)207208 # enrichissement prix : fiches billetterie des événements QC retenus209 ticket_urls = sorted({r["url"] for r in rows_by_id.values()})210 to_fetch = [u for u in ticket_urls211 if f"fiche::{u}" not in cache212 or now - cache[f"fiche::{u}"].get("fetched_at", 0)213 > REFRESH_AFTER][:DETAIL_MAX]214215 def price_worker(url, session):216 return self._parse_fiche_prices(session.get(url, timeout=30).text)217218 for url, prices in self.fetch_many(to_fetch, price_worker,219 max_workers=4).items():220 if prices is None: # fiche illisible : on garde l'existant221 prices = cache.get(f"fiche::{url}", {}).get("prices")222 cache[f"fiche::{url}"] = {"fetched_at": now, "prices": prices}223 # purge des fiches prix orphelines (événements disparus)224 keep = {f"fiche::{u}" for u in ticket_urls}225 cache = {k: v for k, v in cache.items()226 if not k.startswith("fiche::") or k in keep}227 self.save_cache(cache)228229 events: list[Event] = []230 for row in rows_by_id.values():231 prices = (cache.get(f"fiche::{row['url']}", {}).get("prices")232 or {})233 events.append(Event(234 source=self.source_id,235 external_id=row["external_id"],236 url=row["url"], title=row["title"],237 description=row.get("description", ""),238 raw_categories=row.get("raw_categories") or [],239 address=row.get("address", ""),240 city=row.get("city", ""),241 postal_code=row.get("postal_code", ""),242 start_date=row.get("start_date"),243 start_time=row.get("start_time"),244 end_date=row.get("end_date"),245 end_time=row.get("end_time"),246 is_free=prices.get("is_free"),247 price_min=prices.get("price_min"),248 price_label=prices.get("price_label", ""),249 organizer=row.get("organizer", ""),250 image=row.get("image", ""),251 ))252 return events253