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.0 KB · 148 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/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