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.7 KB · 253 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/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