# -----------------------------------------------------------------------------
# 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