# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/banq.py : connecteur BAnQ (banq.qc.ca) — activités & conférences # Source : calendrier des activités de Bibliothèque et Archives nationales # du Québec (Grande Bibliothèque + centres régionaux) — ateliers, # conférences, heure du conte, expositions (~140 fiches à venir). # Extraction: SCRAPFLY obligatoire (anti-bot Radware : GET direct → 302 # challenge, /jsonapi bloqué — sondé 2026-08-25). # Liste /calendrier/a-venir rendue AVEC JavaScript (les cartes # sont injectées côté client) → liens /calendrier/a-venir/. # Fiches : Scrapfly SANS rendu JS (HTML serveur), JSON-LD Event # complet (nom, dates ISO avec fuseau -04:00, lieu, adresse, # ville, image, organisateur, offre 0,00 $ → gratuité PUBLIÉE). # INCRÉMENTAL avec cache disque (data/banq_cache.json) : # nouvelles fiches d'abord, re-visite après 14 jours, plafond # BANQ_DETAIL_MAX fiches/sync pour contenir le coût en crédits. # Accès : robots.txt Drupal standard (le calendrier n'est pas exclu) ; # 1 rendu JS + quelques fiches par sync, cadence hebdomadaire. # Prix : offers.price du JSON-LD (« 0.00 » = gratuit affiché par la # source) → is_free/price_label. Jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text, parse_time from ..schema import Event from .base import BaseConnector BASE = "https://www.banq.qc.ca" CALENDAR = f"{BASE}/calendrier/a-venir" DETAIL_MAX = int(os.environ.get("BANQ_DETAIL_MAX", "50")) REFRESH_AFTER = 14 * 86400 _FICHE_RE = re.compile(r'href="(/calendrier/a-venir/(\d+))"') _LD_RE = re.compile(r'', re.S) class BanqConnector(BaseConnector): source_id = "banq" request_delay = 1.0 # -- découverte (liste rendue JS) ------------------------------------------- def _fiches(self) -> list[str]: html = self.get_rendered(CALENDAR, render_js=True) return sorted({BASE + path for path, _ in _FICHE_RE.findall(html)}) # -- fiche --------------------------------------------------------------------- def _parse_fiche(self, url: str, html: str) -> dict | None: data = None for blob in _LD_RE.findall(html): try: d = json.loads(blob.strip()) except ValueError: continue if d.get("@type") == "Event": data = d break if not data: return None title = clean_text(data.get("name") or "") if not title: return None loc = data.get("location") or {} addr = loc.get("address") or {} org = data.get("organizer") or {} start = str(data.get("startDate") or "") # "2026-08-27T10:30:00-0400" end = str(data.get("endDate") or "") offers = data.get("offers") or {} is_free, price_label = None, "" price = offers.get("price") if price is not None: try: amount = float(price) is_free = amount == 0 price_label = ("Gratuit (0,00 $ affiché)" if amount == 0 else f"{amount:g} $") except (TypeError, ValueError): pass return { "title": title, "description": clean_text(data.get("description") or "")[:2000], "raw_categories": [title], "venue": clean_text(loc.get("name") or ""), "address": clean_text(addr.get("streetAddress") or ""), "city": clean_text(addr.get("addressLocality") or ""), "postal_code": addr.get("postalCode") or "", "start_date": start[:10] or None, "start_time": parse_time(start), "end_date": end[:10] or None, "end_time": parse_time(end), "status": ("cancelled" if "cancel" in str(data.get("eventStatus", "")).lower() else ""), "is_free": is_free, "price_label": price_label, "organizer": clean_text(org.get("name") or ""), "image": data.get("image") or "", } # -- pipeline --------------------------------------------------------------------- def fetch(self) -> list[Event]: cache = self.load_cache() urls = self._fiches() if urls: # liste indisponible → on garde le cache current = set(urls) cache = {u: c for u, c in cache.items() if u in current} else: urls = list(cache) now = time.time() candidates = sorted( (u for u in urls if u not in cache or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER), key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX] for url in candidates: # séquentiel : coût Scrapfly maîtrisé try: row = self._parse_fiche(url, self.get_rendered(url, render_js=False)) except Exception as exc: # fiche cassée : on ne bloque pas la source print(f"[sorti-ka] banq : fiche ignorée {url} : {exc}") row = cache.get(url, {}).get("row") cache[url] = {"fetched_at": now, "row": row} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for url, entry in cache.items(): row = entry.get("row") if not row: continue ext = url.rstrip("/").rsplit("/", 1)[-1] if ext in seen: continue seen.add(ext) events.append(Event( source=self.source_id, external_id=ext, url=url, title=row["title"], description=row.get("description", ""), raw_categories=row.get("raw_categories") or [], venue=row.get("venue", ""), 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"), status=row.get("status", ""), is_free=row.get("is_free"), price_label=row.get("price_label", ""), organizer=row.get("organizer", ""), image=row.get("image", ""), )) return events