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%
5.1 KB · 121 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/terrebonne.py : connecteur Ville de Terrebonne — calendrier5#   Source    : https://terrebonne.ca/calendrier-des-evenements/ (WordPress).6#   Extraction: API REST WordPress publique /wp-json/wp/v2/event (champs ACF7#               start_date/end_date « YYYY-MM-DD HH:MM:SS », place.name,8#               isFull, hours) + taxonomie /wp-json/wp/v2/event_category.9#               Cloudflare bloque tout GET non navigateur (403, consigné10#               2026-08-18) → chaque requête passe par Scrapfly (asp=true,11#               SANS rendu JS : la réponse est du JSON), ~3-5 appels/sync.12#               Vérifié 2026-08-25 (Scrapfly 200, JSON complet).13#   Accès     : API publique du site municipal (aucune auth) ; robots.txt14#               n'interdit pas /wp-json/. Anti-bot contourné avec volumes15#               minimes et cadence quotidienne au plus — usage mesuré.16#   Prix      : non publié par la source → jamais inventé. isFull=True →17#               status "soldout" (complet, publié par la ville).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import json22import os2324from ..normalize import clean_text, parse_date_iso, parse_time25from ..schema import Event26from .base import BaseConnector2728API = ("https://terrebonne.ca/wp-json/wp/v2/event"29       "?per_page=100&page={page}&_embed=wp:featuredmedia")30TAXO = "https://terrebonne.ca/wp-json/wp/v2/event_category?per_page=100"31PAGES_MAX = int(os.environ.get("TER_PAGES", "5"))323334class TerrebonneConnector(BaseConnector):35    source_id = "terrebonne"36    request_delay = 1.03738    def _get_json_scrapfly(self, url: str):39        """JSON via Scrapfly (asp, sans rendu JS) — Cloudflare bloque le direct."""40        return json.loads(self.get_rendered(url, render_js=False))4142    def _categories(self) -> dict[int, str]:43        try:44            terms = self._get_json_scrapfly(TAXO)45            return {int(t["id"]): clean_text(t.get("name") or "")46                    for t in terms if t.get("id")}47        except Exception as exc:   # taxonomie indisponible ≠ source cassée48            print(f"[sorti-ka] terrebonne : taxonomie ignorée : {exc}")49            return {}5051    @staticmethod52    def _image(rec: dict) -> str:53        for media in (rec.get("_embedded") or {}).get("wp:featuredmedia") or []:54            if isinstance(media, dict) and media.get("source_url"):55                return media["source_url"]56        return ""5758    def _rows(self) -> list[dict]:59        out, page = [], 160        while page <= PAGES_MAX:61            try:62                batch = self._get_json_scrapfly(API.format(page=page))63            except Exception:64                if page > 1:      # « rest_post_invalid_page_number » = fin65                    break66                raise67            if not isinstance(batch, list) or not batch:68                break69            out += batch70            if len(batch) < 100:71                break72            page += 173        return out7475    def fetch(self) -> list[Event]:76        rows = self._rows()77        cat_names = self._categories()78        events: list[Event] = []79        for rec in rows:80            title = clean_text((rec.get("title") or {}).get("rendered") or "")81            link = rec.get("link") or ""82            acf = rec.get("acf") or {}83            start_raw = str(acf.get("start_date") or "")84            start = parse_date_iso(start_raw)85            if not title or not link or not start:86                continue87            start_time = parse_time(start_raw)88            if start_time == "00:00":89                start_time = None90            end_raw = str(acf.get("end_date") or "")91            end = parse_date_iso(end_raw)92            end_time = parse_time(end_raw)93            if end_time == "00:00":94                end_time = None95            if not start_time:      # repli : champ texte « hours » de la fiche96                start_time = parse_time(str(acf.get("hours") or ""))97            place = acf.get("place") or {}98            venue = clean_text(place.get("name") or "") if isinstance(place, dict) else ""99            cats = [cat_names[c] for c in rec.get("event_category") or []100                    if c in cat_names]101            events.append(Event(102                source=self.source_id,103                external_id=str(rec.get("id")),104                url=link,105                title=title,106                description=clean_text(107                    (rec.get("content") or {}).get("rendered") or ""),108                raw_categories=cats or [title],109                venue=venue,110                city="Terrebonne",111                region="Lanaudière",112                start_date=start,113                start_time=start_time,114                end_date=end or start,115                end_time=end_time,116                status="soldout" if acf.get("isFull") else "",117                organizer="Ville de Terrebonne",118                image=self._image(rec),119            ))120        return events121