# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quebeccite.py : Québec cité / Destination Québec cité (ATR, # vague ATR 2026-08) # Source : quebec-cite.com/fr/quoi-faire-quebec/evenements — calendrier # officiel de l'office de tourisme de la région de Québec # (Drupal, listing servi par le même index Algolia que le site). # Extraction: index Algolia « default_fr_event_date_start_asc » (appId/clé # de recherche publiques, embarquées dans le JS du thème — # vérifié 2026-08-25 : ~840 fiches avec titre, résumé, lieu # (event_location), GPS, prix texte (« À partir de 98,73 $ »), # gratuité event_free, dates start/end en timestamps Unix, # type/sujets/thèmes, image, lien externe officiel). 1-2 # requêtes/sync (hitsPerPage=1000, filtre event_date_end >= # hier). Aucune fiche détail nécessaire. # Accès : clé Algolia « search-only » publiée par le site lui-même — # requêtes throttlées, identification honnête, lien vers la # fiche originale. is_free seulement si event_free=true ; # price_label = texte source (jamais inventé). # ----------------------------------------------------------------------------- from __future__ import annotations import os import time from datetime import datetime, timezone from ..normalize import clean_text from ..schema import Event from .base import BaseConnector BASE = "https://www.quebec-cite.com" ALGOLIA_URL = ("https://WOZPDCK6IL-dsn.algolia.net/1/indexes/" "default_fr_event_date_start_asc/query") ALGOLIA_HEADERS = { "X-Algolia-Application-Id": "WOZPDCK6IL", "X-Algolia-API-Key": "c1c4abd63a65bcf4efc646449d5c52ed", "Content-Type": "application/json", } QBC_PAGE_MAX = int(os.environ.get("QBC_PAGE_MAX", "5")) def _txt(v) -> str: """Champ Algolia texte OU liste de textes → chaîne nettoyée.""" if isinstance(v, (list, tuple)): return clean_text(", ".join(str(x) for x in v if x)) return clean_text(str(v)) if v else "" def _ts_date(ts) -> str | None: """Timestamp Unix → date ISO. La source encode ses dates à 12:00 UTC (placeholder « jour entier », vérifié sur tout l'index 2026-08-25) : AUCUNE heure réelle n'est publiée → on n'émet jamais d'heure.""" try: ts = int(ts) if ts <= 0: return None return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") except (TypeError, ValueError, OSError): return None class QuebecCiteConnector(BaseConnector): source_id = "quebeccite" request_delay = 0.8 def _post_json(self, payload: dict): self._throttle() resp = self.session.post(ALGOLIA_URL, json=payload, headers=ALGOLIA_HEADERS, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json() def _hits(self) -> list[dict]: since = int(time.time()) - 86400 hits: list[dict] = [] for page in range(QBC_PAGE_MAX): data = self._post_json({ "params": f"hitsPerPage=1000&page={page}" f"&numericFilters=event_date_end>={since}"}) hits.extend(data.get("hits") or []) if page + 1 >= int(data.get("nbPages") or 1): break return hits def fetch(self) -> list[Event]: events: list[Event] = [] seen: set[str] = set() for h in self._hits(): eid = str(h.get("nid") or "") title = clean_text(h.get("title") or h.get("index_title") or "") if not eid or eid in seen or not title: continue seen.add(eid) start_date = _ts_date(h.get("event_date_start")) end_date = _ts_date(h.get("event_date_end")) image = h.get("teaser_image") or "" if image.startswith("/"): image = BASE + image cats = [_txt(h.get("event_type")), _txt(h.get("subjects")), _txt(h.get("themes"))] events.append(Event( source=self.source_id, external_id=eid, url=BASE + (h.get("url") or ""), title=title, description=_txt(h.get("summary")), raw_categories=[c for c in cats if c] + [title], venue=_txt(h.get("event_location")), city="", # non publié (lieu = salle) tourist_region="Région de Québec", lat=h.get("lat"), lng=h.get("lng"), start_date=start_date, end_date=end_date, is_free=True if h.get("event_free") is True else None, price_label=_txt(h.get("price")), website=(h.get("external_link") or "").strip(), image=image, )) return events