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/charlevoix.py : Tourisme Charlevoix (ATR, vague ATR 2026-08)5# Source : tourisme-charlevoix.com/fr/evenements — calendrier officiel de6# l'ATR (frontend SvelteKit + Craft CMS, listing servi par le7# même index Algolia public que le site).8# Extraction: index Algolia « prod_event_fr » (appId/clé de recherche9# publiques, embarquées dans le JS du site — vérifié 2026-08-25 :10# ~700 occurrences avec titre, description, adresse complète,11# GPS, catégories, secteur (ville), start/end en timestamps12# Unix, allDay). 1 requête/sync (hitsPerPage=1000, filtre13# end >= hier). Enrichissement incrémental par l'API du site14# POST /api/get-event {slug} (cache data/charlevoix_cache.json,15# plafond CHX_FICHE_MAX/sync, re-visite 7 j) : image CDN,16# site web, tarif (price), gratuité (free).17# Accès : clé Algolia « search-only » publiée par le site lui-même et18# API publique du frontend — requêtes throttlées, identification19# honnête, lien vers la fiche originale. Prix : texte source20# (price_label) ; is_free seulement si free=true (jamais inventé).21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import os26import time27from datetime import datetime, timezone28from zoneinfo import ZoneInfo2930from ..normalize import clean_text31from ..schema import Event32from .base import BaseConnector3334BASE = "https://www.tourisme-charlevoix.com"35ALGOLIA_URL = ("https://2GIBX867UB-dsn.algolia.net/1/indexes/"36 "prod_event_fr/query")37ALGOLIA_HEADERS = {38 "X-Algolia-Application-Id": "2GIBX867UB",39 "X-Algolia-API-Key": "96e46e72a21cf33ca610df780875c7ae",40 "Content-Type": "application/json",41}42GET_EVENT_API = BASE + "/api/get-event"43CHX_FICHE_MAX = int(os.environ.get("CHX_FICHE_MAX", "20"))44CHX_PAGE_MAX = int(os.environ.get("CHX_PAGE_MAX", "5"))45REFRESH_AFTER = 7 * 8640046TZ = ZoneInfo("America/Montreal")474849def _ts_local(ts) -> tuple[str | None, str | None]:50 """Timestamp Unix → (date ISO, heure HH:MM locale QC, None si minuit)."""51 try:52 dt = datetime.fromtimestamp(int(ts), tz=timezone.utc).astimezone(TZ)53 except (TypeError, ValueError, OSError):54 return None, None55 hm = dt.strftime("%H:%M")56 return dt.strftime("%Y-%m-%d"), (None if hm == "00:00" else hm)575859def _split_address(raw: str) -> tuple[str, str, str]:60 """« 306, chemin Principal, L'Isle-aux-Coudres, G0A 3J0 » →61 (rue, ville, code postal). Le n° civique est parfois isolé par une virgule62 (style FR) : la ville est le DERNIER segment (hors code postal)."""63 parts = [clean_text(p) for p in (raw or "").split(",") if clean_text(p)]64 postal = ""65 if parts and len(parts[-1]) <= 8 and any(ch.isdigit() for ch in parts[-1]):66 postal = parts.pop()67 city = parts.pop() if len(parts) > 1 else ""68 return ", ".join(parts), city, postal697071class CharlevoixConnector(BaseConnector):72 source_id = "charlevoix"73 request_delay = 0.87475 def _post_json(self, url: str, payload: dict, headers: dict | None = None):76 self._throttle()77 resp = self.session.post(url, json=payload, headers=headers,78 timeout=self.timeout)79 self._last_request = time.time()80 resp.raise_for_status()81 return resp.json()8283 def _hits(self) -> list[dict]:84 """Toutes les occurrences à venir de l'index événements du site."""85 since = int(time.time()) - 8640086 hits: list[dict] = []87 for page in range(CHX_PAGE_MAX):88 data = self._post_json(ALGOLIA_URL, {89 "params": f"hitsPerPage=1000&page={page}"90 f"&numericFilters=end>={since}"},91 headers=ALGOLIA_HEADERS)92 hits.extend(data.get("hits") or [])93 if page + 1 >= int(data.get("nbPages") or 1):94 break95 return hits9697 def _fiche(self, slug: str) -> dict:98 """Détail Craft de l'événement : image, site web, tarif, gratuité."""99 entry = (self._post_json(GET_EVENT_API, {100 "slug": slug, "language": "fr", "path": "/fr/evenements",101 }) or {}).get("entry") or {}102 images = entry.get("image") or []103 image = ""104 if images and isinstance(images[0], dict):105 image = images[0].get("medium") or images[0].get("small") or ""106 return {107 "image": image,108 "website": entry.get("website") or "",109 "price": clean_text(str(entry.get("price") or "")),110 "free": entry.get("free") is True,111 }112113 def fetch(self) -> list[Event]:114 hits = self._hits()115 cache = self.load_cache()116 now = time.time()117 slugs = {h.get("slug", "") for h in hits}118 cache = {k: v for k, v in cache.items() if k in slugs}119120 to_fetch = sorted(121 (s for s in slugs122 if s and (s not in cache123 or now - cache[s].get("ts", 0) > REFRESH_AFTER)),124 key=lambda s: cache.get(s, {}).get("ts", 0))[:CHX_FICHE_MAX]125 for slug in to_fetch:126 detail: dict = {}127 try:128 detail = self._fiche(slug)129 except Exception:130 detail = {}131 cache[slug] = {"ts": now, "detail": detail}132 self.save_cache(cache)133134 events: list[Event] = []135 seen: set[str] = set()136 for h in hits:137 eid = str(h.get("objectID") or "")138 title = clean_text(h.get("title") or "")139 if not eid or eid in seen or not title:140 continue141 seen.add(eid)142 start_date, start_time = _ts_local(h.get("start"))143 end_date, end_time = _ts_local(h.get("end"))144 if h.get("allDay"):145 start_time = end_time = None146 street, city, postal = _split_address(h.get("address") or "")147 sectors = h.get("sectors") or []148 geo = h.get("_geoloc") or {}149 detail = cache.get(h.get("slug", ""), {}).get("detail") or {}150 events.append(Event(151 source=self.source_id,152 external_id=eid,153 url=BASE + (h.get("url") or ""),154 title=title,155 description=clean_text(h.get("description") or ""),156 raw_categories=[clean_text(c) for c in157 (h.get("event_categories") or [])158 + (h.get("themes") or [])] + [title],159 address=street,160 city=city or (clean_text(sectors[0]) if sectors else ""),161 postal_code=postal,162 tourist_region="Charlevoix",163 lat=geo.get("lat"), lng=geo.get("lng"),164 start_date=start_date, start_time=start_time,165 end_date=end_date, end_time=end_time,166 is_free=True if detail.get("free") else None,167 price_label=detail.get("price", ""),168 website=detail.get("website", ""),169 image=detail.get("image", ""),170 ))171 return events172