# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/charlevoix.py : Tourisme Charlevoix (ATR, vague ATR 2026-08) # Source : tourisme-charlevoix.com/fr/evenements — calendrier officiel de # l'ATR (frontend SvelteKit + Craft CMS, listing servi par le # même index Algolia public que le site). # Extraction: index Algolia « prod_event_fr » (appId/clé de recherche # publiques, embarquées dans le JS du site — vérifié 2026-08-25 : # ~700 occurrences avec titre, description, adresse complète, # GPS, catégories, secteur (ville), start/end en timestamps # Unix, allDay). 1 requête/sync (hitsPerPage=1000, filtre # end >= hier). Enrichissement incrémental par l'API du site # POST /api/get-event {slug} (cache data/charlevoix_cache.json, # plafond CHX_FICHE_MAX/sync, re-visite 7 j) : image CDN, # site web, tarif (price), gratuité (free). # Accès : clé Algolia « search-only » publiée par le site lui-même et # API publique du frontend — requêtes throttlées, identification # honnête, lien vers la fiche originale. Prix : texte source # (price_label) ; is_free seulement si free=true (jamais inventé). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import time from datetime import datetime, timezone from zoneinfo import ZoneInfo from ..normalize import clean_text from ..schema import Event from .base import BaseConnector BASE = "https://www.tourisme-charlevoix.com" ALGOLIA_URL = ("https://2GIBX867UB-dsn.algolia.net/1/indexes/" "prod_event_fr/query") ALGOLIA_HEADERS = { "X-Algolia-Application-Id": "2GIBX867UB", "X-Algolia-API-Key": "96e46e72a21cf33ca610df780875c7ae", "Content-Type": "application/json", } GET_EVENT_API = BASE + "/api/get-event" CHX_FICHE_MAX = int(os.environ.get("CHX_FICHE_MAX", "20")) CHX_PAGE_MAX = int(os.environ.get("CHX_PAGE_MAX", "5")) REFRESH_AFTER = 7 * 86400 TZ = ZoneInfo("America/Montreal") def _ts_local(ts) -> tuple[str | None, str | None]: """Timestamp Unix → (date ISO, heure HH:MM locale QC, None si minuit).""" try: dt = datetime.fromtimestamp(int(ts), tz=timezone.utc).astimezone(TZ) except (TypeError, ValueError, OSError): return None, None hm = dt.strftime("%H:%M") return dt.strftime("%Y-%m-%d"), (None if hm == "00:00" else hm) def _split_address(raw: str) -> tuple[str, str, str]: """« 306, chemin Principal, L'Isle-aux-Coudres, G0A 3J0 » → (rue, ville, code postal). Le n° civique est parfois isolé par une virgule (style FR) : la ville est le DERNIER segment (hors code postal).""" parts = [clean_text(p) for p in (raw or "").split(",") if clean_text(p)] postal = "" if parts and len(parts[-1]) <= 8 and any(ch.isdigit() for ch in parts[-1]): postal = parts.pop() city = parts.pop() if len(parts) > 1 else "" return ", ".join(parts), city, postal class CharlevoixConnector(BaseConnector): source_id = "charlevoix" request_delay = 0.8 def _post_json(self, url: str, payload: dict, headers: dict | None = None): self._throttle() resp = self.session.post(url, json=payload, headers=headers, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json() def _hits(self) -> list[dict]: """Toutes les occurrences à venir de l'index événements du site.""" since = int(time.time()) - 86400 hits: list[dict] = [] for page in range(CHX_PAGE_MAX): data = self._post_json(ALGOLIA_URL, { "params": f"hitsPerPage=1000&page={page}" f"&numericFilters=end>={since}"}, headers=ALGOLIA_HEADERS) hits.extend(data.get("hits") or []) if page + 1 >= int(data.get("nbPages") or 1): break return hits def _fiche(self, slug: str) -> dict: """Détail Craft de l'événement : image, site web, tarif, gratuité.""" entry = (self._post_json(GET_EVENT_API, { "slug": slug, "language": "fr", "path": "/fr/evenements", }) or {}).get("entry") or {} images = entry.get("image") or [] image = "" if images and isinstance(images[0], dict): image = images[0].get("medium") or images[0].get("small") or "" return { "image": image, "website": entry.get("website") or "", "price": clean_text(str(entry.get("price") or "")), "free": entry.get("free") is True, } def fetch(self) -> list[Event]: hits = self._hits() cache = self.load_cache() now = time.time() slugs = {h.get("slug", "") for h in hits} cache = {k: v for k, v in cache.items() if k in slugs} to_fetch = sorted( (s for s in slugs if s and (s not in cache or now - cache[s].get("ts", 0) > REFRESH_AFTER)), key=lambda s: cache.get(s, {}).get("ts", 0))[:CHX_FICHE_MAX] for slug in to_fetch: detail: dict = {} try: detail = self._fiche(slug) except Exception: detail = {} cache[slug] = {"ts": now, "detail": detail} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for h in hits: eid = str(h.get("objectID") or "") title = clean_text(h.get("title") or "") if not eid or eid in seen or not title: continue seen.add(eid) start_date, start_time = _ts_local(h.get("start")) end_date, end_time = _ts_local(h.get("end")) if h.get("allDay"): start_time = end_time = None street, city, postal = _split_address(h.get("address") or "") sectors = h.get("sectors") or [] geo = h.get("_geoloc") or {} detail = cache.get(h.get("slug", ""), {}).get("detail") or {} events.append(Event( source=self.source_id, external_id=eid, url=BASE + (h.get("url") or ""), title=title, description=clean_text(h.get("description") or ""), raw_categories=[clean_text(c) for c in (h.get("event_categories") or []) + (h.get("themes") or [])] + [title], address=street, city=city or (clean_text(sectors[0]) if sectors else ""), postal_code=postal, tourist_region="Charlevoix", lat=geo.get("lat"), lng=geo.get("lng"), start_date=start_date, start_time=start_time, end_date=end_date, end_time=end_time, is_free=True if detail.get("free") else None, price_label=detail.get("price", ""), website=detail.get("website", ""), image=detail.get("image", ""), )) return events