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.4 KB · 157 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/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