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/drummondville.py : connecteur Ville de Drummondville — calendrier5# Source : https://www.drummondville.ca/culture-loisirs-et-sports/6# calendrier-des-evenements/ (WordPress + Toolset Views).7# Extraction: la vue Toolset se pagine CÔTÉ SERVEUR par ?wpv_paged=N8# (vérifié 2026-08-25) → cartes HTML (10/page) : plage de dates9# FR, heure texte (« 19 h » / « Horaire variable »), catégorie,10# titre, url fiche, image. Enrichissement par fiche11# /evenement/<slug>/ (lieu « event-place », coût « Gratuit »)12# — CACHE INCRÉMENTAL disque, plafond DRU_FICHES par sync.13# Accès : site municipal public, robots.txt permissif (wp-admin14# seulement). GET directs throttlés, identification honnête.15# Prix : la fiche publie le coût (souvent « Gratuit ») → price_label16# fidèle, jamais inventé.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import html as _html21import os22import re2324from ..normalize import clean_text, parse_date_range_fr, parse_time25from ..schema import Event26from .base import BaseConnector2728LIST_URL = ("https://www.drummondville.ca/culture-loisirs-et-sports/"29 "calendrier-des-evenements/")30PAGES_MAX = int(os.environ.get("DRU_PAGES", "12"))31FICHES_MAX = int(os.environ.get("DRU_FICHES", "40"))3233_CARD_RE = re.compile(34 r'<div class="events-list">(.*?)(?=<div class="events-list">|<!-- FIN|$)',35 re.S)36_TITLE_RE = re.compile(37 r'class="events-titre"><a href="([^"]+)"[^>]*>(.*?)</a>', re.S)38_DATE_RE = re.compile(r'class="events-jours">\s*(.*?)\s*</span>', re.S)39_HEURE_RE = re.compile(r'class="events-heure">(.*?)</p>', re.S)40_CAT_RE = re.compile(r'class="events-cat[^"]*">.*?</i>\s*(.*?)</span>', re.S)41_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"')42# fiche43_F_PLACE_RE = re.compile(44 r'class="event-place">\s*<a[^>]*>\s*<p>(.*?)</p>', re.S)45_F_COUT_RE = re.compile(r'class="events-cout">(.*?)</span>', re.S)46# id de la vue Toolset du calendrier (requis pour wpv_paged, sinon la47# pagination ressert la page 1) — extrait du FORMULAIRE DE FILTRE de la page48# (la vue « alerte » 1209 publie aussi un wpv_view_count : ne pas la prendre)49_VIEW_RE = re.compile(r'name="wpv-filter-(\d+)"')50VIEW_FALLBACK = "898"515253def _range(txt: str) -> tuple[str | None, str | None]:54 """« 27 juin au 29 août 2026 » → plage ISO (préfixe « du » requis par le55 parseur commun pour résoudre le début partiel)."""56 txt = re.sub(r"^\s*du\s+", "", txt, flags=re.I)57 return parse_date_range_fr("du " + txt)585960class DrummondvilleConnector(BaseConnector):61 source_id = "drummondville"62 request_delay = 0.86364 def _parse_page(self, html: str) -> list[dict]:65 rows: list[dict] = []66 for block in _CARD_RE.findall(html):67 t = _TITLE_RE.search(block)68 d = _DATE_RE.search(block)69 if not t or not d:70 continue71 start, end = _range(clean_text(d.group(1)))72 if not start:73 continue74 h = _HEURE_RE.search(block)75 heure = clean_text(re.sub(r"<[^>]+>", " ", h.group(1))) if h else ""76 cat = _CAT_RE.search(block)77 img = _IMG_RE.search(block)78 rows.append({79 "url": t.group(1),80 "title": clean_text(_html.unescape(81 re.sub(r"<[^>]+>", " ", t.group(2)))),82 "start": start,83 "end": end or start,84 "start_time": parse_time(heure),85 "category": clean_text(cat.group(1)) if cat else "",86 "image": img.group(1) if img else "",87 })88 return rows8990 def _fiche(self, url: str) -> dict:91 html = self.get(url).text92 out: dict = {}93 m = _F_PLACE_RE.search(html)94 if m:95 out["venue"] = clean_text(_html.unescape(96 re.sub(r"<[^>]+>", " ", m.group(1))))97 m = _F_COUT_RE.search(html)98 if m:99 out["price_label"] = clean_text(re.sub(r"<[^>]+>", " ", m.group(1)))100 return out101102 def fetch(self) -> list[Event]:103 rows: list[dict] = []104 seen_urls: set[str] = set()105 view_id = VIEW_FALLBACK106 for page in range(1, PAGES_MAX + 1):107 params = ({"wpv_view_count": view_id, "wpv_paged": str(page)}108 if page > 1 else None)109 html = self.get(LIST_URL, params=params).text110 if page == 1:111 m = _VIEW_RE.search(html)112 if m:113 view_id = m.group(1)114 batch = self._parse_page(html)115 new = [r for r in batch if r["url"] not in seen_urls]116 if not new: # Toolset ressert la dernière page → stop117 break118 seen_urls.update(r["url"] for r in new)119 rows += new120 if len(batch) < 10:121 break122 cache = self.load_cache()123 fetched = 0124 for r in rows:125 slug = r["url"].rstrip("/").rsplit("/", 1)[-1]126 r["slug"] = slug127 if slug not in cache and fetched < FICHES_MAX:128 try:129 cache[slug] = self._fiche(r["url"])130 fetched += 1131 except Exception as exc: # fiche cassée ≠ source cassée132 print(f"[sorti-ka] drummondville : fiche {slug} "133 f"ignorée : {exc}")134 if fetched:135 self.save_cache(cache)136 events: list[Event] = []137 seen: set[str] = set()138 for r in rows:139 ext = f"{r['slug']}-{r['start']}"140 if ext in seen:141 continue142 seen.add(ext)143 extra = cache.get(r["slug"]) or {}144 events.append(Event(145 source=self.source_id,146 external_id=ext,147 url=r["url"],148 title=r["title"],149 raw_categories=[r["category"]] if r["category"] else [r["title"]],150 venue=extra.get("venue", ""),151 city="Drummondville",152 region="Centre-du-Québec",153 start_date=r["start"],154 start_time=r["start_time"],155 end_date=r["end"],156 price_label=extra.get("price_label", ""),157 organizer="Ville de Drummondville",158 image=r["image"],159 ))160 return events161