# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/monteregie.py : Tourisme Montérégie — événements (ATR, Phase 4) # Source : https://www.tourisme-monteregie.qc.ca — WordPress dont l'API # REST publique expose le CPT `cpt_event` (78 fiches publiées, # X-WP-TotalPages=1, revérifié 2026-08-25). Gisement Tourinsoft # (acf.syndic_object_id EVENMTOV… — même famille que le SIT # Québec ; recoupement géré par dedup_key, mais l'ATR ajoute # GPS, image, site web et description que le flux SITQ ne # publie pas). # Extraction: API JSON /wp-json/wp/v2/cpt_event (paginée per_page=100) → # titre, description (content, fallback excerpt), plage de # dates FR (« Du 18 septembre au 20 septembre 2026 »), lat/long, # site web, image. ENRICHI (2026-08-25) : taxonomie # event-categorie (1 GET/sync) → catégories brutes (« Marchés # de Noël », « Festival et autres événements »). Fiches détail # incrémentales (cache data/monteregie_cache.json, # MTG_FICHE_MAX/sync, re-visite 7 j) : bloc JSON-LD Event → # adresse civique, ville, code postal. Ni heure ni tarif # structurés publiés (vérifié sur fiches) → jamais inventés. # Accès : API REST WordPress publique (aucune auth), robots.txt sans # interdiction sur /wp-json/ — GET throttlés, UA honnête. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text, parse_date_range_fr from ..schema import Event from .base import BaseConnector BASE = "https://www.tourisme-monteregie.qc.ca" API = BASE + "/wp-json/wp/v2/cpt_event" TAX_API = BASE + "/wp-json/wp/v2/event-categorie" PER_PAGE = 100 PAGE_MAX = 5 # garde-fou (78 fiches en 2026-08) FICHE_MAX = int(os.environ.get("MTG_FICHE_MAX", "15")) REFRESH_AFTER = 7 * 86400 _LDJSON_RE = re.compile( r'', re.S) class MonteregieConnector(BaseConnector): source_id = "monteregie" request_delay = 0.8 def _list_events(self) -> list[dict]: rows: list[dict] = [] for page in range(1, PAGE_MAX + 1): batch = self.get_json(API, params={ "per_page": PER_PAGE, "page": page}) if not isinstance(batch, list) or not batch: break rows.extend(batch) if len(batch) < PER_PAGE: break return rows def _categories(self) -> dict[int, str]: """Taxonomie event-categorie : {id: libellé} (1 GET, jamais bloquant).""" try: terms = self.get_json(TAX_API, params={"per_page": 100}) return {t["id"]: clean_text(t.get("name", "")) for t in terms if isinstance(t, dict) and t.get("id")} except Exception: return {} def _parse_fiche(self, html: str) -> dict: """Adresse/ville/salle du bloc JSON-LD Event de la fiche (thème ATR).""" for m in _LDJSON_RE.findall(html): try: data = json.loads(m) except ValueError: continue items = data if isinstance(data, list) else [data] for item in items: if not isinstance(item, dict) or item.get("@type") != "Event": continue loc = item.get("location") or {} addr = loc.get("address") or {} venue = clean_text(loc.get("name", "")) # le thème recopie le nom de l'événement comme nom de lieu — # on ne garde le lieu que s'il diffère du titre (jamais inventé) if venue and venue.lower() == clean_text( item.get("name", "")).lower(): venue = "" return { "venue": venue, "address": clean_text(addr.get("streetAddress", "")), "city": clean_text(addr.get("addressLocality", "")), "postal_code": clean_text(addr.get("postalCode", "")), } return {} def fetch(self) -> list[Event]: rows = self._list_events() categories = self._categories() cache = self.load_cache() now = time.time() current = {str(r.get("id")) for r in rows} cache = {k: v for k, v in cache.items() if k in current} to_fetch = sorted( (r for r in rows if str(r.get("id")) not in cache or now - cache[str(r.get("id"))].get("ts", 0) > REFRESH_AFTER), key=lambda r: cache.get(str(r.get("id")), {}).get("ts", 0))[:FICHE_MAX] for row in to_fetch: eid, link = str(row.get("id")), row.get("link", "") detail: dict = {} try: if link: detail = self._parse_fiche(self.get(link).text) except Exception: detail = {} cache[eid] = {"ts": now, "detail": detail} self.save_cache(cache) events: list[Event] = [] for row in rows: eid = str(row.get("id") or "") acf = row.get("acf") or {} if not eid or not isinstance(acf, dict): continue start, end = parse_date_range_fr(acf.get("date", "")) images = acf.get("images") or [] image = "" if isinstance(images, list) and images and isinstance(images[0], dict): image = images[0].get("url", "") detail = cache.get(eid, {}).get("detail") or {} title = clean_text((row.get("title") or {}).get("rendered", "")) description = clean_text( (row.get("content") or {}).get("rendered", "")) or clean_text( (row.get("excerpt") or {}).get("rendered", "")) raw_cats = [categories[t] for t in (row.get("event-categorie") or []) if t in categories] events.append(Event( source=self.source_id, external_id=eid, url=row.get("link", ""), title=title, description=description, raw_categories=raw_cats + [title], venue=detail.get("venue", ""), address=detail.get("address", ""), city=detail.get("city", ""), postal_code=detail.get("postal_code", ""), tourist_region="Montérégie", lat=acf.get("lat") or None, lng=acf.get("long") or None, start_date=start, end_date=end, website=acf.get("website", "") or "", image=image, )) return events