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/banq.py : connecteur BAnQ (banq.qc.ca) — activités & conférences5# Source : calendrier des activités de Bibliothèque et Archives nationales6# du Québec (Grande Bibliothèque + centres régionaux) — ateliers,7# conférences, heure du conte, expositions (~140 fiches à venir).8# Extraction: SCRAPFLY obligatoire (anti-bot Radware : GET direct → 3029# challenge, /jsonapi bloqué — sondé 2026-08-25).10# Liste /calendrier/a-venir rendue AVEC JavaScript (les cartes11# sont injectées côté client) → liens /calendrier/a-venir/<id>.12# Fiches : Scrapfly SANS rendu JS (HTML serveur), JSON-LD Event13# complet (nom, dates ISO avec fuseau -04:00, lieu, adresse,14# ville, image, organisateur, offre 0,00 $ → gratuité PUBLIÉE).15# INCRÉMENTAL avec cache disque (data/banq_cache.json) :16# nouvelles fiches d'abord, re-visite après 14 jours, plafond17# BANQ_DETAIL_MAX fiches/sync pour contenir le coût en crédits.18# Accès : robots.txt Drupal standard (le calendrier n'est pas exclu) ;19# 1 rendu JS + quelques fiches par sync, cadence hebdomadaire.20# Prix : offers.price du JSON-LD (« 0.00 » = gratuit affiché par la21# source) → is_free/price_label. Jamais inventé.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import json26import os27import re28import time2930from ..normalize import clean_text, parse_time31from ..schema import Event32from .base import BaseConnector3334BASE = "https://www.banq.qc.ca"35CALENDAR = f"{BASE}/calendrier/a-venir"3637DETAIL_MAX = int(os.environ.get("BANQ_DETAIL_MAX", "50"))38REFRESH_AFTER = 14 * 864003940_FICHE_RE = re.compile(r'href="(/calendrier/a-venir/(\d+))"')41_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',42 re.S)434445class BanqConnector(BaseConnector):46 source_id = "banq"47 request_delay = 1.04849 # -- découverte (liste rendue JS) -------------------------------------------50 def _fiches(self) -> list[str]:51 html = self.get_rendered(CALENDAR, render_js=True)52 return sorted({BASE + path for path, _ in _FICHE_RE.findall(html)})5354 # -- fiche ---------------------------------------------------------------------55 def _parse_fiche(self, url: str, html: str) -> dict | None:56 data = None57 for blob in _LD_RE.findall(html):58 try:59 d = json.loads(blob.strip())60 except ValueError:61 continue62 if d.get("@type") == "Event":63 data = d64 break65 if not data:66 return None67 title = clean_text(data.get("name") or "")68 if not title:69 return None70 loc = data.get("location") or {}71 addr = loc.get("address") or {}72 org = data.get("organizer") or {}73 start = str(data.get("startDate") or "") # "2026-08-27T10:30:00-0400"74 end = str(data.get("endDate") or "")75 offers = data.get("offers") or {}76 is_free, price_label = None, ""77 price = offers.get("price")78 if price is not None:79 try:80 amount = float(price)81 is_free = amount == 082 price_label = ("Gratuit (0,00 $ affiché)" if amount == 083 else f"{amount:g} $")84 except (TypeError, ValueError):85 pass86 return {87 "title": title,88 "description": clean_text(data.get("description") or "")[:2000],89 "raw_categories": [title],90 "venue": clean_text(loc.get("name") or ""),91 "address": clean_text(addr.get("streetAddress") or ""),92 "city": clean_text(addr.get("addressLocality") or ""),93 "postal_code": addr.get("postalCode") or "",94 "start_date": start[:10] or None,95 "start_time": parse_time(start),96 "end_date": end[:10] or None,97 "end_time": parse_time(end),98 "status": ("cancelled"99 if "cancel" in str(data.get("eventStatus", "")).lower()100 else ""),101 "is_free": is_free, "price_label": price_label,102 "organizer": clean_text(org.get("name") or ""),103 "image": data.get("image") or "",104 }105106 # -- pipeline ---------------------------------------------------------------------107 def fetch(self) -> list[Event]:108 cache = self.load_cache()109 urls = self._fiches()110 if urls: # liste indisponible → on garde le cache111 current = set(urls)112 cache = {u: c for u, c in cache.items() if u in current}113 else:114 urls = list(cache)115116 now = time.time()117 candidates = sorted(118 (u for u in urls119 if u not in cache120 or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),121 key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX]122123 for url in candidates: # séquentiel : coût Scrapfly maîtrisé124 try:125 row = self._parse_fiche(url, self.get_rendered(url,126 render_js=False))127 except Exception as exc: # fiche cassée : on ne bloque pas la source128 print(f"[sorti-ka] banq : fiche ignorée {url} : {exc}")129 row = cache.get(url, {}).get("row")130 cache[url] = {"fetched_at": now, "row": row}131 self.save_cache(cache)132133 events: list[Event] = []134 seen: set[str] = set()135 for url, entry in cache.items():136 row = entry.get("row")137 if not row:138 continue139 ext = url.rstrip("/").rsplit("/", 1)[-1]140 if ext in seen:141 continue142 seen.add(ext)143 events.append(Event(144 source=self.source_id,145 external_id=ext,146 url=url, title=row["title"],147 description=row.get("description", ""),148 raw_categories=row.get("raw_categories") or [],149 venue=row.get("venue", ""),150 address=row.get("address", ""),151 city=row.get("city", ""),152 postal_code=row.get("postal_code", ""),153 start_date=row.get("start_date"),154 start_time=row.get("start_time"),155 end_date=row.get("end_date"),156 end_time=row.get("end_time"),157 status=row.get("status", ""),158 is_free=row.get("is_free"),159 price_label=row.get("price_label", ""),160 organizer=row.get("organizer", ""),161 image=row.get("image", ""),162 ))163 return events164