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/brossard.py : connecteur Ville de Brossard — calendrier des5# événements municipaux (brossard.ca, Montérégie)6# Source : API REST WordPress publique — type de contenu `city-event`7# (~400 événements : bibliothèque, conférences, ateliers,8# spectacles familiaux). Fraîcheur vérifiée 2026-08 (fiches9# publiées en août 2026 pour des dates jusqu'en décembre 2026).10# Extraction: 1) liste JSON /wp-json/wp/v2/city-event (paginée, champs11# title/link/content/modified) ; 2) dates + heures par fiche :12# bannière « Cet événement aura lieu le 6 décembre 2026, de13# 15 h 00 à 16 h 00 » (fiches ville) ou JSON-LD Event14# (fiches biblio.brossard.ca, redirection suivie) — cache disque15# incrémental data/brossard_cache.json, plafond16# BROSSARD_DETAIL_MAX fiches/sync, re-visite 7 jours.17# Accès : API REST WordPress publique (aucune authentification), GET18# throttlés, identification honnête. robots.txt : Allow.19# Prix : non publié par la source → jamais inventé.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import os25import re26import time2728from ..normalize import clean_text, parse_date_fr, parse_time29from ..schema import Event30from .base import BaseConnector3132API = "https://brossard.ca/wp-json/wp/v2/city-event"33PAGE_SIZE = 10034DETAIL_MAX = int(os.environ.get("BROSSARD_DETAIL_MAX", "80")) # fiches/sync35REFRESH_AFTER = 7 * 864003637_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',38 re.S)39_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')40# « Cet événement aura lieu le 6 décembre 2026, de 15 h 00 à 16 h 00 »41# « … aura lieu le 6 décembre 2026, à 15 h 00 »42# « … aura lieu du 3 juillet 2026 au 5 juillet 2026 »43_BANNER_RE = re.compile(44 r"aura lieu\s+(?:le\s+(?P<jour>\d{1,2}(?:er)?\s+\S+\s+\d{4})"45 r"(?:,?\s*de\s+(?P<heure>\d{1,2}\s*h\s*(?:\d{2})?)"46 r"\s*(?:à|-|–)\s*(?P<fin>\d{1,2}\s*h\s*(?:\d{2})?)"47 r"|,?\s*à\s+(?P<heure2>\d{1,2}\s*h\s*(?:\d{2})?))?"48 r"|du\s+(?P<du>\d{1,2}(?:er)?\s+\S+\s+\d{4})\s+au\s+"49 r"(?P<au>\d{1,2}(?:er)?\s+\S+\s+\d{4}))", re.I)505152class BrossardConnector(BaseConnector):53 source_id = "brossard"54 request_delay = 0.85556 # -- liste REST ---------------------------------------------------------57 def _list(self) -> list[dict]:58 out, page = [], 159 while True:60 try:61 batch = self.get_json(API, params={62 "per_page": PAGE_SIZE, "page": page, "orderby": "modified",63 "order": "desc",64 "_fields": "id,link,title,content,modified,type-event"})65 except Exception:66 if page > 1: # « rest_post_invalid_page_number » = fin67 break68 raise69 if not isinstance(batch, list) or not batch:70 break71 out += batch72 if len(batch) < PAGE_SIZE:73 break74 page += 175 return out7677 # -- taxonomie type-event : id → nom (1 requête, ~11 termes) ---------------78 def _type_event_map(self) -> dict[int, str]:79 """Vraies catégories WordPress (« Bibliothèque — adultes »,80 « Événements de la Ville »…) — remplace l'heuristique par titre."""81 try:82 terms = self.get_json(83 "https://brossard.ca/wp-json/wp/v2/type-event",84 params={"per_page": 100, "_fields": "id,name"})85 return {int(t["id"]): clean_text(t.get("name") or "")86 for t in terms if t.get("id")}87 except Exception as exc: # taxonomie indisponible ≠ source cassée88 print(f"[sorti-ka] brossard : taxonomie type-event ignorée : {exc}")89 return {}9091 # -- fiche : dates/heure/lieu (bannière ville OU JSON-LD biblio) ----------92 def _parse_fiche(self, html: str) -> dict:93 info: dict = {}94 for block in _LD_RE.findall(html):95 if '"Event"' not in block:96 continue97 try:98 ld = json.loads(block)99 except ValueError:100 continue101 if isinstance(ld, list):102 ld = ld[0] if ld else {}103 if ld.get("@type") != "Event":104 continue105 start, end = str(ld.get("startDate") or ""), str(ld.get("endDate") or "")106 info["start_date"] = start[:10] or None107 info["end_date"] = end[:10] or None108 info["time"] = parse_time(start)109 info["end_time"] = parse_time(end)110 loc = ld.get("location") or []111 loc = loc[0] if isinstance(loc, list) and loc else loc112 if isinstance(loc, dict):113 info["venue"] = clean_text(str(loc.get("name") or ""))114 addr = loc.get("address") or {}115 if isinstance(addr, dict):116 info["address"] = clean_text(str(addr.get("streetAddress") or ""))117 info["postal_code"] = clean_text(str(addr.get("postalCode") or ""))118 if ld.get("image"):119 info["image"] = str(ld["image"])120 return info121 m = _BANNER_RE.search(clean_text(html[:200000]))122 if m:123 if m.group("jour"):124 info["start_date"] = parse_date_fr(m.group("jour"))125 info["end_date"] = info["start_date"]126 info["time"] = parse_time(m.group("heure")127 or m.group("heure2") or "")128 info["end_time"] = parse_time(m.group("fin") or "")129 else:130 info["start_date"] = parse_date_fr(m.group("du"))131 info["end_date"] = parse_date_fr(m.group("au"))132 img = _OG_IMG_RE.search(html)133 if img:134 info.setdefault("image", img.group(1))135 return info136137 def _enrich(self, records: list[dict]) -> dict:138 """Cache url → {fetched_at, start_date, …}, incrémental et plafonné.139 Fiches jamais vues d'abord (les plus récemment modifiées en premier —140 la liste REST est déjà triée), puis re-visite roulante après 7 jours."""141 cache = self.load_cache()142 current = {r["link"] for r in records}143 cache = {u: c for u, c in cache.items() if u in current}144 now = time.time()145 candidates = [r["link"] for r in records146 if r["link"] not in cache147 or now - cache[r["link"]].get("fetched_at", 0) > REFRESH_AFTER]148 for url in candidates[:DETAIL_MAX]:149 try:150 info = self._parse_fiche(self.get(url).text)151 except Exception: # fiche cassée/404 : on ne bloque pas la source152 print(f"[sorti-ka] brossard : fiche ignorée {url}")153 info = {}154 info["fetched_at"] = now155 cache[url] = info156 self.save_cache(cache)157 return cache158159 def fetch(self) -> list[Event]:160 records = [r for r in self._list()161 if r.get("link") and (r.get("title") or {}).get("rendered")]162 cache = self._enrich(records)163 type_names = self._type_event_map()164 events: list[Event] = []165 for rec in records:166 info = cache.get(rec["link"]) or {}167 title = clean_text(rec["title"]["rendered"])168 # repli : beaucoup de titres portent la date « (27 novembre 2026) »169 start = info.get("start_date") or parse_date_fr(title)170 if not start:171 continue # fiche pas encore visitée et titre sans date172 cats = [type_names[t] for t in rec.get("type-event") or []173 if t in type_names]174 events.append(Event(175 source=self.source_id,176 external_id=str(rec["id"]),177 url=rec["link"],178 title=title,179 # description intégrale (la coupe à 600 caractères de la180 # vague 1 amputait 90 % des fiches — Phase 2)181 description=clean_text(182 (rec.get("content") or {}).get("rendered") or ""),183 raw_categories=cats or [title],184 venue=info.get("venue", ""),185 address=info.get("address", ""),186 postal_code=info.get("postal_code", ""),187 city="Brossard",188 region="Montérégie",189 start_date=start,190 start_time=info.get("time"),191 end_date=info.get("end_date") or start,192 end_time=info.get("end_time"),193 organizer="Ville de Brossard",194 image=info.get("image", ""),195 ))196 return events197