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%
6.1 KB · 149 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/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