# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lanaudiere.py : Tourisme Lanaudière — événements (ATR, Phase 3) # Source : https://lanaudiere.ca/fr/evenements-lanaudiere — CMS Umbraco, # médias Tourinsoft (mto.media.tourinsoft.eu — même gisement que # le SIT Québec, recoupement géré par dedup_key). La liste rendue # serveur ne montre que ~10 cartes (le reste charge en JS) → on # passe par le SITEMAP officiel (robots.txt), qui publie TOUTES # les fiches /fr/evenements-lanaudiere// (~85 URLs, # revérifié 2026-08-25). # Extraction: sitemap.xml (1 GET) → fiches détail incrémentales (cache # data/lanaudiere_cache.json, LAN_FICHE_MAX/sync, re-visite 7 j, # version v=2) : en-tête structuré member-title-1-1 (titre, # plage de dates FR « Du 17 au 26 juillet 2026 », municipalité) # + og:image. ENRICHI (2026-08-25) : description longue # (expandable-intro, fallback og:description), adresse civique + # code postal (member-coords-1-1__address), organisateur # (member-coords-1-1__name), site web (member-website-1-1) et # audience (« Clientèle admise »). Pas d'heure ni tarif # structurés → jamais inventés. # Accès : robots.txt : « Disallow: /*? » (aucune URL à paramètres # requêtée) + « Crawl-delay: 2 » → GET séquentiels 2,1 s. # ----------------------------------------------------------------------------- from __future__ import annotations 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://lanaudiere.ca" SITEMAP_URL = BASE + "/sitemap.xml" FICHE_MAX = int(os.environ.get("LAN_FICHE_MAX", "10")) REFRESH_AFTER = 7 * 86400 CACHE_V = 2 # v2 : + adresse/CP, organisateur, site web, # audience, description longue (2026-08-25) _SITEMAP_RE = re.compile( r"\s*(https://lanaudiere\.ca/fr/evenements-lanaudiere/" r"[a-z0-9-]+/?)\s*") _TITLE_RE = re.compile(r'member-title-1-1__title">\s*([^<]+?)\s*<') _DATE_RE = re.compile(r'member-title-1-1__subtitle">\s*([^<]+?)\s*<') _CITY_RE = re.compile(r'member-title-1-1__location">([^<]+)<') _OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"') _OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') # description longue de la fiche (bloc « expandable-intro ») _INTRO_RE = re.compile( r'expandable-intro-1-1__text[^>]*>(.*?)', re.S) # coordonnées du membre : nom (organisateur), adresse « 1655, boul. # Base-de-Roc
Joliette, QC J6E 0L1 » _COORD_NAME_RE = re.compile( r'member-coords-1-1__name">\s*(.*?)\s*', re.S) _COORD_ADDR_RE = re.compile( r'member-coords-1-1__address">\s*(.*?)\s*', re.S) _ADDR_SPLIT_RE = re.compile( r"^(?P.*?)\s*(?P[^,<]+),\s*QC" r"(?:\s*(?P[A-Z]\d[A-Z]\s?\d[A-Z]\d))?", re.S) _WEBSITE_RE = re.compile(r'member-website-1-1__btn"\s+href="([^"]+)"') # « Clientèle admise » : liste de publics (Adultes, Adolescents, Familles…) _AUDIENCE_RE = re.compile( r"Client\S*le admise.*?
    (.*?)
", re.S) _LI_RE = re.compile(r"]*>\s*([^<]+?)\s*") class LanaudiereConnector(BaseConnector): source_id = "lanaudiere" request_delay = 2.1 # Crawl-delay: 2 (robots.txt) def _parse_fiche(self, url: str, html: str) -> dict | None: title = _TITLE_RE.search(html) if not title: return None date_raw = _DATE_RE.search(html) start, end = parse_date_range_fr( date_raw.group(1) if date_raw else "") city = _CITY_RE.search(html) img = _OG_IMG_RE.search(html) intro = _INTRO_RE.search(html) og_desc = _OG_DESC_RE.search(html) desc = clean_text(intro.group(1))[:2000] if intro else ( clean_text(og_desc.group(1)) if og_desc else "") address, postal = "", "" coord = _COORD_ADDR_RE.search(html) if coord: m = _ADDR_SPLIT_RE.match(coord.group(1).strip()) if m: address = clean_text(m.group("addr")) postal = (m.group("pc") or "").strip() else: # adresse sur une seule ligne address = clean_text(coord.group(1)) name = _COORD_NAME_RE.search(html) website = _WEBSITE_RE.search(html) aud = _AUDIENCE_RE.search(html) audience = ", ".join(clean_text(x) for x in _LI_RE.findall( aud.group(1))) if aud else "" return { "external_id": url.rstrip("/").split("/")[-1], "url": url, "title": clean_text(title.group(1)), "description": desc, "city": clean_text(city.group(1)) if city else "", "address": address, "postal_code": postal, "organizer": clean_text(name.group(1)) if name else "", "website": website.group(1).strip() if website else "", "audience": audience, "start_date": start, "end_date": end, "image": clean_text(img.group(1)) if img else "", } def fetch(self) -> list[Event]: xml = self.get(SITEMAP_URL).text urls = sorted({u.rstrip("/") + "/" for u in _SITEMAP_RE.findall(xml) if not u.rstrip("/").endswith("evenements-lanaudiere")}) cache = self.load_cache() now = time.time() current = set(urls) cache = {u: c for u, c in cache.items() if u in current} # nouvelles fiches d'abord, puis celles parsées avant v2 (sans # adresse/site/audience), puis re-visite roulante to_fetch = sorted( (u for u in urls 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] for url in to_fetch: # séquentiel : Crawl-delay respecté try: row = self._parse_fiche(url, self.get(url).text) except Exception: row = None if row is None: print(f"[sorti-ka] lanaudiere : fiche ignorée {url}") cache[url] = {"ts": now, "row": None, "v": CACHE_V} continue cache[url] = {"ts": now, "row": row, "v": CACHE_V} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for entry in cache.values(): r = entry.get("row") if not r or 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"]], audience=r.get("audience", ""), address=r.get("address", ""), city=r["city"], postal_code=r.get("postal_code", ""), tourist_region="Lanaudière", start_date=r["start_date"], end_date=r["end_date"], organizer=r.get("organizer", ""), website=r.get("website", ""), image=r["image"], )) return events