SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
7.3 KB · 172 lines python
Raw Blame History
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