# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/joliette.py : connecteur Ville de Joliette — événements # Source : https://www.joliette.ca/evenements (OctoberCMS/Blanko, # rendu serveur). # Extraction: pages liste /evenements puis /evenements/2, /3... (arrêt à la # première page sans carte) → cartes « c-event-card » : # catégorie, titre, date FR (simple ou plage « Tous les # dimanches du 26 juillet au 30 août 2026 »), lieu, url fiche, # image. Enrichissement par fiche /evenement// # (heures « 19 h à 21 h », coût « Gratuit ») — CACHE # INCRÉMENTAL disque, plafond JOL_FICHES par sync. # Accès : site municipal public, GET directs throttlés, identification # honnête ; robots.txt permissif. # Prix : la fiche publie le coût (souvent « Gratuit ») → price_label # fidèle, jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from ..normalize import clean_text, parse_date_range_fr, parse_time from ..schema import Event from .base import BaseConnector LIST_URL = "https://www.joliette.ca/evenements" PAGES_MAX = int(os.environ.get("JOL_PAGES", "8")) FICHES_MAX = int(os.environ.get("JOL_FICHES", "40")) _CARD_RE = re.compile( r']*>(.*?)', re.S) _CAT_RE = re.compile(r'c-event-card__category">([^<]+)<') _TITLE_RE = re.compile(r'c-event-card__title">([^<]+)<') _DATE_RE = re.compile(r'c-event-card__date">([^<]+)<') _VENUE_RE = re.compile(r'c-location__text">([^<]+)<') _IMG_RE = re.compile(r'data-src="([^"]+)"') # fiche _F_ITEM_RE = re.compile(r'c-event-infos__text">([^<]+)') def _range(txt: str) -> tuple[str | None, str | None]: txt = re.sub(r"^\s*(tous les \w+\s+)?du\s+", "", txt, flags=re.I) return parse_date_range_fr("du " + txt) class JolietteConnector(BaseConnector): source_id = "joliette" request_delay = 0.8 def _parse_page(self, html: str) -> list[dict]: rows: list[dict] = [] for url, block in _CARD_RE.findall(html): t = _TITLE_RE.search(block) d = _DATE_RE.search(block) if not t or not d: continue raw_date = clean_text(_html.unescape(d.group(1))) start, end = _range(raw_date) if not start: continue cat = _CAT_RE.search(block) venue = _VENUE_RE.search(block) img = _IMG_RE.search(block) rows.append({ "url": url, "title": clean_text(_html.unescape(t.group(1))), "start": start, "end": end or start, "category": clean_text(cat.group(1)) if cat else "", "venue": clean_text(_html.unescape( venue.group(1))) if venue else "", "image": img.group(1) if img else "", }) return rows def _fiche(self, url: str) -> dict: """Heures et coût depuis les items d'info de la fiche (icônes non discriminantes → classement par contenu : heure si « N h », coût si gratuit/$, le reste est déjà connu de la carte).""" out: dict = {} for txt in _F_ITEM_RE.findall(self.get(url).text): txt = clean_text(_html.unescape(txt)) low = txt.lower() if "hours" not in out and re.search(r"\d{1,2}\s*h", low): out["hours"] = txt elif "price_label" not in out and ( "gratuit" in low or "$" in txt or "payant" in low): out["price_label"] = txt return out def fetch(self) -> list[Event]: rows: list[dict] = [] for page in range(1, PAGES_MAX + 1): url = LIST_URL if page == 1 else f"{LIST_URL}/{page}" batch = self._parse_page(self.get(url).text) if not batch: break rows += batch cache = self.load_cache() fetched = 0 for r in rows: slug = "/".join(r["url"].rstrip("/").rsplit("/", 2)[-2:]) r["slug"] = slug if slug not in cache and fetched < FICHES_MAX: try: cache[slug] = self._fiche(r["url"]) fetched += 1 except Exception as exc: # fiche cassée ≠ source cassée print(f"[sorti-ka] joliette : fiche {slug} ignorée : {exc}") if fetched: self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for r in rows: ext = f"{r['slug'].replace('/', '-')}-{r['start']}" if ext in seen: continue seen.add(ext) extra = cache.get(r["slug"]) or {} hours = extra.get("hours", "") start_time = parse_time(hours) end_time = None m = re.search(r"\bà\s+(\d{1,2}\s*h(?:\s*\d{2})?)", hours) if m: end_time = parse_time(m.group(1)) events.append(Event( source=self.source_id, external_id=ext, url=r["url"], title=r["title"], raw_categories=[r["category"]] if r["category"] else [r["title"]], venue=r["venue"], city="Joliette", region="Lanaudière", start_date=r["start"], start_time=start_time, end_date=r["end"], end_time=end_time, price_label=extra.get("price_label", ""), organizer="Ville de Joliette", image=r["image"], )) return events