# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/levis.py : connecteur Ville de Lévis — calendrier des événements # Source : https://levis.ca/fr/calendrier-des-evenements (nouveau site # Nuxt/Craft CMS lancé fin 2025 ; l'ancien ville.levis.qc.ca et # son WAF 403 — cause de l'abandon 2026-08-18 — y redirige). # Extraction: API GraphQL publique https://levis.ca/graphql/ avec le # queryToken PUBLIC exposé dans window.__NUXT__.config de la # page (extrait à chaque run, constante de repli) — requête # calquée sur getListingEvenements du bundle : occurrences # datées (startDateLocalized), fiche (titre, url, accroche, # image, lieu, types, secteur). ~160 événements à venir, # vérifié 2026-08-25. # Accès : API du site municipal public, jeton de LECTURE publié dans # le HTML à tous les visiteurs ; robots.txt n'interdit pas # /graphql/. POST throttlés, identification honnête. # Prix : non publié par la source → jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import re import time from ..normalize import clean_text, parse_date_iso, parse_time from ..schema import Event from .base import BaseConnector PAGE = "https://levis.ca/fr/calendrier-des-evenements" GRAPHQL = "https://levis.ca/graphql/" # jeton de lecture publié dans la page (repli si l'extraction échoue) FALLBACK_TOKEN = "dfPT62ua1B5YtAebfrOgqXodvS235bob" _TOKEN_RE = re.compile(r'queryToken:"([^"]+)"') BATCH = 100 QUERY = """query($limit:Int,$offset:Int,$rangeStart:String){ pagesEntries(slug:["calendrier-des-evenements"], type:"listingEvenementsTemplate"){ ... on listingEvenementsTemplate_Entry { calendarElement { eventCount(rangeStart:$rangeStart) events(offset:$offset, limit:$limit, rangeStart:$rangeStart, orderBy:"startDate ASC"){ ... on evenements_Event { startDate: startDateLocalized endDate: endDateLocalized hideStartHourToggle hideEndHourToggle selectEventEntry { ... on evenementStructure_Entry { id: uid url title slug wysiwygSimpleElement thumbnailImage: thubmnailImageElement { url } selectLieuElement { ... on lieuStructure_Entry { title } } selectEventTypeElement: selectEventTypesElement { title } selectSecteurElement { title } } } } } } } } }""" class LevisConnector(BaseConnector): source_id = "levis" request_delay = 0.8 def _token(self) -> str: try: m = _TOKEN_RE.search(self.get(PAGE).text) if m: return m.group(1) except Exception: pass return FALLBACK_TOKEN def _gql(self, token: str, variables: dict) -> dict: self._throttle() resp = self.session.post(GRAPHQL, json={ "query": QUERY, "variables": variables, }, headers={"Authorization": f"Bearer {token}"}, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json() def _calendar(self, payload: dict) -> dict: pages = (payload.get("data") or {}).get("pagesEntries") or [{}] return pages[0].get("calendarElement") or {} def fetch(self) -> list[Event]: token = self._token() events: list[Event] = [] seen: set[str] = set() offset, total = 0, None while True: cal = self._calendar(self._gql(token, { "limit": BATCH, "offset": offset, "rangeStart": "today"})) if total is None: total = int(cal.get("eventCount") or 0) batch = cal.get("events") or [] for occ in batch: sel = (occ.get("selectEventEntry") or [{}])[0] title = clean_text(sel.get("title") or "") url = sel.get("url") or "" start = parse_date_iso(occ.get("startDate")) if not title or not url or not start: continue ext = f"{sel.get('id') or sel.get('slug')}-{start}" if ext in seen: continue seen.add(ext) start_time = (None if occ.get("hideStartHourToggle") else parse_time(occ.get("startDate"))) if start_time == "00:00": start_time = None end = parse_date_iso(occ.get("endDate")) end_time = (None if occ.get("hideEndHourToggle") else parse_time(occ.get("endDate"))) if end_time == "00:00": end_time = None venue = clean_text(((sel.get("selectLieuElement") or [{}])[0]).get("title") or "") cats = [clean_text(t.get("title") or "") for t in sel.get("selectEventTypeElement") or []] image = "" thumb = sel.get("thumbnailImage") or [] if thumb and thumb[0].get("url"): image = thumb[0]["url"] events.append(Event( source=self.source_id, external_id=ext, url=url, title=title, description=clean_text(sel.get("wysiwygSimpleElement") or ""), raw_categories=[c for c in cats if c] or [title], venue=venue, city="Lévis", region="Chaudière-Appalaches", start_date=start, start_time=start_time, end_date=end or start, end_time=end_time, organizer="Ville de Lévis", image=image, )) offset += BATCH if len(batch) < BATCH or (total and offset >= total): break return events