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/outaouais.py : Tourisme Outaouais — calendrier des événements (ATR)5# Source : https://tourismeoutaouais.com/calendrier-des-evenements — site6# Astro : la page embarque TOUT le calendrier dans les props7# sérialisées de l'îlot <astro-island CalendrierDesEvenements>8# (159 événements dont ~22 à venir, revérifié 2026-08-25) →9# UNE SEULE requête pour la liste.10# Extraction: props JSON (format Svelte [type, valeur] déballé) : id, titre,11# slug/url (interne /attraits/<slug> ou site officiel externe),12# municipalité, territoire, photo (CDN admin.tourismeoutaouais.com),13# startDate/endDate ISO. ENRICHISSEMENT (2026-08-25) : les fiches14# internes /attraits/<slug> des événements à venir sont visitées15# incrémentalement (cache data/outaouais_cache.json,16# OUT_FICHE_MAX/sync, re-visite 7 j) : description (meta17# description / div.description), adresse civique + ville18# (div.address « 988, rue Saint-louis, Gatineau (Québec) »),19# site web officiel (« Visitez le site web »). Pas d'heure ni de20# tarif publiés → jamais inventés. GPS via annuaire/géocodeur.21# Accès : robots.txt « Allow: / » — GET direct, identification honnête.22# Décision : comble l'échec Gatineau (site 100 % JS, consigné 2026-08-18) —23# l'Outaouais n'avait que 337 fiches au baseline Phase 3.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import html as _html28import json29import os30import re31import time32from datetime import date3334from ..normalize import clean_text35from ..schema import Event36from .base import BaseConnector3738BASE = "https://tourismeoutaouais.com"39ADMIN = "https://admin.tourismeoutaouais.com"40LIST_URL = BASE + "/calendrier-des-evenements"41FICHE_MAX = int(os.environ.get("OUT_FICHE_MAX", "20"))42REFRESH_AFTER = 7 * 864004344_META_DESC_RE = re.compile(r'<meta name="description" content="([^"]*)"')45_DIV_DESC_RE = re.compile(r'class="my-4 description"[^>]*>(.*?)</div>', re.S)46_ADDR_RE = re.compile(r'class="address"[^>]*>\s*([^<]+?)\s*</div>')47_WEBSITE_RE = re.compile(48 r'href="(https?://[^"]+)"[^>]*>\s*Visitez le site web')495051def _unwrap(v):52 """Déballe le format de sérialisation Astro/Svelte : [0, valeur] scalaire,53 [1, [...]] liste — récursif, prudent."""54 if isinstance(v, list) and len(v) == 2 and v[0] in (0, 1):55 return _unwrap(v[1])56 if isinstance(v, list):57 return [_unwrap(x) for x in v]58 if isinstance(v, dict):59 return {k: _unwrap(x) for k, x in v.items()}60 return v616263def _extract_events(page_html: str) -> list[dict]:64 """Événements des props de l'îlot calendrier ([] si structure changée)."""65 idx = page_html.find("CalendrierDesEvenements")66 if idx < 0:67 return []68 m = re.search(r'props="([^"]*)"', page_html[max(0, idx - 2000):])69 if not m:70 return []71 try:72 data = _unwrap(json.loads(_html.unescape(m.group(1))))73 except Exception:74 return []75 events = data.get("events") if isinstance(data, dict) else None76 return [e for e in (events or []) if isinstance(e, dict)]777879class OutaouaisConnector(BaseConnector):80 source_id = "outaouais"81 request_delay = 0.88283 def _parse_fiche(self, html: str) -> dict:84 """Fiche interne /attraits/<slug> : description, adresse, ville, site."""85 desc = ""86 m = _DIV_DESC_RE.search(html) or _META_DESC_RE.search(html)87 if m:88 desc = clean_text(m.group(1))[:2000]89 address, city = "", ""90 a = _ADDR_RE.search(html)91 if a:92 raw = clean_text(a.group(1)).replace("(Québec)", "").strip(" ,")93 if "," in raw: # « 988, rue Saint-louis, Gatineau »94 address, city = (s.strip() for s in raw.rsplit(",", 1))95 else:96 address = raw97 w = _WEBSITE_RE.search(html)98 return {"desc": desc, "address": address, "city": city,99 "website": w.group(1) if w else ""}100101 def _enrich_fiches(self, rows: list[dict]) -> None:102 """Visite incrémentale des fiches internes des événements À VENIR103 (budget : les fiches passées ne sont pas re-crawlées)."""104 today = date.today().isoformat()105 urls = sorted({r["url"] for r in rows106 if r["url"].startswith(BASE + "/attraits/")107 and (r.get("end") or r.get("start") or "") >= today})108 cache = self.load_cache()109 now = time.time()110 cache = {u: c for u, c in cache.items() if u in {r["url"] for r in rows}}111 to_fetch = sorted(112 (u for u in urls113 if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER),114 key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX]115116 def worker(url, session):117 return self._parse_fiche(session.get(url, timeout=25).text)118119 for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items():120 cache[url] = {"ts": now, **(det or {})}121 self.save_cache(cache)122 for r in rows:123 det = cache.get(r["url"]) or {}124 r["description"] = det.get("desc", "")125 r["address"] = det.get("address", "")126 r["fiche_city"] = det.get("city", "")127 r["website"] = det.get("website", "")128129 def fetch(self) -> list[Event]:130 html = self.get(LIST_URL).text131 rows: list[dict] = []132 seen: set[str] = set()133 for e in _extract_events(html):134 ext_id = str(e.get("id") or "")135 title = clean_text(e.get("title") or "")136 if not ext_id or not title or ext_id in seen:137 continue138 seen.add(ext_id)139 url = e.get("url") or ""140 if url.startswith("/"):141 url = BASE + url142 if not url.startswith("http"):143 url = LIST_URL144 photo = e.get("photo") or ""145 if photo.startswith("/"):146 photo = ADMIN + photo147 muni_slug = e.get("municipality") or ""148 if muni_slug.startswith("municipalite-exterieure"):149 muni_slug = "" # « extérieur de la région » ≠ ville150 muni = clean_text(muni_slug.replace("-", " ")).title()151 rows.append({152 "ext_id": ext_id, "title": title, "url": url,153 "territory": clean_text(e.get("territory") or ""),154 "city": muni, "photo": photo,155 "start": e.get("startDate"),156 "end": e.get("endDate") or e.get("startDate"),157 })158 self._enrich_fiches(rows)159160 events: list[Event] = []161 for r in rows:162 events.append(Event(163 source=self.source_id,164 external_id=r["ext_id"],165 url=r["url"],166 title=r["title"],167 description=r.get("description", ""),168 raw_categories=[r["title"], r["territory"]],169 address=r.get("address", ""),170 city=r["city"] or r.get("fiche_city", ""),171 tourist_region="Outaouais",172 start_date=r["start"],173 end_date=r["end"],174 website=r.get("website", ""),175 image=r["photo"],176 ))177 return events178