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/joliette.py : connecteur Ville de Joliette — événements5# Source : https://www.joliette.ca/evenements (OctoberCMS/Blanko,6# rendu serveur).7# Extraction: pages liste /evenements puis /evenements/2, /3... (arrêt à la8# première page sans carte) → cartes « c-event-card » :9# catégorie, titre, date FR (simple ou plage « Tous les10# dimanches du 26 juillet au 30 août 2026 »), lieu, url fiche,11# image. Enrichissement par fiche /evenement/<cat>/<slug>12# (heures « 19 h à 21 h », coût « Gratuit ») — CACHE13# INCRÉMENTAL disque, plafond JOL_FICHES par sync.14# Accès : site municipal public, GET directs throttlés, identification15# honnête ; robots.txt permissif.16# Prix : la fiche publie le coût (souvent « Gratuit ») → price_label17# fidèle, jamais inventé.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as _html22import os23import re2425from ..normalize import clean_text, parse_date_range_fr, parse_time26from ..schema import Event27from .base import BaseConnector2829LIST_URL = "https://www.joliette.ca/evenements"30PAGES_MAX = int(os.environ.get("JOL_PAGES", "8"))31FICHES_MAX = int(os.environ.get("JOL_FICHES", "40"))3233_CARD_RE = re.compile(34 r'<a href="(https://www\.joliette\.ca/evenement/[^"]+)" class="c-event-card[^"]*"[^>]*>(.*?)</a>',35 re.S)36_CAT_RE = re.compile(r'c-event-card__category">([^<]+)<')37_TITLE_RE = re.compile(r'c-event-card__title">([^<]+)<')38_DATE_RE = re.compile(r'c-event-card__date">([^<]+)<')39_VENUE_RE = re.compile(r'c-location__text">([^<]+)<')40_IMG_RE = re.compile(r'data-src="([^"]+)"')41# fiche42_F_ITEM_RE = re.compile(r'c-event-infos__text"><span>([^<]+)</span>')434445def _range(txt: str) -> tuple[str | None, str | None]:46 txt = re.sub(r"^\s*(tous les \w+\s+)?du\s+", "", txt, flags=re.I)47 return parse_date_range_fr("du " + txt)484950class JolietteConnector(BaseConnector):51 source_id = "joliette"52 request_delay = 0.85354 def _parse_page(self, html: str) -> list[dict]:55 rows: list[dict] = []56 for url, block in _CARD_RE.findall(html):57 t = _TITLE_RE.search(block)58 d = _DATE_RE.search(block)59 if not t or not d:60 continue61 raw_date = clean_text(_html.unescape(d.group(1)))62 start, end = _range(raw_date)63 if not start:64 continue65 cat = _CAT_RE.search(block)66 venue = _VENUE_RE.search(block)67 img = _IMG_RE.search(block)68 rows.append({69 "url": url,70 "title": clean_text(_html.unescape(t.group(1))),71 "start": start,72 "end": end or start,73 "category": clean_text(cat.group(1)) if cat else "",74 "venue": clean_text(_html.unescape(75 venue.group(1))) if venue else "",76 "image": img.group(1) if img else "",77 })78 return rows7980 def _fiche(self, url: str) -> dict:81 """Heures et coût depuis les items d'info de la fiche (icônes non82 discriminantes → classement par contenu : heure si « N h », coût si83 gratuit/$, le reste est déjà connu de la carte)."""84 out: dict = {}85 for txt in _F_ITEM_RE.findall(self.get(url).text):86 txt = clean_text(_html.unescape(txt))87 low = txt.lower()88 if "hours" not in out and re.search(r"\d{1,2}\s*h", low):89 out["hours"] = txt90 elif "price_label" not in out and (91 "gratuit" in low or "$" in txt or "payant" in low):92 out["price_label"] = txt93 return out9495 def fetch(self) -> list[Event]:96 rows: list[dict] = []97 for page in range(1, PAGES_MAX + 1):98 url = LIST_URL if page == 1 else f"{LIST_URL}/{page}"99 batch = self._parse_page(self.get(url).text)100 if not batch:101 break102 rows += batch103 cache = self.load_cache()104 fetched = 0105 for r in rows:106 slug = "/".join(r["url"].rstrip("/").rsplit("/", 2)[-2:])107 r["slug"] = slug108 if slug not in cache and fetched < FICHES_MAX:109 try:110 cache[slug] = self._fiche(r["url"])111 fetched += 1112 except Exception as exc: # fiche cassée ≠ source cassée113 print(f"[sorti-ka] joliette : fiche {slug} ignorée : {exc}")114 if fetched:115 self.save_cache(cache)116 events: list[Event] = []117 seen: set[str] = set()118 for r in rows:119 ext = f"{r['slug'].replace('/', '-')}-{r['start']}"120 if ext in seen:121 continue122 seen.add(ext)123 extra = cache.get(r["slug"]) or {}124 hours = extra.get("hours", "")125 start_time = parse_time(hours)126 end_time = None127 m = re.search(r"\bà\s+(\d{1,2}\s*h(?:\s*\d{2})?)", hours)128 if m:129 end_time = parse_time(m.group(1))130 events.append(Event(131 source=self.source_id,132 external_id=ext,133 url=r["url"],134 title=r["title"],135 raw_categories=[r["category"]] if r["category"] else [r["title"]],136 venue=r["venue"],137 city="Joliette",138 region="Lanaudière",139 start_date=r["start"],140 start_time=start_time,141 end_date=r["end"],142 end_time=end_time,143 price_label=extra.get("price_label", ""),144 organizer="Ville de Joliette",145 image=r["image"],146 ))147 return events148