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/lavalweb.py : Ville de Laval — calendrier VIVANT laval.ca (Phase 3)5# Remplace la source « laval » (jeu Données Québec MORT À LA SOURCE depuis6# 2025-12, consigné Phase 2). L'ancien connecteur reste branché (réveil7# automatique si la Ville republie) ; le recoupement éventuel est géré par8# dedup_key.9# Source : https://www.laval.ca/calendrier-activites/ — WordPress, listes10# rendues serveur, pagination ?findstr[group]=events&findstr[page]=N11# (~58 pages × 8 cartes ≈ 460 activités à venir, vérifié 2026-08-19).12# Extraction: cartes .event-item (date FR « 18 août 2026 | 18 h », titre,13# lieu, lien, image lazy) + fiches détail incrémentales (cache14# data/lavalweb_cache.json, LAVALWEB_FICHE_MAX/sync, re-visite15# 7 j) : og:description. GPS via annuaire de salles/géocodeur.16# Accès : robots.txt laval.ca : « Disallow: /wp-admin/ » seulement — les17# pages calendrier sont libres. GET throttlés 0,5 s, UA honnête.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import os22import re23import time2425from ..normalize import clean_text, parse_date_range_fr, parse_time26from ..schema import Event27from .base import BaseConnector2829BASE = "https://www.laval.ca"30LIST_URL = BASE + "/calendrier-activites/?findstr%5Bgroup%5D=events&findstr%5Bpage%5D={page}"31PAGES_MAX = int(os.environ.get("LAVALWEB_PAGES_MAX", "60"))32FICHE_MAX = int(os.environ.get("LAVALWEB_FICHE_MAX", "30"))33REFRESH_AFTER = 7 * 864003435_CARD_RE = re.compile(r'class="event-item">(.*?)</a>', re.S)36_LINK_RE = re.compile(r'href="(/calendrier-activites/[^"?#]+)"')37_DATE_RE = re.compile(r'event-item__date">([^<]+)<')38_TITLE_RE = re.compile(r'event-item__title"><span>([^<]+)<')39_VENUE_RE = re.compile(r'event-item__localisation">\s*<span>([^<]+)<')40_IMG_RE = re.compile(r'data-lazy-src="(https://www\.laval\.ca/wp-content/[^"]+)"')41_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')424344class LavalWebConnector(BaseConnector):45 source_id = "lavalweb"46 request_delay = 0.54748 def _parse_page(self, html: str) -> list[dict]:49 rows = []50 for m in _CARD_RE.finditer(html):51 card = m.group(1)52 link = _LINK_RE.search(card)53 title = _TITLE_RE.search(card)54 if not link or not title:55 continue56 date_raw = _DATE_RE.search(card)57 # « 18 août 2026 | 18 h » (séance) ou « Du 4 juin au 22 août 2026 »58 start = end = start_time = None59 if date_raw:60 txt = clean_text(date_raw.group(1))61 parts = txt.split("|")62 start, end = parse_date_range_fr(parts[0])63 if len(parts) > 1:64 start_time = parse_time(parts[1])65 venue = _VENUE_RE.search(card)66 img = _IMG_RE.search(card)67 slug = link.group(1).strip("/").split("/")[-1]68 rows.append({69 "external_id": slug,70 "url": BASE + link.group(1),71 "title": clean_text(title.group(1)),72 "venue": clean_text(venue.group(1)) if venue else "",73 "start_date": start, "end_date": end, "start_time": start_time,74 "image": img.group(1) if img else "",75 })76 return rows7778 def _enrich_fiches(self, rows: list[dict]) -> None:79 """og:description des fiches — incrémental, plafonné, re-visite 7 j."""80 cache = self.load_cache()81 now = time.time()82 current = {r["url"] for r in rows}83 cache = {u: c for u, c in cache.items() if u in current}84 to_fetch = sorted(85 (u for u in current86 if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER),87 key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX]8889 def worker(url, session):90 m = _OG_DESC_RE.search(session.get(url, timeout=20).text)91 return clean_text(m.group(1)) if m else ""9293 for url, desc in self.fetch_many(to_fetch, worker, max_workers=4).items():94 cache[url] = {"ts": now, "desc": desc or ""}95 self.save_cache(cache)96 for r in rows:97 r["description"] = (cache.get(r["url"]) or {}).get("desc", "")9899 def fetch(self) -> list[Event]:100 rows: list[dict] = []101 for page in range(1, PAGES_MAX + 1):102 html = self.get(LIST_URL.format(page=page)).text103 batch = self._parse_page(html)104 if not batch:105 break106 rows.extend(batch)107 self._enrich_fiches(rows)108109 events: list[Event] = []110 seen: set[str] = set()111 for r in rows:112 if r["external_id"] in seen:113 continue114 seen.add(r["external_id"])115 events.append(Event(116 source=self.source_id,117 external_id=r["external_id"],118 url=r["url"],119 title=r["title"],120 description=r.get("description", ""),121 raw_categories=[r["title"]],122 venue=r["venue"],123 city="Laval",124 region="Laval",125 start_date=r["start_date"],126 start_time=r["start_time"],127 end_date=r["end_date"],128 image=r["image"],129 organizer="Ville de Laval",130 ))131 return events132