# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/bassaintlaurent.py : Tourisme Bas-Saint-Laurent — événements (ATR) # Source : https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/ — # WordPress (Bedrock), liste rendue serveur (~27 fiches à venir, # revérifié 2026-08-25 ; le marqueur ajax-load-more__end-page # confirme que TOUTE la liste tient sur la page 1 — aucun # événement caché derrière l'AJAX get_grid). # Extraction: cartes .card--type-event (lien, plage de dates FR, titre, # municipalité, image) + fiches détail incrémentales (cache # data/bassaintlaurent_cache.json, BSL_FICHE_MAX/sync, re-visite # 7 j, version v=2). ENRICHI (2026-08-25) : bloc window.locations # de la fiche (JSON structuré) → adresse civique (street), ville, # code postal, GPS exact, site web officiel ; description longue # = paragraphes « À propos » (fallback og:description). Pas # d'heure ni de tarif publiés → jamais inventés. # Accès : pages publiques de l'ATR, rendu serveur — GET throttlés 0,8 s, # identification honnête, lien vers la fiche originale. # Décision : le Bas-Saint-Laurent n'avait que 168 fiches au baseline — # équilibre régional (04-SOURCES-CANDIDATES §A). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text, parse_date_range_fr from ..schema import Event from .base import BaseConnector BASE = "https://www.bassaintlaurent.ca" LIST_URL = BASE + "/fr/quoi-faire/evenements/" FICHE_MAX = int(os.environ.get("BSL_FICHE_MAX", "15")) REFRESH_AFTER = 7 * 86400 CACHE_V = 2 # v2 : + adresse/ville/CP/site web (window.locations) # + description « À propos » (2026-08-25) _CARD_RE = re.compile( r']*class="card-location card--type-event">(.*?)', re.S) _DATE_RE = re.compile(r'card-location__dates">([^<]+)<') _TITLE_RE = re.compile(r'card-location__title">([^<]+)<') _CITY_RE = re.compile(r'card-location__city">(?:<[^>]+>)*([^<]+)<') _IMG_RE = re.compile(r']+src="([^"]+)"') _OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') _GPS_RE = re.compile( r'"lat(?:itude)?"\s*[:=]\s*"?(-?\d{2}\.\d+)"?\s*,\s*' r'"(?:lng|lon|longitude)"\s*[:=]\s*"?(-?\d{2}\.\d+)"?') # fiche : annuaire JSON embarqué (adresse structurée + site web) _LOCATIONS_RE = re.compile(r"window\.locations\s*=\s*(\[.*?\]);\s*", re.S) # fiche : paragraphes « À propos » (description longue) _ABOUT_RE = re.compile(r'js-truncated text-content">(.*?)', re.S) _END_PAGE_MARK = "ajax-load-more__end-page" class BasSaintLaurentConnector(BaseConnector): source_id = "bassaintlaurent" request_delay = 0.8 def _parse_fiche(self, html: str) -> dict: gps = _GPS_RE.search(html) det: dict = { "desc": "", "lat": float(gps.group(1)) if gps else None, "lng": float(gps.group(2)) if gps else None, "address": "", "city": "", "postal_code": "", "website": "", } about = _ABOUT_RE.search(html) if about: det["desc"] = clean_text(about.group(1))[:2000] if not det["desc"]: m = _OG_DESC_RE.search(html) det["desc"] = clean_text(m.group(1)) if m else "" loc_m = _LOCATIONS_RE.search(html) if loc_m: try: loc = json.loads(loc_m.group(1))[0] except Exception: loc = {} det["address"] = clean_text(loc.get("street") or "") det["city"] = clean_text(loc.get("city") or "") det["postal_code"] = (loc.get("postal_code") or "").strip() site = (loc.get("url") or "").strip() if site and not site.startswith("http"): site = "https://" + site det["website"] = site return det def _enrich_fiches(self, rows: list[dict]) -> None: cache = self.load_cache() now = time.time() current = {r["url"] for r in rows} cache = {u: c for u, c in cache.items() if u in current} # nouvelles fiches puis celles parsées avant v2, puis re-visite roulante to_fetch = sorted( (u for u in current if u not in cache or cache[u].get("v", 1) < CACHE_V or now - cache[u].get("ts", 0) > REFRESH_AFTER), key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0, cache.get(u, {}).get("ts", 0)))[:FICHE_MAX] def worker(url, session): return self._parse_fiche(session.get(url, timeout=25).text) for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items(): cache[url] = {"ts": now, "v": CACHE_V, **(det or {"desc": "", "lat": None, "lng": None})} self.save_cache(cache) for r in rows: det = cache.get(r["url"]) or {} r["description"] = det.get("desc", "") r["lat"], r["lng"] = det.get("lat"), det.get("lng") r["address"] = det.get("address", "") r["postal_code"] = det.get("postal_code", "") r["website"] = det.get("website", "") if det.get("city"): # ville structurée de la fiche r["city"] = det["city"] def fetch(self) -> list[Event]: html = self.get(LIST_URL).text if _END_PAGE_MARK not in html: # garde-fou : pagination apparue ? print("[sorti-ka] bassaintlaurent : liste possiblement paginée " "(marqueur end-page absent) — vérifier get_grid") rows: list[dict] = [] for m in _CARD_RE.finditer(html): url, card = m.group(1), m.group(2) title = _TITLE_RE.search(card) if not title: continue date_raw = _DATE_RE.search(card) start, end = parse_date_range_fr( date_raw.group(1) if date_raw else "") city = _CITY_RE.search(card) img = _IMG_RE.search(card) rows.append({ "external_id": url.rstrip("/").split("/")[-1], "url": url, "title": clean_text(title.group(1)), "city": clean_text(city.group(1)) if city else "", "start_date": start, "end_date": end, "image": img.group(1) if img else "", }) self._enrich_fiches(rows) events: list[Event] = [] seen: set[str] = set() for r in rows: if r["external_id"] in seen: continue seen.add(r["external_id"]) events.append(Event( source=self.source_id, external_id=r["external_id"], url=r["url"], title=r["title"], description=r.get("description", ""), raw_categories=[r["title"]], address=r.get("address", ""), city=r["city"], postal_code=r.get("postal_code", ""), tourist_region="Bas-Saint-Laurent", lat=r.get("lat"), lng=r.get("lng"), start_date=r["start_date"], end_date=r["end_date"], website=r.get("website", ""), image=r["image"], )) return events