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/soreltracy.py : connecteur Ville de Sorel-Tracy — événements5# Source : https://ville.sorel-tracy.qc.ca/evenements (OctoberCMS,6# rendu serveur — même famille que rouyn-noranda/joliette).7# Extraction: pages liste /evenements puis /evenements/2, /3... (arrêt à la8# première page sans carte) → cartes « thumb-event » : date FR9# (« 25 août 2026 » ou « Du 29 juillet 2026 au 26 août 2026 »),10# titre, catégorie, image, url fiche. Enrichissement par fiche11# /evenement/<slug> (heures, lieu, coût « Gratuit » — blocs12# event-info__text) — CACHE INCRÉMENTAL disque, plafond13# SOR_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 BaseConnector2829BASE = "https://ville.sorel-tracy.qc.ca"30LIST_URL = BASE + "/evenements"31PAGES_MAX = int(os.environ.get("SOR_PAGES", "8"))32FICHES_MAX = int(os.environ.get("SOR_FICHES", "40"))3334_CARD_RE = re.compile(35 r'<a href="(https://ville\.sorel-tracy\.qc\.ca/evenement/[^"]+)"[^>]*class="thumb-event[^"]*"[^>]*>(.*?)</a>',36 re.S)37_DATE_RE = re.compile(r'card__date">\s*(.*?)\s*</div>', re.S)38_TITLE_RE = re.compile(r'thumb-event__title">\s*(.*?)\s*</div>', re.S)39_CAT_RE = re.compile(r'thumb-event__category">\s*(.*?)\s*</div>', re.S)40_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"')414243class SorelTracyConnector(BaseConnector):44 source_id = "soreltracy"45 request_delay = 0.84647 def _parse_page(self, html: str) -> list[dict]:48 rows: list[dict] = []49 for url, block in _CARD_RE.findall(html):50 t = _TITLE_RE.search(block)51 d = _DATE_RE.search(block)52 if not t or not d:53 continue54 start, end = parse_date_range_fr(55 clean_text(_html.unescape(d.group(1))))56 if not start:57 continue58 cat = _CAT_RE.search(block)59 img = _IMG_RE.search(block)60 image = img.group(1) if img else ""61 if image.startswith("/"):62 image = BASE + image63 rows.append({64 "url": url,65 "title": clean_text(_html.unescape(66 re.sub(r"<[^>]+>", " ", t.group(1)))),67 "start": start,68 "end": end or start,69 "category": clean_text(_html.unescape(70 cat.group(1))) if cat else "",71 "image": image,72 })73 return rows7475 def _fiche(self, url: str) -> dict:76 """Heures, lieu et coût depuis les blocs event-info de la fiche77 (classement par contenu : heure si « N h », coût si gratuit/$,78 lieu sinon — la date est déjà connue de la carte)."""79 html = self.get(url).text80 out: dict = {}81 texts: list[str] = []82 for m in re.finditer(83 r'event-info__text"[^>]*>\s*(?:<a[^>]*>)?(.*?)(?:</a>)?\s*</div>',84 html, re.S):85 txt = clean_text(_html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))))86 if txt:87 texts.append(txt)88 for txt in texts:89 low = txt.lower()90 if "hours" not in out and re.search(r"\d{1,2}\s*h(\s|$|\d)", low):91 out["hours"] = txt92 elif "price_label" not in out and (93 "gratuit" in low or "$" in txt or "payant" in low):94 out["price_label"] = txt95 elif "venue" not in out and not parse_date_range_fr(txt)[0]:96 out["venue"] = txt97 return out9899 def fetch(self) -> list[Event]:100 rows: list[dict] = []101 for page in range(1, PAGES_MAX + 1):102 url = LIST_URL if page == 1 else f"{LIST_URL}/{page}"103 try:104 batch = self._parse_page(self.get(url).text)105 except Exception:106 if page > 1: # fin de pagination (404)107 break108 raise109 if not batch:110 break111 rows += batch112 cache = self.load_cache()113 fetched = 0114 for r in rows:115 slug = r["url"].rstrip("/").rsplit("/", 1)[-1]116 r["slug"] = slug117 if slug not in cache and fetched < FICHES_MAX:118 try:119 cache[slug] = self._fiche(r["url"])120 fetched += 1121 except Exception as exc: # fiche cassée ≠ source cassée122 print(f"[sorti-ka] soreltracy : fiche {slug} ignorée : {exc}")123 if fetched:124 self.save_cache(cache)125 events: list[Event] = []126 seen: set[str] = set()127 for r in rows:128 ext = f"{r['slug']}-{r['start']}"129 if ext in seen:130 continue131 seen.add(ext)132 extra = cache.get(r["slug"]) or {}133 hours = extra.get("hours", "")134 start_time = parse_time(hours)135 end_time = None136 m = re.search(r"\bà\s+(\d{1,2}\s*h(?:\s*\d{2})?)", hours)137 if m:138 end_time = parse_time(m.group(1))139 events.append(Event(140 source=self.source_id,141 external_id=ext,142 url=r["url"],143 title=r["title"],144 raw_categories=[r["category"]] if r["category"] else [r["title"]],145 venue=extra.get("venue", ""),146 city="Sorel-Tracy",147 region="Montérégie",148 start_date=r["start"],149 start_time=start_time,150 end_date=r["end"],151 end_time=end_time,152 price_label=extra.get("price_label", ""),153 organizer="Ville de Sorel-Tracy",154 image=r["image"],155 ))156 return events157