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/lanaudiere.py : Tourisme Lanaudière — événements (ATR, Phase 3)5# Source : https://lanaudiere.ca/fr/evenements-lanaudiere — CMS Umbraco,6# médias Tourinsoft (mto.media.tourinsoft.eu — même gisement que7# le SIT Québec, recoupement géré par dedup_key). La liste rendue8# serveur ne montre que ~10 cartes (le reste charge en JS) → on9# passe par le SITEMAP officiel (robots.txt), qui publie TOUTES10# les fiches /fr/evenements-lanaudiere/<slug>/ (~85 URLs,11# revérifié 2026-08-25).12# Extraction: sitemap.xml (1 GET) → fiches détail incrémentales (cache13# data/lanaudiere_cache.json, LAN_FICHE_MAX/sync, re-visite 7 j,14# version v=2) : en-tête structuré member-title-1-1 (titre,15# plage de dates FR « Du 17 au 26 juillet 2026 », municipalité)16# + og:image. ENRICHI (2026-08-25) : description longue17# (expandable-intro, fallback og:description), adresse civique +18# code postal (member-coords-1-1__address), organisateur19# (member-coords-1-1__name), site web (member-website-1-1) et20# audience (« Clientèle admise »). Pas d'heure ni tarif21# structurés → jamais inventés.22# Accès : robots.txt : « Disallow: /*? » (aucune URL à paramètres23# requêtée) + « Crawl-delay: 2 » → GET séquentiels 2,1 s.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import time3031from ..normalize import clean_text, parse_date_range_fr32from ..schema import Event33from .base import BaseConnector3435BASE = "https://lanaudiere.ca"36SITEMAP_URL = BASE + "/sitemap.xml"37FICHE_MAX = int(os.environ.get("LAN_FICHE_MAX", "10"))38REFRESH_AFTER = 7 * 8640039CACHE_V = 2 # v2 : + adresse/CP, organisateur, site web,40# audience, description longue (2026-08-25)4142_SITEMAP_RE = re.compile(43 r"<loc>\s*(https://lanaudiere\.ca/fr/evenements-lanaudiere/"44 r"[a-z0-9-]+/?)\s*</loc>")45_TITLE_RE = re.compile(r'member-title-1-1__title">\s*([^<]+?)\s*<')46_DATE_RE = re.compile(r'member-title-1-1__subtitle">\s*([^<]+?)\s*<')47_CITY_RE = re.compile(r'member-title-1-1__location"><span>([^<]+)<')48_OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"')49_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')50# description longue de la fiche (bloc « expandable-intro »)51_INTRO_RE = re.compile(52 r'expandable-intro-1-1__text[^>]*>(.*?)</div>', re.S)53# coordonnées du membre : nom (organisateur), adresse « 1655, boul.54# Base-de-Roc<br />Joliette, QC J6E 0L1 »55_COORD_NAME_RE = re.compile(56 r'member-coords-1-1__name">\s*(.*?)\s*</div>', re.S)57_COORD_ADDR_RE = re.compile(58 r'member-coords-1-1__address">\s*(.*?)\s*</div>', re.S)59_ADDR_SPLIT_RE = re.compile(60 r"^(?P<addr>.*?)<br\s*/?>\s*(?P<city>[^,<]+),\s*QC"61 r"(?:\s*(?P<pc>[A-Z]\d[A-Z]\s?\d[A-Z]\d))?", re.S)62_WEBSITE_RE = re.compile(r'member-website-1-1__btn"\s+href="([^"]+)"')63# « Clientèle admise » : liste de publics (Adultes, Adolescents, Familles…)64_AUDIENCE_RE = re.compile(65 r"Client\S*le admise.*?<ul>(.*?)</ul>", re.S)66_LI_RE = re.compile(r"<li[^>]*>\s*([^<]+?)\s*</li>")676869class LanaudiereConnector(BaseConnector):70 source_id = "lanaudiere"71 request_delay = 2.1 # Crawl-delay: 2 (robots.txt)7273 def _parse_fiche(self, url: str, html: str) -> dict | None:74 title = _TITLE_RE.search(html)75 if not title:76 return None77 date_raw = _DATE_RE.search(html)78 start, end = parse_date_range_fr(79 date_raw.group(1) if date_raw else "")80 city = _CITY_RE.search(html)81 img = _OG_IMG_RE.search(html)82 intro = _INTRO_RE.search(html)83 og_desc = _OG_DESC_RE.search(html)84 desc = clean_text(intro.group(1))[:2000] if intro else (85 clean_text(og_desc.group(1)) if og_desc else "")86 address, postal = "", ""87 coord = _COORD_ADDR_RE.search(html)88 if coord:89 m = _ADDR_SPLIT_RE.match(coord.group(1).strip())90 if m:91 address = clean_text(m.group("addr"))92 postal = (m.group("pc") or "").strip()93 else: # adresse sur une seule ligne94 address = clean_text(coord.group(1))95 name = _COORD_NAME_RE.search(html)96 website = _WEBSITE_RE.search(html)97 aud = _AUDIENCE_RE.search(html)98 audience = ", ".join(clean_text(x) for x in _LI_RE.findall(99 aud.group(1))) if aud else ""100 return {101 "external_id": url.rstrip("/").split("/")[-1],102 "url": url,103 "title": clean_text(title.group(1)),104 "description": desc,105 "city": clean_text(city.group(1)) if city else "",106 "address": address, "postal_code": postal,107 "organizer": clean_text(name.group(1)) if name else "",108 "website": website.group(1).strip() if website else "",109 "audience": audience,110 "start_date": start, "end_date": end,111 "image": clean_text(img.group(1)) if img else "",112 }113114 def fetch(self) -> list[Event]:115 xml = self.get(SITEMAP_URL).text116 urls = sorted({u.rstrip("/") + "/" for u in _SITEMAP_RE.findall(xml)117 if not u.rstrip("/").endswith("evenements-lanaudiere")})118 cache = self.load_cache()119 now = time.time()120 current = set(urls)121 cache = {u: c for u, c in cache.items() if u in current}122 # nouvelles fiches d'abord, puis celles parsées avant v2 (sans123 # adresse/site/audience), puis re-visite roulante124 to_fetch = sorted(125 (u for u in urls126 if u not in cache or cache[u].get("v", 1) < CACHE_V127 or now - cache[u].get("ts", 0) > REFRESH_AFTER),128 key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,129 cache.get(u, {}).get("ts", 0)))[:FICHE_MAX]130 for url in to_fetch: # séquentiel : Crawl-delay respecté131 try:132 row = self._parse_fiche(url, self.get(url).text)133 except Exception:134 row = None135 if row is None:136 print(f"[sorti-ka] lanaudiere : fiche ignorée {url}")137 cache[url] = {"ts": now, "row": None, "v": CACHE_V}138 continue139 cache[url] = {"ts": now, "row": row, "v": CACHE_V}140 self.save_cache(cache)141142 events: list[Event] = []143 seen: set[str] = set()144 for entry in cache.values():145 r = entry.get("row")146 if not r or r["external_id"] in seen:147 continue148 seen.add(r["external_id"])149 events.append(Event(150 source=self.source_id,151 external_id=r["external_id"],152 url=r["url"],153 title=r["title"],154 description=r.get("description", ""),155 raw_categories=[r["title"]],156 audience=r.get("audience", ""),157 address=r.get("address", ""),158 city=r["city"],159 postal_code=r.get("postal_code", ""),160 tourist_region="Lanaudière",161 start_date=r["start_date"],162 end_date=r["end_date"],163 organizer=r.get("organizer", ""),164 website=r.get("website", ""),165 image=r["image"],166 ))167 return events168