# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lavalweb.py : Ville de Laval — calendrier VIVANT laval.ca (Phase 3) # Remplace la source « laval » (jeu Données Québec MORT À LA SOURCE depuis # 2025-12, consigné Phase 2). L'ancien connecteur reste branché (réveil # automatique si la Ville republie) ; le recoupement éventuel est géré par # dedup_key. # Source : https://www.laval.ca/calendrier-activites/ — WordPress, listes # rendues serveur, pagination ?findstr[group]=events&findstr[page]=N # (~58 pages × 8 cartes ≈ 460 activités à venir, vérifié 2026-08-19). # Extraction: cartes .event-item (date FR « 18 août 2026 | 18 h », titre, # lieu, lien, image lazy) + fiches détail incrémentales (cache # data/lavalweb_cache.json, LAVALWEB_FICHE_MAX/sync, re-visite # 7 j) : og:description. GPS via annuaire de salles/géocodeur. # Accès : robots.txt laval.ca : « Disallow: /wp-admin/ » seulement — les # pages calendrier sont libres. GET throttlés 0,5 s, UA honnête. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from ..normalize import clean_text, parse_date_range_fr, parse_time from ..schema import Event from .base import BaseConnector BASE = "https://www.laval.ca" LIST_URL = BASE + "/calendrier-activites/?findstr%5Bgroup%5D=events&findstr%5Bpage%5D={page}" PAGES_MAX = int(os.environ.get("LAVALWEB_PAGES_MAX", "60")) FICHE_MAX = int(os.environ.get("LAVALWEB_FICHE_MAX", "30")) REFRESH_AFTER = 7 * 86400 _CARD_RE = re.compile(r'class="event-item">(.*?)', re.S) _LINK_RE = re.compile(r'href="(/calendrier-activites/[^"?#]+)"') _DATE_RE = re.compile(r'event-item__date">([^<]+)<') _TITLE_RE = re.compile(r'event-item__title">([^<]+)<') _VENUE_RE = re.compile(r'event-item__localisation">\s*([^<]+)<') _IMG_RE = re.compile(r'data-lazy-src="(https://www\.laval\.ca/wp-content/[^"]+)"') _OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') class LavalWebConnector(BaseConnector): source_id = "lavalweb" request_delay = 0.5 def _parse_page(self, html: str) -> list[dict]: rows = [] for m in _CARD_RE.finditer(html): card = m.group(1) link = _LINK_RE.search(card) title = _TITLE_RE.search(card) if not link or not title: continue date_raw = _DATE_RE.search(card) # « 18 août 2026 | 18 h » (séance) ou « Du 4 juin au 22 août 2026 » start = end = start_time = None if date_raw: txt = clean_text(date_raw.group(1)) parts = txt.split("|") start, end = parse_date_range_fr(parts[0]) if len(parts) > 1: start_time = parse_time(parts[1]) venue = _VENUE_RE.search(card) img = _IMG_RE.search(card) slug = link.group(1).strip("/").split("/")[-1] rows.append({ "external_id": slug, "url": BASE + link.group(1), "title": clean_text(title.group(1)), "venue": clean_text(venue.group(1)) if venue else "", "start_date": start, "end_date": end, "start_time": start_time, "image": img.group(1) if img else "", }) return rows def _enrich_fiches(self, rows: list[dict]) -> None: """og:description des fiches — incrémental, plafonné, re-visite 7 j.""" 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} to_fetch = sorted( (u for u in current if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] def worker(url, session): m = _OG_DESC_RE.search(session.get(url, timeout=20).text) return clean_text(m.group(1)) if m else "" for url, desc in self.fetch_many(to_fetch, worker, max_workers=4).items(): cache[url] = {"ts": now, "desc": desc or ""} self.save_cache(cache) for r in rows: r["description"] = (cache.get(r["url"]) or {}).get("desc", "") def fetch(self) -> list[Event]: rows: list[dict] = [] for page in range(1, PAGES_MAX + 1): html = self.get(LIST_URL.format(page=page)).text batch = self._parse_page(html) if not batch: break rows.extend(batch) 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"]], venue=r["venue"], city="Laval", region="Laval", start_date=r["start_date"], start_time=r["start_time"], end_date=r["end_date"], image=r["image"], organizer="Ville de Laval", )) return events