# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/tourismelaval.py : Tourisme Laval (ATR, vague ATR 2026-08) # Source : tourismelaval.com/explorer-possibilites/evenements/ — calendrier # officiel de l'ATR (WordPress + FacetWP, CPT « event », cartes # RENDUES SERVEUR ; vérifié 2026-08-25 : 21 événements sur # 2 pages). NB : le calendrier « tribe » du même site # (/calendrier/) est mort à la source (derniers événements # décembre 2025) ; le CPT event est le vivant. L'API REST # wp/v2/event est verrouillée (401 Kadence Security) → parse des # cartes SSR. # Extraction: GET /explorer-possibilites/evenements/ (+ /page/N/) → #
: lien, # image, catégorie, titre, extrait, plage de dates FR # (« 18 août 2026 - 25 août 2026 »). Pas de fiche détail # (adresse/heure non structurées sur les fiches, vérifié). # Accès : pages publiques — GET throttlés, identification honnête, lien # vers la fiche originale. Ni prix ni heure structurés → jamais # inventés. Ville = Laval (périmètre de l'ATR, île de Laval). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from ..normalize import clean_text, parse_date_range_fr from ..schema import Event from .base import BaseConnector BASE = "https://www.tourismelaval.com" LIST_URL = BASE + "/explorer-possibilites/evenements/" LVT_PAGE_MAX = int(os.environ.get("LVT_PAGE_MAX", "5")) _ARTICLE_RE = re.compile( r'
]+src="([^"]+)"') _CAT_RE = re.compile(r'post__cat">\s*]*>([^<]*)

') _TITLE_RE = re.compile(r'grid__title"[^>]*>([^<]*)') _EXCERPT_RE = re.compile(r'post__excerpt"\s*>\s*(.*?)', re.S) _DATE_RE = re.compile(r'post__date">.*?]*>([^<]*)', re.S) class TourismeLavalConnector(BaseConnector): source_id = "tourismelaval" request_delay = 0.8 def _articles(self, html: str) -> list[dict]: out = [] for m in _ARTICLE_RE.finditer(html): eid, body = m.groups() link = _LINK_RE.search(body) title = _TITLE_RE.search(body) if not (link and title): continue date_m = _DATE_RE.search(body) start = end = None if date_m: # « 18 août 2026 - 25 août 2026 » ou « 27 juin 2026 » parts = [p.strip() for p in date_m.group(1).split(" - ")] start, _ = parse_date_range_fr(parts[0]) end = (parse_date_range_fr(parts[1])[0] if len(parts) > 1 else start) img = _IMG_RE.search(body) cat = _CAT_RE.search(body) excerpt = _EXCERPT_RE.search(body) out.append({ "external_id": eid, "url": link.group(1), "title": clean_text(title.group(1)), "raw_categories": [clean_text(cat.group(1)) if cat else "", clean_text(title.group(1))], "description": clean_text(excerpt.group(1)) if excerpt else "", "start_date": start, "end_date": end, "image": img.group(1) if img else "", }) return out def fetch(self) -> list[Event]: events: list[Event] = [] seen: set[str] = set() for page in range(1, LVT_PAGE_MAX + 1): url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" try: articles = self._articles(self.get(url).text) except Exception: break new = [a for a in articles if a["external_id"] not in seen] if not new: break for a in new: seen.add(a["external_id"]) if not a["title"]: continue events.append(Event( source=self.source_id, external_id=a["external_id"], url=a["url"], title=a["title"], description=a["description"], raw_categories=[c for c in a["raw_categories"] if c], city="Laval", tourist_region="Laval", start_date=a["start_date"], end_date=a["end_date"], image=a["image"], )) if len(articles) < 12: # page incomplète (12/page) = dernière break return events