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%
7.0 KB · 164 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/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