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/laurentides.py : Tourisme Laurentides (ATR) — RÉHABILITÉE (vague5# ATR 2026-08 ; écartée Phase 3 pour WAF 403 : la chaîne anti-bot passe)6# Source : laurentides.com/evenements — calendrier officiel de l'ATR7# (WordPress + Modern Events Calendar « mec-events »).8# Extraction: API REST WordPress /wp-json/wp/v2/mec-events (paginée9# per_page=100, ~228 fiches, X-WP-TotalPages vérifié 2026-08-25)10# via BaseConnector.get() dont l'AUTO-ESCALADE anti-bot11# (Oxylabs → Scrapfly ASP → Bright Data) perce le WAF qui bloque12# le GET direct (403). La liste donne titre, description13# (content), image (_embedded featuredmedia), lien, modified.14# MEC n'expose PAS les dates en REST → fiches détail15# incrémentales (cache data/laurentides_cache.json, plafond16# LAU_FICHE_MAX/sync, re-visite si `modified` change) : dates17# (mec-start/end-date-label « Déc 05 2026 »), tarif18# (mec-events-event-cost), lieu + adresse19# (mec-single-event-location).20# Accès : API REST publique (aucune auth) ; le WAF de l'hébergeur21# bloque les UA non-navigateur → escalade proxy/Scrapfly,22# requêtes throttlées et plafonnées (coût par requête), lien23# vers la fiche originale. Prix : texte source, jamais inventé.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import time3031from ..normalize import clean_text, parse_date_fr, utc_to_local32from ..schema import Event33from .base import BaseConnector3435BASE = "https://www.laurentides.com"36API = BASE + "/wp-json/wp/v2/mec-events"37PER_PAGE = 10038LAU_PAGE_MAX = int(os.environ.get("LAU_PAGE_MAX", "6"))39LAU_FICHE_MAX = int(os.environ.get("LAU_FICHE_MAX", "10"))4041# « Déc 05 2026 » (format MEC) → date ISO via parse_date_fr (« 05 dec 2026 »)42_MEC_DATE_RE = re.compile(r"([A-Za-zÀ-ÿ]{3,9})\s+(\d{1,2})\s+(\d{4})")43_START_RE = re.compile(r'mec-start-date-label[^>]*>([^<]*)')44_END_RE = re.compile(r'mec-end-date-label[^>]*>([^<]*)')45_COST_RE = re.compile(r'mec-events-event-cost[^>]*>([^<]*)')46_LOC_VENUE_RE = re.compile(47 r'mec-single-event-location.*?<dd class="author[^"]*">([^<]*)', re.S)48_LOC_ADDR_RE = re.compile(49 r'mec-single-event-location.*?<span class="mec-address">([^<]*)', re.S)50_ADDR_CITY_RE = re.compile(r",\s*([^,]+),\s*QC", re.I)51# lien « Ajouter à mon agenda Google » : datetimes UTC réels de la séance52_GCAL_RE = re.compile(r"dates=(\d{8}T\d{6})Z(?:%2F|/)(\d{8}T\d{6})Z")535455def _mec_date(raw: str) -> str | None:56 m = _MEC_DATE_RE.search(clean_text(raw or ""))57 if not m:58 return None59 return parse_date_fr(f"{m.group(2)} {m.group(1)} {m.group(3)}")606162class LaurentidesConnector(BaseConnector):63 source_id = "laurentides"64 request_delay = 1.0 # chaque requête peut coûter un crédit Scrapfly6566 def _list_events(self) -> list[dict]:67 rows: list[dict] = []68 for page in range(1, LAU_PAGE_MAX + 1):69 try:70 batch = self.get_json(API, params={71 "per_page": PER_PAGE, "page": page, "_embed": "1"})72 except Exception:73 break74 if not isinstance(batch, list) or not batch:75 break76 rows.extend(batch)77 if len(batch) < PER_PAGE:78 break79 return rows8081 def _parse_fiche(self, html: str) -> dict:82 out: dict = {}83 # datetimes UTC réels (lien Google Agenda généré par MEC), sinon84 # libellés de dates du bandeau (« Déc 05 2026 », sans heure)85 m = _GCAL_RE.search(html)86 if m:87 def _iso(c: str) -> str: # « 20261205T150000 » → ISO UTC88 return (f"{c[:4]}-{c[4:6]}-{c[6:8]}T"89 f"{c[9:11]}:{c[11:13]}:{c[13:15]}Z")90 out["start_date"], out["start_time"] = utc_to_local(_iso(m.group(1)))91 out["end_date"], out["end_time"] = utc_to_local(_iso(m.group(2)))92 if not out.get("start_date"):93 d = _START_RE.search(html)94 if d:95 out["start_date"] = _mec_date(d.group(1))96 d = _END_RE.search(html)97 if d:98 out["end_date"] = _mec_date(d.group(1))99 m = _COST_RE.search(html)100 if m:101 out["price_label"] = clean_text(m.group(1))102 m = _LOC_VENUE_RE.search(html)103 if m:104 out["venue"] = clean_text(m.group(1))105 m = _LOC_ADDR_RE.search(html)106 if m:107 out["address"] = clean_text(m.group(1))108 c = _ADDR_CITY_RE.search(out["address"])109 if c:110 out["city"] = clean_text(c.group(1))111 return out112113 def fetch(self) -> list[Event]:114 rows = self._list_events()115 cache = self.load_cache()116 now = time.time()117 current = {str(r.get("id")) for r in rows}118 cache = {k: v for k, v in cache.items() if k in current}119120 # incrémental : nouvelles fiches d'abord, puis celles dont le contenu121 # a changé chez la source (champ `modified` du REST)122 def _stale(r: dict) -> bool:123 entry = cache.get(str(r.get("id")))124 return not entry or entry.get("modified") != r.get("modified")125126 to_fetch = sorted(127 (r for r in rows if r.get("link") and _stale(r)),128 key=lambda r: cache.get(str(r.get("id")), {}).get("ts", 0)129 )[:LAU_FICHE_MAX]130 for row in to_fetch:131 eid = str(row.get("id"))132 detail: dict = {}133 try:134 detail = self._parse_fiche(self.get(row["link"]).text)135 except Exception:136 detail = {}137 cache[eid] = {"ts": now, "modified": row.get("modified"),138 "detail": detail}139 self.save_cache(cache)140141 events: list[Event] = []142 for r in rows:143 eid = str(r.get("id") or "")144 title = clean_text((r.get("title") or {}).get("rendered", ""))145 if not eid or not title:146 continue147 detail = cache.get(eid, {}).get("detail") or {}148 image = ""149 media = ((r.get("_embedded") or {}).get("wp:featuredmedia") or [])150 if media and isinstance(media[0], dict):151 image = media[0].get("source_url", "") or ""152 description = clean_text(153 (r.get("content") or {}).get("rendered", "")) or clean_text(154 (r.get("excerpt") or {}).get("rendered", ""))155 events.append(Event(156 source=self.source_id,157 external_id=eid,158 url=r.get("link", ""),159 title=title,160 description=description,161 raw_categories=[title],162 venue=detail.get("venue", ""),163 address=detail.get("address", ""),164 city=detail.get("city", ""),165 tourist_region="Laurentides",166 start_date=detail.get("start_date"),167 start_time=detail.get("start_time"),168 end_date=detail.get("end_date") or detail.get("start_date"),169 end_time=detail.get("end_time"),170 price_label=detail.get("price_label", ""),171 image=image,172 ))173 return events174