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/monteregie.py : Tourisme Montérégie — événements (ATR, Phase 4)5# Source : https://www.tourisme-monteregie.qc.ca — WordPress dont l'API6# REST publique expose le CPT `cpt_event` (78 fiches publiées,7# X-WP-TotalPages=1, revérifié 2026-08-25). Gisement Tourinsoft8# (acf.syndic_object_id EVENMTOV… — même famille que le SIT9# Québec ; recoupement géré par dedup_key, mais l'ATR ajoute10# GPS, image, site web et description que le flux SITQ ne11# publie pas).12# Extraction: API JSON /wp-json/wp/v2/cpt_event (paginée per_page=100) →13# titre, description (content, fallback excerpt), plage de14# dates FR (« Du 18 septembre au 20 septembre 2026 »), lat/long,15# site web, image. ENRICHI (2026-08-25) : taxonomie16# event-categorie (1 GET/sync) → catégories brutes (« Marchés17# de Noël », « Festival et autres événements »). Fiches détail18# incrémentales (cache data/monteregie_cache.json,19# MTG_FICHE_MAX/sync, re-visite 7 j) : bloc JSON-LD Event →20# adresse civique, ville, code postal. Ni heure ni tarif21# structurés publiés (vérifié sur fiches) → jamais inventés.22# Accès : API REST WordPress publique (aucune auth), robots.txt sans23# interdiction sur /wp-json/ — GET throttlés, UA honnête.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import json28import os29import re30import time3132from ..normalize import clean_text, parse_date_range_fr33from ..schema import Event34from .base import BaseConnector3536BASE = "https://www.tourisme-monteregie.qc.ca"37API = BASE + "/wp-json/wp/v2/cpt_event"38TAX_API = BASE + "/wp-json/wp/v2/event-categorie"39PER_PAGE = 10040PAGE_MAX = 5 # garde-fou (78 fiches en 2026-08)41FICHE_MAX = int(os.environ.get("MTG_FICHE_MAX", "15"))42REFRESH_AFTER = 7 * 864004344_LDJSON_RE = re.compile(45 r'<script type="application/ld\+json"[^>]*>(.*?)</script>', re.S)464748class MonteregieConnector(BaseConnector):49 source_id = "monteregie"50 request_delay = 0.85152 def _list_events(self) -> list[dict]:53 rows: list[dict] = []54 for page in range(1, PAGE_MAX + 1):55 batch = self.get_json(API, params={56 "per_page": PER_PAGE, "page": page})57 if not isinstance(batch, list) or not batch:58 break59 rows.extend(batch)60 if len(batch) < PER_PAGE:61 break62 return rows6364 def _categories(self) -> dict[int, str]:65 """Taxonomie event-categorie : {id: libellé} (1 GET, jamais bloquant)."""66 try:67 terms = self.get_json(TAX_API, params={"per_page": 100})68 return {t["id"]: clean_text(t.get("name", ""))69 for t in terms if isinstance(t, dict) and t.get("id")}70 except Exception:71 return {}7273 def _parse_fiche(self, html: str) -> dict:74 """Adresse/ville/salle du bloc JSON-LD Event de la fiche (thème ATR)."""75 for m in _LDJSON_RE.findall(html):76 try:77 data = json.loads(m)78 except ValueError:79 continue80 items = data if isinstance(data, list) else [data]81 for item in items:82 if not isinstance(item, dict) or item.get("@type") != "Event":83 continue84 loc = item.get("location") or {}85 addr = loc.get("address") or {}86 venue = clean_text(loc.get("name", ""))87 # le thème recopie le nom de l'événement comme nom de lieu —88 # on ne garde le lieu que s'il diffère du titre (jamais inventé)89 if venue and venue.lower() == clean_text(90 item.get("name", "")).lower():91 venue = ""92 return {93 "venue": venue,94 "address": clean_text(addr.get("streetAddress", "")),95 "city": clean_text(addr.get("addressLocality", "")),96 "postal_code": clean_text(addr.get("postalCode", "")),97 }98 return {}99100 def fetch(self) -> list[Event]:101 rows = self._list_events()102 categories = self._categories()103 cache = self.load_cache()104 now = time.time()105 current = {str(r.get("id")) for r in rows}106 cache = {k: v for k, v in cache.items() if k in current}107 to_fetch = sorted(108 (r for r in rows109 if str(r.get("id")) not in cache110 or now - cache[str(r.get("id"))].get("ts", 0) > REFRESH_AFTER),111 key=lambda r: cache.get(str(r.get("id")), {}).get("ts", 0))[:FICHE_MAX]112 for row in to_fetch:113 eid, link = str(row.get("id")), row.get("link", "")114 detail: dict = {}115 try:116 if link:117 detail = self._parse_fiche(self.get(link).text)118 except Exception:119 detail = {}120 cache[eid] = {"ts": now, "detail": detail}121 self.save_cache(cache)122123 events: list[Event] = []124 for row in rows:125 eid = str(row.get("id") or "")126 acf = row.get("acf") or {}127 if not eid or not isinstance(acf, dict):128 continue129 start, end = parse_date_range_fr(acf.get("date", ""))130 images = acf.get("images") or []131 image = ""132 if isinstance(images, list) and images and isinstance(images[0], dict):133 image = images[0].get("url", "")134 detail = cache.get(eid, {}).get("detail") or {}135 title = clean_text((row.get("title") or {}).get("rendered", ""))136 description = clean_text(137 (row.get("content") or {}).get("rendered", "")) or clean_text(138 (row.get("excerpt") or {}).get("rendered", ""))139 raw_cats = [categories[t] for t in (row.get("event-categorie") or [])140 if t in categories]141 events.append(Event(142 source=self.source_id,143 external_id=eid,144 url=row.get("link", ""),145 title=title,146 description=description,147 raw_categories=raw_cats + [title],148 venue=detail.get("venue", ""),149 address=detail.get("address", ""),150 city=detail.get("city", ""),151 postal_code=detail.get("postal_code", ""),152 tourist_region="Montérégie",153 lat=acf.get("lat") or None,154 lng=acf.get("long") or None,155 start_date=start,156 end_date=end,157 website=acf.get("website", "") or "",158 image=image,159 ))160 return events161