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.3 KB · 147 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/saguenaysorties.py : Sorties Saguenay — portail officiel de la5#   Ville de Saguenay (Phase 3). Remplace le CSV Données Québec mort (2022).6#   Source    : https://sorties.saguenay.ca — portail événementiel municipal7#               (motorisé par les données La Vitrine culturelle, médias8#               lvc-medias.lavitrine.com ; recoupement géré par dedup_key).9#   Extraction: POST /ajax/plus (offset par pas de 36, ~300-400 séances10#               horodatées, vérifié 2026-08-19) — chaque <article> = UNE séance :11#               discipline, date FR, heure « 08 h 30 », titre, « Ville, Salle »,12#               image, data-detail-id. Détails incrémentaux POST /ajax/details13#               (cache data/saguenaysorties_cache.json, SAG_DETAIL_MAX/sync,14#               re-visite 7 j) : description + adresse civique.15#   Accès     : portail public municipal, pas de robots.txt publié (page de16#               maintenance) — volumes modestes, POST throttlés 0,8 s, UA17#               honnête, lien vers la fiche officielle /festivals?id=<id>.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import os22import re23import time2425from ..normalize import clean_text, parse_date_fr, parse_time26from ..schema import Event27from .base import BaseConnector2829BASE = "https://sorties.saguenay.ca"30AJAX_LIST = BASE + "/ajax/plus"31AJAX_DETAIL = BASE + "/ajax/details"32BATCH = 3633BATCH_MAX = int(os.environ.get("SAG_BATCH_MAX", "15"))      # ≤ 540 séances34DETAIL_MAX = int(os.environ.get("SAG_DETAIL_MAX", "40"))35REFRESH_AFTER = 7 * 864003637_DISC_RE = re.compile(r'discipline_name">\s*([^<]+?)\s*</div>', re.S)38_IMG_RE = re.compile(r'<img src="([^"]+)"')39_DATE_RE = re.compile(r'article-date[^>]*>\s*([^<]+?)\s*</p>', re.S)40_TITLE_RE = re.compile(r'data-detail-id="(\d+)"[^>]*>\s*([^<]+?)\s*</a>', re.S)41_PLACE_RE = re.compile(r'</h3>\s*<p>\s*(.*?)\s*</p>', re.S)42_ADDR_RE = re.compile(r'<br\s*/?>\s*([^<]+?)\s*</p>', re.S)434445class SaguenaySortiesConnector(BaseConnector):46    source_id = "saguenaysorties"47    request_delay = 0.84849    def _post(self, url: str, data: dict) -> str:50        self._throttle()51        resp = self.session.post(url, data=data, timeout=self.timeout)52        self._last_request = time.time()53        resp.raise_for_status()54        return resp.text5556    def _parse_batch(self, html: str) -> list[dict]:57        rows = []58        for art in html.split("<article")[1:]:59            m_title = _TITLE_RE.search(art)60            if not m_title:61                continue62            dates = _DATE_RE.findall(art)63            date_iso = parse_date_fr(dates[0]) if dates else None64            heure = parse_time(dates[1]) if len(dates) > 1 else None65            m_place = _PLACE_RE.search(art)66            city = venue = ""67            if m_place:68                place = clean_text(m_place.group(1))69                if "," in place:70                    city, venue = (p.strip() for p in place.split(",", 1))71                else:72                    city = place73            # événements multi-lieux : la carte liste TOUS les sites séparés74            # par des virgules — un « lieu » de 100+ caractères n'est pas une75            # salle, on ne garde que la ville (jamais un faux GPS de salle)76            if len(venue) > 100:77                venue = ""78            if len(city) > 60:79                city = ""80            disc = _DISC_RE.search(art)81            img = _IMG_RE.search(art)82            rows.append({83                "detail_id": m_title.group(1),84                "title": clean_text(m_title.group(2)),85                "date": date_iso, "time": heure,86                "city": city, "venue": venue,87                "discipline": clean_text(disc.group(1)) if disc else "",88                "image": img.group(1) if img else "",89            })90        return rows9192    def _details(self, ids: list[str]) -> dict:93        """description + adresse par detail_id — incrémental, plafonné."""94        cache = self.load_cache()95        now = time.time()96        to_fetch = sorted(97            (i for i in ids98             if i not in cache or now - cache[i].get("ts", 0) > REFRESH_AFTER),99            key=lambda i: cache.get(i, {}).get("ts", 0))[:DETAIL_MAX]100        for did in to_fetch:101            try:102                html = self._post(AJAX_DETAIL, {"id": did})103                m = re.search(r"</h3>\s*(.*?)\s*<h4", html, re.S)104                desc = clean_text(m.group(1)) if m else ""105                m_addr = _ADDR_RE.search(html)106                addr = clean_text(m_addr.group(1)) if m_addr else ""107                cache[did] = {"ts": now, "desc": desc, "addr": addr}108            except Exception:109                cache[did] = {"ts": now, "desc": "", "addr": ""}110        self.save_cache(cache)111        return cache112113    def fetch(self) -> list[Event]:114        rows: list[dict] = []115        for i in range(BATCH_MAX):116            html = self._post(AJAX_LIST, {"offset": str(i * BATCH), "page": "1"})117            batch = self._parse_batch(html)118            rows.extend(batch)119            if len(batch) < BATCH // 2:       # dernière page (lots partiels vus)120                break121        details = self._details(sorted({r["detail_id"] for r in rows}))122123        events: list[Event] = []124        seen: set[str] = set()125        for r in rows:126            ext = f"{r['detail_id']}-{r['date'] or 'nd'}-{r['time'] or 'nd'}"127            if ext in seen or not r["title"]:128                continue129            seen.add(ext)130            det = details.get(r["detail_id"]) or {}131            events.append(Event(132                source=self.source_id,133                external_id=ext,134                url=f"{BASE}/festivals?id={r['detail_id']}",135                title=r["title"],136                description=det.get("desc", ""),137                raw_categories=[r["discipline"]] if r["discipline"] else [],138                venue=r["venue"],139                address=det.get("addr", ""),140                city=r["city"],141                tourist_region="Saguenay–Lac-Saint-Jean",142                start_date=r["date"],143                start_time=r["time"],144                image=r["image"],145            ))146        return events147