Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
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