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.5 KB · 174 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/laurentides.py : Tourisme Laurentides (ATR) — RÉHABILITÉE (vague5#   ATR 2026-08 ; écartée Phase 3 pour WAF 403 : la chaîne anti-bot passe)6#   Source    : laurentides.com/evenements — calendrier officiel de l'ATR7#               (WordPress + Modern Events Calendar « mec-events »).8#   Extraction: API REST WordPress /wp-json/wp/v2/mec-events (paginée9#               per_page=100, ~228 fiches, X-WP-TotalPages vérifié 2026-08-25)10#               via BaseConnector.get() dont l'AUTO-ESCALADE anti-bot11#               (Oxylabs → Scrapfly ASP → Bright Data) perce le WAF qui bloque12#               le GET direct (403). La liste donne titre, description13#               (content), image (_embedded featuredmedia), lien, modified.14#               MEC n'expose PAS les dates en REST → fiches détail15#               incrémentales (cache data/laurentides_cache.json, plafond16#               LAU_FICHE_MAX/sync, re-visite si `modified` change) : dates17#               (mec-start/end-date-label « Déc 05 2026 »), tarif18#               (mec-events-event-cost), lieu + adresse19#               (mec-single-event-location).20#   Accès     : API REST publique (aucune auth) ; le WAF de l'hébergeur21#               bloque les UA non-navigateur → escalade proxy/Scrapfly,22#               requêtes throttlées et plafonnées (coût par requête), lien23#               vers la fiche originale. Prix : texte source, jamais inventé.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import time3031from ..normalize import clean_text, parse_date_fr, utc_to_local32from ..schema import Event33from .base import BaseConnector3435BASE = "https://www.laurentides.com"36API = BASE + "/wp-json/wp/v2/mec-events"37PER_PAGE = 10038LAU_PAGE_MAX = int(os.environ.get("LAU_PAGE_MAX", "6"))39LAU_FICHE_MAX = int(os.environ.get("LAU_FICHE_MAX", "10"))4041# « Déc 05 2026 » (format MEC) → date ISO via parse_date_fr (« 05 dec 2026 »)42_MEC_DATE_RE = re.compile(r"([A-Za-zÀ-ÿ]{3,9})\s+(\d{1,2})\s+(\d{4})")43_START_RE = re.compile(r'mec-start-date-label[^>]*>([^<]*)')44_END_RE = re.compile(r'mec-end-date-label[^>]*>([^<]*)')45_COST_RE = re.compile(r'mec-events-event-cost[^>]*>([^<]*)')46_LOC_VENUE_RE = re.compile(47    r'mec-single-event-location.*?<dd class="author[^"]*">([^<]*)', re.S)48_LOC_ADDR_RE = re.compile(49    r'mec-single-event-location.*?<span class="mec-address">([^<]*)', re.S)50_ADDR_CITY_RE = re.compile(r",\s*([^,]+),\s*QC", re.I)51# lien « Ajouter à mon agenda Google » : datetimes UTC réels de la séance52_GCAL_RE = re.compile(r"dates=(\d{8}T\d{6})Z(?:%2F|/)(\d{8}T\d{6})Z")535455def _mec_date(raw: str) -> str | None:56    m = _MEC_DATE_RE.search(clean_text(raw or ""))57    if not m:58        return None59    return parse_date_fr(f"{m.group(2)} {m.group(1)} {m.group(3)}")606162class LaurentidesConnector(BaseConnector):63    source_id = "laurentides"64    request_delay = 1.0          # chaque requête peut coûter un crédit Scrapfly6566    def _list_events(self) -> list[dict]:67        rows: list[dict] = []68        for page in range(1, LAU_PAGE_MAX + 1):69            try:70                batch = self.get_json(API, params={71                    "per_page": PER_PAGE, "page": page, "_embed": "1"})72            except Exception:73                break74            if not isinstance(batch, list) or not batch:75                break76            rows.extend(batch)77            if len(batch) < PER_PAGE:78                break79        return rows8081    def _parse_fiche(self, html: str) -> dict:82        out: dict = {}83        # datetimes UTC réels (lien Google Agenda généré par MEC), sinon84        # libellés de dates du bandeau (« Déc 05 2026 », sans heure)85        m = _GCAL_RE.search(html)86        if m:87            def _iso(c: str) -> str:   # « 20261205T150000 » → ISO UTC88                return (f"{c[:4]}-{c[4:6]}-{c[6:8]}T"89                        f"{c[9:11]}:{c[11:13]}:{c[13:15]}Z")90            out["start_date"], out["start_time"] = utc_to_local(_iso(m.group(1)))91            out["end_date"], out["end_time"] = utc_to_local(_iso(m.group(2)))92        if not out.get("start_date"):93            d = _START_RE.search(html)94            if d:95                out["start_date"] = _mec_date(d.group(1))96            d = _END_RE.search(html)97            if d:98                out["end_date"] = _mec_date(d.group(1))99        m = _COST_RE.search(html)100        if m:101            out["price_label"] = clean_text(m.group(1))102        m = _LOC_VENUE_RE.search(html)103        if m:104            out["venue"] = clean_text(m.group(1))105        m = _LOC_ADDR_RE.search(html)106        if m:107            out["address"] = clean_text(m.group(1))108            c = _ADDR_CITY_RE.search(out["address"])109            if c:110                out["city"] = clean_text(c.group(1))111        return out112113    def fetch(self) -> list[Event]:114        rows = self._list_events()115        cache = self.load_cache()116        now = time.time()117        current = {str(r.get("id")) for r in rows}118        cache = {k: v for k, v in cache.items() if k in current}119120        # incrémental : nouvelles fiches d'abord, puis celles dont le contenu121        # a changé chez la source (champ `modified` du REST)122        def _stale(r: dict) -> bool:123            entry = cache.get(str(r.get("id")))124            return not entry or entry.get("modified") != r.get("modified")125126        to_fetch = sorted(127            (r for r in rows if r.get("link") and _stale(r)),128            key=lambda r: cache.get(str(r.get("id")), {}).get("ts", 0)129        )[:LAU_FICHE_MAX]130        for row in to_fetch:131            eid = str(row.get("id"))132            detail: dict = {}133            try:134                detail = self._parse_fiche(self.get(row["link"]).text)135            except Exception:136                detail = {}137            cache[eid] = {"ts": now, "modified": row.get("modified"),138                          "detail": detail}139        self.save_cache(cache)140141        events: list[Event] = []142        for r in rows:143            eid = str(r.get("id") or "")144            title = clean_text((r.get("title") or {}).get("rendered", ""))145            if not eid or not title:146                continue147            detail = cache.get(eid, {}).get("detail") or {}148            image = ""149            media = ((r.get("_embedded") or {}).get("wp:featuredmedia") or [])150            if media and isinstance(media[0], dict):151                image = media[0].get("source_url", "") or ""152            description = clean_text(153                (r.get("content") or {}).get("rendered", "")) or clean_text(154                (r.get("excerpt") or {}).get("rendered", ""))155            events.append(Event(156                source=self.source_id,157                external_id=eid,158                url=r.get("link", ""),159                title=title,160                description=description,161                raw_categories=[title],162                venue=detail.get("venue", ""),163                address=detail.get("address", ""),164                city=detail.get("city", ""),165                tourist_region="Laurentides",166                start_date=detail.get("start_date"),167                start_time=detail.get("start_time"),168                end_date=detail.get("end_date") or detail.get("start_date"),169                end_time=detail.get("end_time"),170                price_label=detail.get("price_label", ""),171                image=image,172            ))173        return events174