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%
5.6 KB · 132 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/lavalweb.py : Ville de Laval — calendrier VIVANT laval.ca (Phase 3)5#   Remplace la source « laval » (jeu Données Québec MORT À LA SOURCE depuis6#   2025-12, consigné Phase 2). L'ancien connecteur reste branché (réveil7#   automatique si la Ville republie) ; le recoupement éventuel est géré par8#   dedup_key.9#   Source    : https://www.laval.ca/calendrier-activites/ — WordPress, listes10#               rendues serveur, pagination ?findstr[group]=events&findstr[page]=N11#               (~58 pages × 8 cartes ≈ 460 activités à venir, vérifié 2026-08-19).12#   Extraction: cartes .event-item (date FR « 18 août 2026 | 18 h », titre,13#               lieu, lien, image lazy) + fiches détail incrémentales (cache14#               data/lavalweb_cache.json, LAVALWEB_FICHE_MAX/sync, re-visite15#               7 j) : og:description. GPS via annuaire de salles/géocodeur.16#   Accès     : robots.txt laval.ca : « Disallow: /wp-admin/ » seulement — les17#               pages calendrier sont libres. GET throttlés 0,5 s, UA honnête.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import os22import re23import time2425from ..normalize import clean_text, parse_date_range_fr, parse_time26from ..schema import Event27from .base import BaseConnector2829BASE = "https://www.laval.ca"30LIST_URL = BASE + "/calendrier-activites/?findstr%5Bgroup%5D=events&findstr%5Bpage%5D={page}"31PAGES_MAX = int(os.environ.get("LAVALWEB_PAGES_MAX", "60"))32FICHE_MAX = int(os.environ.get("LAVALWEB_FICHE_MAX", "30"))33REFRESH_AFTER = 7 * 864003435_CARD_RE = re.compile(r'class="event-item">(.*?)</a>', re.S)36_LINK_RE = re.compile(r'href="(/calendrier-activites/[^"?#]+)"')37_DATE_RE = re.compile(r'event-item__date">([^<]+)<')38_TITLE_RE = re.compile(r'event-item__title"><span>([^<]+)<')39_VENUE_RE = re.compile(r'event-item__localisation">\s*<span>([^<]+)<')40_IMG_RE = re.compile(r'data-lazy-src="(https://www\.laval\.ca/wp-content/[^"]+)"')41_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')424344class LavalWebConnector(BaseConnector):45    source_id = "lavalweb"46    request_delay = 0.54748    def _parse_page(self, html: str) -> list[dict]:49        rows = []50        for m in _CARD_RE.finditer(html):51            card = m.group(1)52            link = _LINK_RE.search(card)53            title = _TITLE_RE.search(card)54            if not link or not title:55                continue56            date_raw = _DATE_RE.search(card)57            # « 18 août 2026 | 18 h » (séance) ou « Du 4 juin au 22 août 2026 »58            start = end = start_time = None59            if date_raw:60                txt = clean_text(date_raw.group(1))61                parts = txt.split("|")62                start, end = parse_date_range_fr(parts[0])63                if len(parts) > 1:64                    start_time = parse_time(parts[1])65            venue = _VENUE_RE.search(card)66            img = _IMG_RE.search(card)67            slug = link.group(1).strip("/").split("/")[-1]68            rows.append({69                "external_id": slug,70                "url": BASE + link.group(1),71                "title": clean_text(title.group(1)),72                "venue": clean_text(venue.group(1)) if venue else "",73                "start_date": start, "end_date": end, "start_time": start_time,74                "image": img.group(1) if img else "",75            })76        return rows7778    def _enrich_fiches(self, rows: list[dict]) -> None:79        """og:description des fiches — incrémental, plafonné, re-visite 7 j."""80        cache = self.load_cache()81        now = time.time()82        current = {r["url"] for r in rows}83        cache = {u: c for u, c in cache.items() if u in current}84        to_fetch = sorted(85            (u for u in current86             if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER),87            key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX]8889        def worker(url, session):90            m = _OG_DESC_RE.search(session.get(url, timeout=20).text)91            return clean_text(m.group(1)) if m else ""9293        for url, desc in self.fetch_many(to_fetch, worker, max_workers=4).items():94            cache[url] = {"ts": now, "desc": desc or ""}95        self.save_cache(cache)96        for r in rows:97            r["description"] = (cache.get(r["url"]) or {}).get("desc", "")9899    def fetch(self) -> list[Event]:100        rows: list[dict] = []101        for page in range(1, PAGES_MAX + 1):102            html = self.get(LIST_URL.format(page=page)).text103            batch = self._parse_page(html)104            if not batch:105                break106            rows.extend(batch)107        self._enrich_fiches(rows)108109        events: list[Event] = []110        seen: set[str] = set()111        for r in rows:112            if r["external_id"] in seen:113                continue114            seen.add(r["external_id"])115            events.append(Event(116                source=self.source_id,117                external_id=r["external_id"],118                url=r["url"],119                title=r["title"],120                description=r.get("description", ""),121                raw_categories=[r["title"]],122                venue=r["venue"],123                city="Laval",124                region="Laval",125                start_date=r["start_date"],126                start_time=r["start_time"],127                end_date=r["end_date"],128                image=r["image"],129                organizer="Ville de Laval",130            ))131        return events132