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.7 KB · 172 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/bassaintlaurent.py : Tourisme Bas-Saint-Laurent — événements (ATR)5#   Source    : https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/ —6#               WordPress (Bedrock), liste rendue serveur (~27 fiches à venir,7#               revérifié 2026-08-25 ; le marqueur ajax-load-more__end-page8#               confirme que TOUTE la liste tient sur la page 1 — aucun9#               événement caché derrière l'AJAX get_grid).10#   Extraction: cartes .card--type-event (lien, plage de dates FR, titre,11#               municipalité, image) + fiches détail incrémentales (cache12#               data/bassaintlaurent_cache.json, BSL_FICHE_MAX/sync, re-visite13#               7 j, version v=2). ENRICHI (2026-08-25) : bloc window.locations14#               de la fiche (JSON structuré) → adresse civique (street), ville,15#               code postal, GPS exact, site web officiel ; description longue16#               = paragraphes « À propos » (fallback og:description). Pas17#               d'heure ni de tarif publiés → jamais inventés.18#   Accès     : pages publiques de l'ATR, rendu serveur — GET throttlés 0,8 s,19#               identification honnête, lien vers la fiche originale.20#   Décision  : le Bas-Saint-Laurent n'avait que 168 fiches au baseline —21#               équilibre régional (04-SOURCES-CANDIDATES §A).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import json26import os27import re28import time2930from ..normalize import clean_text, parse_date_range_fr31from ..schema import Event32from .base import BaseConnector3334BASE = "https://www.bassaintlaurent.ca"35LIST_URL = BASE + "/fr/quoi-faire/evenements/"36FICHE_MAX = int(os.environ.get("BSL_FICHE_MAX", "15"))37REFRESH_AFTER = 7 * 8640038CACHE_V = 2                 # v2 : + adresse/ville/CP/site web (window.locations)39#                             + description « À propos » (2026-08-25)4041_CARD_RE = re.compile(42    r'<a href="(https://www\.bassaintlaurent\.ca/fr/quoi-faire/evenements/'43    r'[^"]+)"[^>]*class="card-location card--type-event">(.*?)</a>', re.S)44_DATE_RE = re.compile(r'card-location__dates">([^<]+)<')45_TITLE_RE = re.compile(r'card-location__title">([^<]+)<')46_CITY_RE = re.compile(r'card-location__city">(?:<[^>]+>)*([^<]+)<')47_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"')48_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')49_GPS_RE = re.compile(50    r'"lat(?:itude)?"\s*[:=]\s*"?(-?\d{2}\.\d+)"?\s*,\s*'51    r'"(?:lng|lon|longitude)"\s*[:=]\s*"?(-?\d{2}\.\d+)"?')52# fiche : annuaire JSON embarqué (adresse structurée + site web)53_LOCATIONS_RE = re.compile(r"window\.locations\s*=\s*(\[.*?\]);\s*</script>",54                           re.S)55# fiche : paragraphes « À propos » (description longue)56_ABOUT_RE = re.compile(r'js-truncated text-content">(.*?)</div>', re.S)57_END_PAGE_MARK = "ajax-load-more__end-page"585960class BasSaintLaurentConnector(BaseConnector):61    source_id = "bassaintlaurent"62    request_delay = 0.86364    def _parse_fiche(self, html: str) -> dict:65        gps = _GPS_RE.search(html)66        det: dict = {67            "desc": "", "lat": float(gps.group(1)) if gps else None,68            "lng": float(gps.group(2)) if gps else None,69            "address": "", "city": "", "postal_code": "", "website": "",70        }71        about = _ABOUT_RE.search(html)72        if about:73            det["desc"] = clean_text(about.group(1))[:2000]74        if not det["desc"]:75            m = _OG_DESC_RE.search(html)76            det["desc"] = clean_text(m.group(1)) if m else ""77        loc_m = _LOCATIONS_RE.search(html)78        if loc_m:79            try:80                loc = json.loads(loc_m.group(1))[0]81            except Exception:82                loc = {}83            det["address"] = clean_text(loc.get("street") or "")84            det["city"] = clean_text(loc.get("city") or "")85            det["postal_code"] = (loc.get("postal_code") or "").strip()86            site = (loc.get("url") or "").strip()87            if site and not site.startswith("http"):88                site = "https://" + site89            det["website"] = site90        return det9192    def _enrich_fiches(self, rows: list[dict]) -> None:93        cache = self.load_cache()94        now = time.time()95        current = {r["url"] for r in rows}96        cache = {u: c for u, c in cache.items() if u in current}97        # nouvelles fiches puis celles parsées avant v2, puis re-visite roulante98        to_fetch = sorted(99            (u for u in current100             if u not in cache or cache[u].get("v", 1) < CACHE_V101             or now - cache[u].get("ts", 0) > REFRESH_AFTER),102            key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,103                           cache.get(u, {}).get("ts", 0)))[:FICHE_MAX]104105        def worker(url, session):106            return self._parse_fiche(session.get(url, timeout=25).text)107108        for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items():109            cache[url] = {"ts": now, "v": CACHE_V,110                          **(det or {"desc": "", "lat": None, "lng": None})}111        self.save_cache(cache)112        for r in rows:113            det = cache.get(r["url"]) or {}114            r["description"] = det.get("desc", "")115            r["lat"], r["lng"] = det.get("lat"), det.get("lng")116            r["address"] = det.get("address", "")117            r["postal_code"] = det.get("postal_code", "")118            r["website"] = det.get("website", "")119            if det.get("city"):               # ville structurée de la fiche120                r["city"] = det["city"]121122    def fetch(self) -> list[Event]:123        html = self.get(LIST_URL).text124        if _END_PAGE_MARK not in html:        # garde-fou : pagination apparue ?125            print("[sorti-ka] bassaintlaurent : liste possiblement paginée "126                  "(marqueur end-page absent) — vérifier get_grid")127        rows: list[dict] = []128        for m in _CARD_RE.finditer(html):129            url, card = m.group(1), m.group(2)130            title = _TITLE_RE.search(card)131            if not title:132                continue133            date_raw = _DATE_RE.search(card)134            start, end = parse_date_range_fr(135                date_raw.group(1) if date_raw else "")136            city = _CITY_RE.search(card)137            img = _IMG_RE.search(card)138            rows.append({139                "external_id": url.rstrip("/").split("/")[-1],140                "url": url,141                "title": clean_text(title.group(1)),142                "city": clean_text(city.group(1)) if city else "",143                "start_date": start, "end_date": end,144                "image": img.group(1) if img else "",145            })146        self._enrich_fiches(rows)147148        events: list[Event] = []149        seen: set[str] = set()150        for r in rows:151            if r["external_id"] in seen:152                continue153            seen.add(r["external_id"])154            events.append(Event(155                source=self.source_id,156                external_id=r["external_id"],157                url=r["url"],158                title=r["title"],159                description=r.get("description", ""),160                raw_categories=[r["title"]],161                address=r.get("address", ""),162                city=r["city"],163                postal_code=r.get("postal_code", ""),164                tourist_region="Bas-Saint-Laurent",165                lat=r.get("lat"), lng=r.get("lng"),166                start_date=r["start_date"],167                end_date=r["end_date"],168                website=r.get("website", ""),169                image=r["image"],170            ))171        return events172