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/bassaintlaurent.py : Tourisme Bas-Saint-Laurent — événements (ATR)5# Source : https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/ —6# WordPress (Bedrock), liste rendue serveur (~27 fiches à venir,7# revérifié 2026-08-25 ; le marqueur ajax-load-more__end-page8# confirme que TOUTE la liste tient sur la page 1 — aucun9# événement caché derrière l'AJAX get_grid).10# Extraction: cartes .card--type-event (lien, plage de dates FR, titre,11# municipalité, image) + fiches détail incrémentales (cache12# data/bassaintlaurent_cache.json, BSL_FICHE_MAX/sync, re-visite13# 7 j, version v=2). ENRICHI (2026-08-25) : bloc window.locations14# de la fiche (JSON structuré) → adresse civique (street), ville,15# code postal, GPS exact, site web officiel ; description longue16# = paragraphes « À propos » (fallback og:description). Pas17# d'heure ni de tarif publiés → jamais inventés.18# Accès : pages publiques de l'ATR, rendu serveur — GET throttlés 0,8 s,19# identification honnête, lien vers la fiche originale.20# Décision : le Bas-Saint-Laurent n'avait que 168 fiches au baseline —21# équilibre régional (04-SOURCES-CANDIDATES §A).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import json26import os27import re28import time2930from ..normalize import clean_text, parse_date_range_fr31from ..schema import Event32from .base import BaseConnector3334BASE = "https://www.bassaintlaurent.ca"35LIST_URL = BASE + "/fr/quoi-faire/evenements/"36FICHE_MAX = int(os.environ.get("BSL_FICHE_MAX", "15"))37REFRESH_AFTER = 7 * 8640038CACHE_V = 2 # v2 : + adresse/ville/CP/site web (window.locations)39# + description « À propos » (2026-08-25)4041_CARD_RE = re.compile(42 r'<a href="(https://www\.bassaintlaurent\.ca/fr/quoi-faire/evenements/'43 r'[^"]+)"[^>]*class="card-location card--type-event">(.*?)</a>', re.S)44_DATE_RE = re.compile(r'card-location__dates">([^<]+)<')45_TITLE_RE = re.compile(r'card-location__title">([^<]+)<')46_CITY_RE = re.compile(r'card-location__city">(?:<[^>]+>)*([^<]+)<')47_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"')48_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')49_GPS_RE = re.compile(50 r'"lat(?:itude)?"\s*[:=]\s*"?(-?\d{2}\.\d+)"?\s*,\s*'51 r'"(?:lng|lon|longitude)"\s*[:=]\s*"?(-?\d{2}\.\d+)"?')52# fiche : annuaire JSON embarqué (adresse structurée + site web)53_LOCATIONS_RE = re.compile(r"window\.locations\s*=\s*(\[.*?\]);\s*</script>",54 re.S)55# fiche : paragraphes « À propos » (description longue)56_ABOUT_RE = re.compile(r'js-truncated text-content">(.*?)</div>', re.S)57_END_PAGE_MARK = "ajax-load-more__end-page"585960class BasSaintLaurentConnector(BaseConnector):61 source_id = "bassaintlaurent"62 request_delay = 0.86364 def _parse_fiche(self, html: str) -> dict:65 gps = _GPS_RE.search(html)66 det: dict = {67 "desc": "", "lat": float(gps.group(1)) if gps else None,68 "lng": float(gps.group(2)) if gps else None,69 "address": "", "city": "", "postal_code": "", "website": "",70 }71 about = _ABOUT_RE.search(html)72 if about:73 det["desc"] = clean_text(about.group(1))[:2000]74 if not det["desc"]:75 m = _OG_DESC_RE.search(html)76 det["desc"] = clean_text(m.group(1)) if m else ""77 loc_m = _LOCATIONS_RE.search(html)78 if loc_m:79 try:80 loc = json.loads(loc_m.group(1))[0]81 except Exception:82 loc = {}83 det["address"] = clean_text(loc.get("street") or "")84 det["city"] = clean_text(loc.get("city") or "")85 det["postal_code"] = (loc.get("postal_code") or "").strip()86 site = (loc.get("url") or "").strip()87 if site and not site.startswith("http"):88 site = "https://" + site89 det["website"] = site90 return det9192 def _enrich_fiches(self, rows: list[dict]) -> None:93 cache = self.load_cache()94 now = time.time()95 current = {r["url"] for r in rows}96 cache = {u: c for u, c in cache.items() if u in current}97 # nouvelles fiches puis celles parsées avant v2, puis re-visite roulante98 to_fetch = sorted(99 (u for u in current100 if u not in cache or cache[u].get("v", 1) < CACHE_V101 or now - cache[u].get("ts", 0) > REFRESH_AFTER),102 key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,103 cache.get(u, {}).get("ts", 0)))[:FICHE_MAX]104105 def worker(url, session):106 return self._parse_fiche(session.get(url, timeout=25).text)107108 for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items():109 cache[url] = {"ts": now, "v": CACHE_V,110 **(det or {"desc": "", "lat": None, "lng": None})}111 self.save_cache(cache)112 for r in rows:113 det = cache.get(r["url"]) or {}114 r["description"] = det.get("desc", "")115 r["lat"], r["lng"] = det.get("lat"), det.get("lng")116 r["address"] = det.get("address", "")117 r["postal_code"] = det.get("postal_code", "")118 r["website"] = det.get("website", "")119 if det.get("city"): # ville structurée de la fiche120 r["city"] = det["city"]121122 def fetch(self) -> list[Event]:123 html = self.get(LIST_URL).text124 if _END_PAGE_MARK not in html: # garde-fou : pagination apparue ?125 print("[sorti-ka] bassaintlaurent : liste possiblement paginée "126 "(marqueur end-page absent) — vérifier get_grid")127 rows: list[dict] = []128 for m in _CARD_RE.finditer(html):129 url, card = m.group(1), m.group(2)130 title = _TITLE_RE.search(card)131 if not title:132 continue133 date_raw = _DATE_RE.search(card)134 start, end = parse_date_range_fr(135 date_raw.group(1) if date_raw else "")136 city = _CITY_RE.search(card)137 img = _IMG_RE.search(card)138 rows.append({139 "external_id": url.rstrip("/").split("/")[-1],140 "url": url,141 "title": clean_text(title.group(1)),142 "city": clean_text(city.group(1)) if city else "",143 "start_date": start, "end_date": end,144 "image": img.group(1) if img else "",145 })146 self._enrich_fiches(rows)147148 events: list[Event] = []149 seen: set[str] = set()150 for r in rows:151 if r["external_id"] in seen:152 continue153 seen.add(r["external_id"])154 events.append(Event(155 source=self.source_id,156 external_id=r["external_id"],157 url=r["url"],158 title=r["title"],159 description=r.get("description", ""),160 raw_categories=[r["title"]],161 address=r.get("address", ""),162 city=r["city"],163 postal_code=r.get("postal_code", ""),164 tourist_region="Bas-Saint-Laurent",165 lat=r.get("lat"), lng=r.get("lng"),166 start_date=r["start_date"],167 end_date=r["end_date"],168 website=r.get("website", ""),169 image=r["image"],170 ))171 return events172