# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/terrebonne.py : connecteur Ville de Terrebonne — calendrier # Source : https://terrebonne.ca/calendrier-des-evenements/ (WordPress). # Extraction: API REST WordPress publique /wp-json/wp/v2/event (champs ACF # start_date/end_date « YYYY-MM-DD HH:MM:SS », place.name, # isFull, hours) + taxonomie /wp-json/wp/v2/event_category. # Cloudflare bloque tout GET non navigateur (403, consigné # 2026-08-18) → chaque requête passe par Scrapfly (asp=true, # SANS rendu JS : la réponse est du JSON), ~3-5 appels/sync. # Vérifié 2026-08-25 (Scrapfly 200, JSON complet). # Accès : API publique du site municipal (aucune auth) ; robots.txt # n'interdit pas /wp-json/. Anti-bot contourné avec volumes # minimes et cadence quotidienne au plus — usage mesuré. # Prix : non publié par la source → jamais inventé. isFull=True → # status "soldout" (complet, publié par la ville). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os from ..normalize import clean_text, parse_date_iso, parse_time from ..schema import Event from .base import BaseConnector API = ("https://terrebonne.ca/wp-json/wp/v2/event" "?per_page=100&page={page}&_embed=wp:featuredmedia") TAXO = "https://terrebonne.ca/wp-json/wp/v2/event_category?per_page=100" PAGES_MAX = int(os.environ.get("TER_PAGES", "5")) class TerrebonneConnector(BaseConnector): source_id = "terrebonne" request_delay = 1.0 def _get_json_scrapfly(self, url: str): """JSON via Scrapfly (asp, sans rendu JS) — Cloudflare bloque le direct.""" return json.loads(self.get_rendered(url, render_js=False)) def _categories(self) -> dict[int, str]: try: terms = self._get_json_scrapfly(TAXO) return {int(t["id"]): clean_text(t.get("name") or "") for t in terms if t.get("id")} except Exception as exc: # taxonomie indisponible ≠ source cassée print(f"[sorti-ka] terrebonne : taxonomie ignorée : {exc}") return {} @staticmethod def _image(rec: dict) -> str: for media in (rec.get("_embedded") or {}).get("wp:featuredmedia") or []: if isinstance(media, dict) and media.get("source_url"): return media["source_url"] return "" def _rows(self) -> list[dict]: out, page = [], 1 while page <= PAGES_MAX: try: batch = self._get_json_scrapfly(API.format(page=page)) except Exception: if page > 1: # « rest_post_invalid_page_number » = fin break raise if not isinstance(batch, list) or not batch: break out += batch if len(batch) < 100: break page += 1 return out def fetch(self) -> list[Event]: rows = self._rows() cat_names = self._categories() events: list[Event] = [] for rec in rows: title = clean_text((rec.get("title") or {}).get("rendered") or "") link = rec.get("link") or "" acf = rec.get("acf") or {} start_raw = str(acf.get("start_date") or "") start = parse_date_iso(start_raw) if not title or not link or not start: continue start_time = parse_time(start_raw) if start_time == "00:00": start_time = None end_raw = str(acf.get("end_date") or "") end = parse_date_iso(end_raw) end_time = parse_time(end_raw) if end_time == "00:00": end_time = None if not start_time: # repli : champ texte « hours » de la fiche start_time = parse_time(str(acf.get("hours") or "")) place = acf.get("place") or {} venue = clean_text(place.get("name") or "") if isinstance(place, dict) else "" cats = [cat_names[c] for c in rec.get("event_category") or [] if c in cat_names] events.append(Event( source=self.source_id, external_id=str(rec.get("id")), url=link, title=title, description=clean_text( (rec.get("content") or {}).get("rendered") or ""), raw_categories=cats or [title], venue=venue, city="Terrebonne", region="Lanaudière", start_date=start, start_time=start_time, end_date=end or start, end_time=end_time, status="soldout" if acf.get("isFull") else "", organizer="Ville de Terrebonne", image=self._image(rec), )) return events