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/longueuil.py : connecteur Ville de Longueuil — nouveau portail5# Source : longueuil.quebec/fr/evenements (portail Nuxt.js, rendu serveur).6# Extraction: cartes <article class="event__item"> de la page liste —7# <time datetime="2026-08-29T10:00:00-04:00"> (date + heure8# locales, parfois début ET fin), titre + lien, extrait.9# Enrichissement par fiche (incrémental, cache disque10# data/longueuil_cache.json, plafond LGL_FICHE_MAX/sync,11# re-visite 30 j) : adresse civique, code postal, municipalité12# extraits de l'état Nuxt (address_line1/postal_code/locality).13# GPS absent de la source → géocodeur central (salle + ville).14# Accès : portail municipal public, rendu serveur — GET directs15# throttlés, identification honnête. (L'ancien jeu « événements »16# sur Données Québec est périmé depuis 2017 — vérifié 2026-08-17 ;17# ce portail est la source vivante.)18# Prix : non publié dans la liste → jamais inventé.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import os23import re24import time2526from ..normalize import clean_text, parse_date_iso, parse_time27from ..schema import Event28from .base import BaseConnector2930BASE = "https://longueuil.quebec"31LIST_URL = BASE + "/fr/evenements"32LGL_FICHE_MAX = int(os.environ.get("LGL_FICHE_MAX", "30")) # fiches/sync33REFRESH_AFTER = 30 * 86400 # l'adresse d'un événement bouge rarement3435_ARTICLE_RE = re.compile(r"<article class=\"event__item\".*?</article>", re.S)36_TIME_RE = re.compile(r"datetime=\"([^\"]+)\"")37_LINK_RE = re.compile(r"href=\"(/fr/evenements/[^\"]+)\"")38_TITLE_RE = re.compile(r"link__txt[^>]*>\s*(.*?)\s*</span>", re.S)39_EXCERPT_RE = re.compile(r"event__excerpt[^>]*>\s*(.*?)\s*</p>", re.S)4041# champs de l'état Nuxt des fiches (valeurs entre guillemets, échappées JS)42_ADDR_RE = re.compile(r"address_line1:\"([^\"]*)\"")43_POSTAL_RE = re.compile(r"postal_code:\"([^\"]*)\"")44_LOCALITY_RE = re.compile(r"locality:\"([^\"]*)\"")45# visuel de la fiche : URL mediaImages du CMS dans l'état Nuxt (styles46# front_medium_16_9 / front_large_21_9), échappée /47_IMG_RES = (48 re.compile(r"\"(https:\\u002F\\u002Fcms\.longueuil\.quebec\\u002Fsites"49 r"[^\"]*?front_medium_16_9[^\"]*?)\""),50 re.compile(r"\"(https:\\u002F\\u002Fcms\.longueuil\.quebec\\u002Fsites"51 r"[^\"]*?front_large_21_9[^\"]*?)\""),52)535455def _js_str(raw: str) -> str:56 """Décode les échappements JS (\\u00e9 → é) d'une valeur d'état Nuxt."""57 if "\\u" in raw or "\\/" in raw:58 try:59 return raw.encode("utf-8").decode("unicode_escape")60 except Exception:61 return raw62 return raw636465class LongueuilConnector(BaseConnector):66 source_id = "longueuil"67 request_delay = 0.86869 def _cards(self, html: str) -> list[dict]:70 rows = []71 for block in _ARTICLE_RE.findall(html):72 link = _LINK_RE.search(block)73 title = _TITLE_RE.search(block)74 times = _TIME_RE.findall(block)75 if not (link and title and times):76 continue77 excerpt = _EXCERPT_RE.search(block)78 path = link.group(1)79 rows.append({80 "url": BASE + path,81 "external_id": path.rstrip("/").rsplit("/", 1)[-1],82 "title": clean_text(re.sub(r"<[^>]+>", "", title.group(1))),83 "description": clean_text(re.sub(84 r"<[^>]+>", "", excerpt.group(1))) if excerpt else "",85 "start": times[0],86 "end": times[-1],87 })88 return rows8990 def _enrich_addresses(self, rows: list[dict]) -> None:91 """Adresse/ville depuis la fiche (état Nuxt) — incrémental, plafonné."""92 cache = self.load_cache()93 now = time.time()94 current = {r["url"] for r in rows}95 cache = {u: c for u, c in cache.items() if u in current}9697 to_fetch = sorted(98 (u for u in current99 if u not in cache100 or "image" not in cache[u] # entrées d'avant la Phase 2101 or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),102 key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:LGL_FICHE_MAX]103104 def worker(url, session):105 html = session.get(url, timeout=20).text106 addr = _ADDR_RE.search(html)107 postal = _POSTAL_RE.search(html)108 loc = _LOCALITY_RE.search(html)109 image = ""110 for img_re in _IMG_RES:111 m = img_re.search(html)112 if m:113 image = _js_str(m.group(1))114 break115 return {"address": _js_str(addr.group(1)) if addr else "",116 "postal": _js_str(postal.group(1)) if postal else "",117 "city": _js_str(loc.group(1)) if loc else "",118 "image": image}119120 for url, info in self.fetch_many(to_fetch, worker, max_workers=4).items():121 if info is not None:122 info["fetched_at"] = now123 cache[url] = info124 self.save_cache(cache)125 for r in rows:126 info = cache.get(r["url"]) or {}127 r["address"] = info.get("address", "")128 r["postal"] = info.get("postal", "")129 r["city"] = info.get("city") or "Longueuil"130 r["image"] = info.get("image", "")131132 def fetch(self) -> list[Event]:133 rows = self._cards(self.get(LIST_URL).text)134 self._enrich_addresses(rows)135 events: list[Event] = []136 seen: set[str] = set()137 for r in rows:138 if r["external_id"] in seen:139 continue140 seen.add(r["external_id"])141 start_date = parse_date_iso(r["start"])142 end_date = parse_date_iso(r["end"])143 start_time = parse_time(r["start"])144 if start_time == "00:00": # minuit = « toute la journée »145 start_time = None146 end_time = parse_time(r["end"]) if r["end"] != r["start"] else None147 if end_time == "00:00":148 end_time = None149 events.append(Event(150 source=self.source_id,151 external_id=r["external_id"],152 url=r["url"],153 title=r["title"],154 description=r["description"],155 raw_categories=[r["title"]],156 address=r.get("address", ""),157 postal_code=r.get("postal", ""),158 city=r.get("city", "Longueuil"),159 region="Montérégie",160 start_date=start_date,161 start_time=start_time,162 end_date=end_date or start_date,163 end_time=end_time,164 image=r.get("image", ""),165 ))166 return events167