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/levis.py : connecteur Ville de Lévis — calendrier des événements5# Source : https://levis.ca/fr/calendrier-des-evenements (nouveau site6# Nuxt/Craft CMS lancé fin 2025 ; l'ancien ville.levis.qc.ca et7# son WAF 403 — cause de l'abandon 2026-08-18 — y redirige).8# Extraction: API GraphQL publique https://levis.ca/graphql/ avec le9# queryToken PUBLIC exposé dans window.__NUXT__.config de la10# page (extrait à chaque run, constante de repli) — requête11# calquée sur getListingEvenements du bundle : occurrences12# datées (startDateLocalized), fiche (titre, url, accroche,13# image, lieu, types, secteur). ~160 événements à venir,14# vérifié 2026-08-25.15# Accès : API du site municipal public, jeton de LECTURE publié dans16# le HTML à tous les visiteurs ; robots.txt n'interdit pas17# /graphql/. POST throttlés, identification honnête.18# Prix : non publié par la source → jamais inventé.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import re23import time2425from ..normalize import clean_text, parse_date_iso, parse_time26from ..schema import Event27from .base import BaseConnector2829PAGE = "https://levis.ca/fr/calendrier-des-evenements"30GRAPHQL = "https://levis.ca/graphql/"31# jeton de lecture publié dans la page (repli si l'extraction échoue)32FALLBACK_TOKEN = "dfPT62ua1B5YtAebfrOgqXodvS235bob"33_TOKEN_RE = re.compile(r'queryToken:"([^"]+)"')34BATCH = 1003536QUERY = """query($limit:Int,$offset:Int,$rangeStart:String){37 pagesEntries(slug:["calendrier-des-evenements"], type:"listingEvenementsTemplate"){38 ... on listingEvenementsTemplate_Entry {39 calendarElement {40 eventCount(rangeStart:$rangeStart)41 events(offset:$offset, limit:$limit, rangeStart:$rangeStart,42 orderBy:"startDate ASC"){43 ... on evenements_Event {44 startDate: startDateLocalized endDate: endDateLocalized45 hideStartHourToggle hideEndHourToggle46 selectEventEntry {47 ... on evenementStructure_Entry {48 id: uid url title slug wysiwygSimpleElement49 thumbnailImage: thubmnailImageElement { url }50 selectLieuElement { ... on lieuStructure_Entry { title } }51 selectEventTypeElement: selectEventTypesElement { title }52 selectSecteurElement { title }53 }54 }55 }56 }57 }58 }59 }60}"""616263class LevisConnector(BaseConnector):64 source_id = "levis"65 request_delay = 0.86667 def _token(self) -> str:68 try:69 m = _TOKEN_RE.search(self.get(PAGE).text)70 if m:71 return m.group(1)72 except Exception:73 pass74 return FALLBACK_TOKEN7576 def _gql(self, token: str, variables: dict) -> dict:77 self._throttle()78 resp = self.session.post(GRAPHQL, json={79 "query": QUERY, "variables": variables,80 }, headers={"Authorization": f"Bearer {token}"}, timeout=self.timeout)81 self._last_request = time.time()82 resp.raise_for_status()83 return resp.json()8485 def _calendar(self, payload: dict) -> dict:86 pages = (payload.get("data") or {}).get("pagesEntries") or [{}]87 return pages[0].get("calendarElement") or {}8889 def fetch(self) -> list[Event]:90 token = self._token()91 events: list[Event] = []92 seen: set[str] = set()93 offset, total = 0, None94 while True:95 cal = self._calendar(self._gql(token, {96 "limit": BATCH, "offset": offset, "rangeStart": "today"}))97 if total is None:98 total = int(cal.get("eventCount") or 0)99 batch = cal.get("events") or []100 for occ in batch:101 sel = (occ.get("selectEventEntry") or [{}])[0]102 title = clean_text(sel.get("title") or "")103 url = sel.get("url") or ""104 start = parse_date_iso(occ.get("startDate"))105 if not title or not url or not start:106 continue107 ext = f"{sel.get('id') or sel.get('slug')}-{start}"108 if ext in seen:109 continue110 seen.add(ext)111 start_time = (None if occ.get("hideStartHourToggle")112 else parse_time(occ.get("startDate")))113 if start_time == "00:00":114 start_time = None115 end = parse_date_iso(occ.get("endDate"))116 end_time = (None if occ.get("hideEndHourToggle")117 else parse_time(occ.get("endDate")))118 if end_time == "00:00":119 end_time = None120 venue = clean_text(((sel.get("selectLieuElement")121 or [{}])[0]).get("title") or "")122 cats = [clean_text(t.get("title") or "")123 for t in sel.get("selectEventTypeElement") or []]124 image = ""125 thumb = sel.get("thumbnailImage") or []126 if thumb and thumb[0].get("url"):127 image = thumb[0]["url"]128 events.append(Event(129 source=self.source_id,130 external_id=ext,131 url=url,132 title=title,133 description=clean_text(sel.get("wysiwygSimpleElement") or ""),134 raw_categories=[c for c in cats if c] or [title],135 venue=venue,136 city="Lévis",137 region="Chaudière-Appalaches",138 start_date=start,139 start_time=start_time,140 end_date=end or start,141 end_time=end_time,142 organizer="Ville de Lévis",143 image=image,144 ))145 offset += BATCH146 if len(batch) < BATCH or (total and offset >= total):147 break148 return events149