# ----------------------------------------------------------------------------- # 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