# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/laurentides.py : Tourisme Laurentides (ATR) — RÉHABILITÉE (vague
# ATR 2026-08 ; écartée Phase 3 pour WAF 403 : la chaîne anti-bot passe)
# Source : laurentides.com/evenements — calendrier officiel de l'ATR
# (WordPress + Modern Events Calendar « mec-events »).
# Extraction: API REST WordPress /wp-json/wp/v2/mec-events (paginée
# per_page=100, ~228 fiches, X-WP-TotalPages vérifié 2026-08-25)
# via BaseConnector.get() dont l'AUTO-ESCALADE anti-bot
# (Oxylabs → Scrapfly ASP → Bright Data) perce le WAF qui bloque
# le GET direct (403). La liste donne titre, description
# (content), image (_embedded featuredmedia), lien, modified.
# MEC n'expose PAS les dates en REST → fiches détail
# incrémentales (cache data/laurentides_cache.json, plafond
# LAU_FICHE_MAX/sync, re-visite si `modified` change) : dates
# (mec-start/end-date-label « Déc 05 2026 »), tarif
# (mec-events-event-cost), lieu + adresse
# (mec-single-event-location).
# Accès : API REST publique (aucune auth) ; le WAF de l'hébergeur
# bloque les UA non-navigateur → escalade proxy/Scrapfly,
# requêtes throttlées et plafonnées (coût par requête), lien
# vers la fiche originale. Prix : texte source, jamais inventé.
# -----------------------------------------------------------------------------
from __future__ import annotations
import os
import re
import time
from ..normalize import clean_text, parse_date_fr, utc_to_local
from ..schema import Event
from .base import BaseConnector
BASE = "https://www.laurentides.com"
API = BASE + "/wp-json/wp/v2/mec-events"
PER_PAGE = 100
LAU_PAGE_MAX = int(os.environ.get("LAU_PAGE_MAX", "6"))
LAU_FICHE_MAX = int(os.environ.get("LAU_FICHE_MAX", "10"))
# « Déc 05 2026 » (format MEC) → date ISO via parse_date_fr (« 05 dec 2026 »)
_MEC_DATE_RE = re.compile(r"([A-Za-zÀ-ÿ]{3,9})\s+(\d{1,2})\s+(\d{4})")
_START_RE = re.compile(r'mec-start-date-label[^>]*>([^<]*)')
_END_RE = re.compile(r'mec-end-date-label[^>]*>([^<]*)')
_COST_RE = re.compile(r'mec-events-event-cost[^>]*>([^<]*)')
_LOC_VENUE_RE = re.compile(
r'mec-single-event-location.*?
([^<]*)', re.S)
_LOC_ADDR_RE = re.compile(
r'mec-single-event-location.*?([^<]*)', re.S)
_ADDR_CITY_RE = re.compile(r",\s*([^,]+),\s*QC", re.I)
# lien « Ajouter à mon agenda Google » : datetimes UTC réels de la séance
_GCAL_RE = re.compile(r"dates=(\d{8}T\d{6})Z(?:%2F|/)(\d{8}T\d{6})Z")
def _mec_date(raw: str) -> str | None:
m = _MEC_DATE_RE.search(clean_text(raw or ""))
if not m:
return None
return parse_date_fr(f"{m.group(2)} {m.group(1)} {m.group(3)}")
class LaurentidesConnector(BaseConnector):
source_id = "laurentides"
request_delay = 1.0 # chaque requête peut coûter un crédit Scrapfly
def _list_events(self) -> list[dict]:
rows: list[dict] = []
for page in range(1, LAU_PAGE_MAX + 1):
try:
batch = self.get_json(API, params={
"per_page": PER_PAGE, "page": page, "_embed": "1"})
except Exception:
break
if not isinstance(batch, list) or not batch:
break
rows.extend(batch)
if len(batch) < PER_PAGE:
break
return rows
def _parse_fiche(self, html: str) -> dict:
out: dict = {}
# datetimes UTC réels (lien Google Agenda généré par MEC), sinon
# libellés de dates du bandeau (« Déc 05 2026 », sans heure)
m = _GCAL_RE.search(html)
if m:
def _iso(c: str) -> str: # « 20261205T150000 » → ISO UTC
return (f"{c[:4]}-{c[4:6]}-{c[6:8]}T"
f"{c[9:11]}:{c[11:13]}:{c[13:15]}Z")
out["start_date"], out["start_time"] = utc_to_local(_iso(m.group(1)))
out["end_date"], out["end_time"] = utc_to_local(_iso(m.group(2)))
if not out.get("start_date"):
d = _START_RE.search(html)
if d:
out["start_date"] = _mec_date(d.group(1))
d = _END_RE.search(html)
if d:
out["end_date"] = _mec_date(d.group(1))
m = _COST_RE.search(html)
if m:
out["price_label"] = clean_text(m.group(1))
m = _LOC_VENUE_RE.search(html)
if m:
out["venue"] = clean_text(m.group(1))
m = _LOC_ADDR_RE.search(html)
if m:
out["address"] = clean_text(m.group(1))
c = _ADDR_CITY_RE.search(out["address"])
if c:
out["city"] = clean_text(c.group(1))
return out
def fetch(self) -> list[Event]:
rows = self._list_events()
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}
# incrémental : nouvelles fiches d'abord, puis celles dont le contenu
# a changé chez la source (champ `modified` du REST)
def _stale(r: dict) -> bool:
entry = cache.get(str(r.get("id")))
return not entry or entry.get("modified") != r.get("modified")
to_fetch = sorted(
(r for r in rows if r.get("link") and _stale(r)),
key=lambda r: cache.get(str(r.get("id")), {}).get("ts", 0)
)[:LAU_FICHE_MAX]
for row in to_fetch:
eid = str(row.get("id"))
detail: dict = {}
try:
detail = self._parse_fiche(self.get(row["link"]).text)
except Exception:
detail = {}
cache[eid] = {"ts": now, "modified": row.get("modified"),
"detail": detail}
self.save_cache(cache)
events: list[Event] = []
for r in rows:
eid = str(r.get("id") or "")
title = clean_text((r.get("title") or {}).get("rendered", ""))
if not eid or not title:
continue
detail = cache.get(eid, {}).get("detail") or {}
image = ""
media = ((r.get("_embedded") or {}).get("wp:featuredmedia") or [])
if media and isinstance(media[0], dict):
image = media[0].get("source_url", "") or ""
description = clean_text(
(r.get("content") or {}).get("rendered", "")) or clean_text(
(r.get("excerpt") or {}).get("rendered", ""))
events.append(Event(
source=self.source_id,
external_id=eid,
url=r.get("link", ""),
title=title,
description=description,
raw_categories=[title],
venue=detail.get("venue", ""),
address=detail.get("address", ""),
city=detail.get("city", ""),
tourist_region="Laurentides",
start_date=detail.get("start_date"),
start_time=detail.get("start_time"),
end_date=detail.get("end_date") or detail.get("start_date"),
end_time=detail.get("end_time"),
price_label=detail.get("price_label", ""),
image=image,
))
return events