# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/outaouais.py : Tourisme Outaouais — calendrier des événements (ATR) # Source : https://tourismeoutaouais.com/calendrier-des-evenements — site # Astro : la page embarque TOUT le calendrier dans les props # sérialisées de l'îlot # (159 événements dont ~22 à venir, revérifié 2026-08-25) → # UNE SEULE requête pour la liste. # Extraction: props JSON (format Svelte [type, valeur] déballé) : id, titre, # slug/url (interne /attraits/ ou site officiel externe), # municipalité, territoire, photo (CDN admin.tourismeoutaouais.com), # startDate/endDate ISO. ENRICHISSEMENT (2026-08-25) : les fiches # internes /attraits/ des événements à venir sont visitées # incrémentalement (cache data/outaouais_cache.json, # OUT_FICHE_MAX/sync, re-visite 7 j) : description (meta # description / div.description), adresse civique + ville # (div.address « 988, rue Saint-louis, Gatineau (Québec) »), # site web officiel (« Visitez le site web »). Pas d'heure ni de # tarif publiés → jamais inventés. GPS via annuaire/géocodeur. # Accès : robots.txt « Allow: / » — GET direct, identification honnête. # Décision : comble l'échec Gatineau (site 100 % JS, consigné 2026-08-18) — # l'Outaouais n'avait que 337 fiches au baseline Phase 3. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import os import re import time from datetime import date from ..normalize import clean_text from ..schema import Event from .base import BaseConnector BASE = "https://tourismeoutaouais.com" ADMIN = "https://admin.tourismeoutaouais.com" LIST_URL = BASE + "/calendrier-des-evenements" FICHE_MAX = int(os.environ.get("OUT_FICHE_MAX", "20")) REFRESH_AFTER = 7 * 86400 _META_DESC_RE = re.compile(r']*>(.*?)', re.S) _ADDR_RE = re.compile(r'class="address"[^>]*>\s*([^<]+?)\s*') _WEBSITE_RE = re.compile( r'href="(https?://[^"]+)"[^>]*>\s*Visitez le site web') def _unwrap(v): """Déballe le format de sérialisation Astro/Svelte : [0, valeur] scalaire, [1, [...]] liste — récursif, prudent.""" if isinstance(v, list) and len(v) == 2 and v[0] in (0, 1): return _unwrap(v[1]) if isinstance(v, list): return [_unwrap(x) for x in v] if isinstance(v, dict): return {k: _unwrap(x) for k, x in v.items()} return v def _extract_events(page_html: str) -> list[dict]: """Événements des props de l'îlot calendrier ([] si structure changée).""" idx = page_html.find("CalendrierDesEvenements") if idx < 0: return [] m = re.search(r'props="([^"]*)"', page_html[max(0, idx - 2000):]) if not m: return [] try: data = _unwrap(json.loads(_html.unescape(m.group(1)))) except Exception: return [] events = data.get("events") if isinstance(data, dict) else None return [e for e in (events or []) if isinstance(e, dict)] class OutaouaisConnector(BaseConnector): source_id = "outaouais" request_delay = 0.8 def _parse_fiche(self, html: str) -> dict: """Fiche interne /attraits/ : description, adresse, ville, site.""" desc = "" m = _DIV_DESC_RE.search(html) or _META_DESC_RE.search(html) if m: desc = clean_text(m.group(1))[:2000] address, city = "", "" a = _ADDR_RE.search(html) if a: raw = clean_text(a.group(1)).replace("(Québec)", "").strip(" ,") if "," in raw: # « 988, rue Saint-louis, Gatineau » address, city = (s.strip() for s in raw.rsplit(",", 1)) else: address = raw w = _WEBSITE_RE.search(html) return {"desc": desc, "address": address, "city": city, "website": w.group(1) if w else ""} def _enrich_fiches(self, rows: list[dict]) -> None: """Visite incrémentale des fiches internes des événements À VENIR (budget : les fiches passées ne sont pas re-crawlées).""" today = date.today().isoformat() urls = sorted({r["url"] for r in rows if r["url"].startswith(BASE + "/attraits/") and (r.get("end") or r.get("start") or "") >= today}) cache = self.load_cache() now = time.time() cache = {u: c for u, c in cache.items() if u in {r["url"] for r in rows}} to_fetch = sorted( (u for u in urls if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] def worker(url, session): return self._parse_fiche(session.get(url, timeout=25).text) for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items(): cache[url] = {"ts": now, **(det or {})} self.save_cache(cache) for r in rows: det = cache.get(r["url"]) or {} r["description"] = det.get("desc", "") r["address"] = det.get("address", "") r["fiche_city"] = det.get("city", "") r["website"] = det.get("website", "") def fetch(self) -> list[Event]: html = self.get(LIST_URL).text rows: list[dict] = [] seen: set[str] = set() for e in _extract_events(html): ext_id = str(e.get("id") or "") title = clean_text(e.get("title") or "") if not ext_id or not title or ext_id in seen: continue seen.add(ext_id) url = e.get("url") or "" if url.startswith("/"): url = BASE + url if not url.startswith("http"): url = LIST_URL photo = e.get("photo") or "" if photo.startswith("/"): photo = ADMIN + photo muni_slug = e.get("municipality") or "" if muni_slug.startswith("municipalite-exterieure"): muni_slug = "" # « extérieur de la région » ≠ ville muni = clean_text(muni_slug.replace("-", " ")).title() rows.append({ "ext_id": ext_id, "title": title, "url": url, "territory": clean_text(e.get("territory") or ""), "city": muni, "photo": photo, "start": e.get("startDate"), "end": e.get("endDate") or e.get("startDate"), }) self._enrich_fiches(rows) events: list[Event] = [] for r in rows: events.append(Event( source=self.source_id, external_id=r["ext_id"], url=r["url"], title=r["title"], description=r.get("description", ""), raw_categories=[r["title"], r["territory"]], address=r.get("address", ""), city=r["city"] or r.get("fiche_city", ""), tourist_region="Outaouais", start_date=r["start"], end_date=r["end"], website=r.get("website", ""), image=r["photo"], )) return events