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%
7.0 KB · 167 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/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