# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/soreltracy.py : connecteur Ville de Sorel-Tracy — événements # Source : https://ville.sorel-tracy.qc.ca/evenements (OctoberCMS, # rendu serveur — même famille que rouyn-noranda/joliette). # Extraction: pages liste /evenements puis /evenements/2, /3... (arrêt à la # première page sans carte) → cartes « thumb-event » : date FR # (« 25 août 2026 » ou « Du 29 juillet 2026 au 26 août 2026 »), # titre, catégorie, image, url fiche. Enrichissement par fiche # /evenement/ (heures, lieu, coût « Gratuit » — blocs # event-info__text) — CACHE INCRÉMENTAL disque, plafond # SOR_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 BASE = "https://ville.sorel-tracy.qc.ca" LIST_URL = BASE + "/evenements" PAGES_MAX = int(os.environ.get("SOR_PAGES", "8")) FICHES_MAX = int(os.environ.get("SOR_FICHES", "40")) _CARD_RE = re.compile( r']*class="thumb-event[^"]*"[^>]*>(.*?)', re.S) _DATE_RE = re.compile(r'card__date">\s*(.*?)\s*', re.S) _TITLE_RE = re.compile(r'thumb-event__title">\s*(.*?)\s*', re.S) _CAT_RE = re.compile(r'thumb-event__category">\s*(.*?)\s*', re.S) _IMG_RE = re.compile(r']+src="([^"]+)"') class SorelTracyConnector(BaseConnector): source_id = "soreltracy" 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 start, end = parse_date_range_fr( clean_text(_html.unescape(d.group(1)))) if not start: continue cat = _CAT_RE.search(block) img = _IMG_RE.search(block) image = img.group(1) if img else "" if image.startswith("/"): image = BASE + image rows.append({ "url": url, "title": clean_text(_html.unescape( re.sub(r"<[^>]+>", " ", t.group(1)))), "start": start, "end": end or start, "category": clean_text(_html.unescape( cat.group(1))) if cat else "", "image": image, }) return rows def _fiche(self, url: str) -> dict: """Heures, lieu et coût depuis les blocs event-info de la fiche (classement par contenu : heure si « N h », coût si gratuit/$, lieu sinon — la date est déjà connue de la carte).""" html = self.get(url).text out: dict = {} texts: list[str] = [] for m in re.finditer( r'event-info__text"[^>]*>\s*(?:]*>)?(.*?)(?:)?\s*', html, re.S): txt = clean_text(_html.unescape(re.sub(r"<[^>]+>", " ", m.group(1)))) if txt: texts.append(txt) for txt in texts: low = txt.lower() if "hours" not in out and re.search(r"\d{1,2}\s*h(\s|$|\d)", 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 elif "venue" not in out and not parse_date_range_fr(txt)[0]: out["venue"] = 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}" try: batch = self._parse_page(self.get(url).text) except Exception: if page > 1: # fin de pagination (404) break raise if not batch: break rows += batch cache = self.load_cache() fetched = 0 for r in rows: slug = r["url"].rstrip("/").rsplit("/", 1)[-1] 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] soreltracy : 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']}-{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=extra.get("venue", ""), city="Sorel-Tracy", region="Montérégie", 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 Sorel-Tracy", image=r["image"], )) return events