SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
6.6 KB · 161 lines python
Raw Blame History
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