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%
9.9 KB · 223 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/cantonsdelest.py : connecteur Tourisme Cantons-de-l'Est (ATR Estrie)5#   Source    : cantonsdelest.com/evenements — calendrier officiel de6#               l'association touristique régionale (données Tourinsoft, la7#               même plateforme que le SIT Québec : médias mto.tourinsoft.eu).8#   Extraction: la page liste ne rend que ~13 cartes mais publie ~5969#               occurrences (data-found) derrière un bouton « Voir plus » →10#               PAGINATION AJAX : POST /event/more {ids, offset} avec le11#               jeton CSRF Yii de la page (vérifié 2026-08-25). Chaque carte12#               donne lien /evenements/<id>/<slug>[/<idDate>], catégorie,13#               titre, image et « Ville | date FR ». Fiches détail14#               incrémentales (cache disque data/cantonsdelest_cache.json,15#               plafond CDE_FICHE_MAX/sync, re-visite 7 j) : JSON-LD Event16#               complet (description riche, adresse civique, images) + GPS17#               exact (attributs data-lat/data-lng de la carte).18#   Accès     : pages publiques de l'ATR, rendu serveur — GET/POST directs19#               throttlés, identification honnête, lien vers la fiche20#               originale. Recoupement avec le flux SITQ géré par dedup_key.21#   Prix      : non publié de façon structurée → jamais inventé.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.cantonsdelest.com"35LIST_URL = BASE + "/evenements"36MORE_URL = BASE + "/event/more"37CDE_FICHE_MAX = int(os.environ.get("CDE_FICHE_MAX", "40"))   # fiches/sync38CDE_PAGE_MAX = int(os.environ.get("CDE_PAGE_MAX", "120"))    # pages AJAX/sync39REFRESH_AFTER = 7 * 864004041_LD_JSON_RE = re.compile(r"application/ld\+json[^>]*>")42_GPS_RE = re.compile(r"data-lat=\"(-?\d+\.?\d*)\" data-lng=\"(-?\d+\.?\d*)\"")43_CSRF_PARAM_RE = re.compile(r'<meta name="csrf-param" content="([^"]+)"')44_CSRF_TOKEN_RE = re.compile(r'<meta name="csrf-token" content="([^"]+)"')45_IDS_RE = re.compile(r'data-ids="([^"]+)"')46# une carte de la liste (rendue serveur ou renvoyée par /event/more) :47# lien, image, catégorie, titre, « Ville | 28 août 2026 »48_CARD_RE = re.compile(49    r'<a href="(/evenements/\d+/[^"#?]+)">\s*'50    r'(?:<img src="([^"]*)"[^>]*>)?\s*</a>.*?'51    r'container-article-category">\s*<b>([^<]*)</b>.*?'52    r'container-article-title[^"]*">(.*?)</a>.*?'53    r'listing-city""?>([^<]*)</p>', re.S)54# mois abrégés du gabarit (« 21 janv. 2027 », « 5 févr. 2027 ») que le parseur55# central ne connaît pas — constaté live 2026-08-25 (45/596 cartes)56_MONTH_ABBR_RE = re.compile(r"\b(janv|f[ée]vr)\.?", re.I)57_MONTH_ABBR = {"janv": "janvier", "fevr": "février", "févr": "février"}585960def _dates_fr(raw: str) -> tuple[str | None, str | None]:61    """parse_date_range_fr avec les abréviations propres au site déployées."""62    raw = _MONTH_ABBR_RE.sub(63        lambda m: _MONTH_ABBR[m.group(1).lower()], raw)64    return parse_date_range_fr(raw)656667def _cards(html: str) -> list[dict]:68    """Cartes d'une page liste : path, image, catégorie, titre, ville, dates."""69    out = []70    for m in _CARD_RE.finditer(html):71        path, img, cat, title, city_date = m.groups()72        city, date_raw = "", city_date or ""73        if "|" in city_date:74            city, date_raw = city_date.split("|", 1)75        start, end = _dates_fr(date_raw)76        parts = path.strip("/").split("/")   # evenements/<id>/<slug>[/<idDate>]77        out.append({78            "path": path,79            "external_id": parts[1] + (f"-{parts[3]}" if len(parts) > 3 else ""),80            "url": BASE + path,81            "title": clean_text(title),82            "raw_categories": [clean_text(cat), clean_text(title)],83            "city": clean_text(city),84            "start_date": start, "end_date": end,85            "image": (img or "").strip(),86        })87    return out888990def _ld_event(html: str) -> dict | None:91    """Premier bloc JSON-LD de type Event d'une fiche."""92    for m in _LD_JSON_RE.finditer(html):93        try:94            d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip())95        except Exception:96            continue97        for doc in (d if isinstance(d, list) else [d]):98            if isinstance(doc, dict) and doc.get("@type") == "Event":99                return doc100    return None101102103class CantonsDeLEstConnector(BaseConnector):104    source_id = "cantonsdelest"105    request_delay = 0.8106107    def _post_more(self, data: dict) -> str:108        """POST throttlé vers /event/more (pagination AJAX Yii)."""109        self._throttle()110        resp = self.session.post(MORE_URL, data=data, timeout=self.timeout)111        self._last_request = time.time()112        resp.raise_for_status()113        return resp.text114115    def _list_cards(self) -> list[dict]:116        """Toutes les cartes du calendrier : page 1 rendue serveur puis117        pagination AJAX (POST {ids, offset, _csrf}) jusqu'à épuisement."""118        first = self.get(LIST_URL).text119        cards = _cards(first)120        seen = {c["path"] for c in cards}121        param = _CSRF_PARAM_RE.search(first)122        token = _CSRF_TOKEN_RE.search(first)123        ids = _IDS_RE.search(first)124        if not (param and token and ids):     # structure changée : page 1 seule125            print("[sorti-ka] cantonsdelest : pagination AJAX introuvable")126            return cards127        base = {param.group(1): token.group(1), "ids": ids.group(1)}128        for offset in range(2, CDE_PAGE_MAX + 1):129            try:130                more = _cards(self._post_more({**base, "offset": offset}))131            except Exception:132                break133            new = [c for c in more if c["path"] not in seen]134            if not new:                       # page vide ou répétée → fin135                break136            cards.extend(new)137            seen.update(c["path"] for c in new)138        return cards139140    def _parse_fiche(self, path: str, html: str) -> dict | None:141        ld = _ld_event(html)142        if not ld or not ld.get("name"):143            return None144        addr = ((ld.get("location") or {}).get("address") or {})145        gps = _GPS_RE.search(html)146        images = ld.get("image") or []147        if isinstance(images, str):148            images = [images]149        parts = path.strip("/").split("/")     # evenements/<id>/<slug>[/<idDate>]150        ext = parts[1] + (f"-{parts[3]}" if len(parts) > 3 else "")151        return {152            "external_id": ext,153            "url": BASE + path,154            "title": clean_text(ld.get("name") or ""),155            "description": clean_text(ld.get("description") or ""),156            "raw_categories": [clean_text(ld.get("name") or ""),157                               parts[2].replace("-", " ")],158            "address": clean_text(addr.get("streetAddress") or ""),159            "city": clean_text(addr.get("addressLocality") or ""),160            "postal_code": (addr.get("postalCode") or "").strip(),161            "lat": float(gps.group(1)) if gps else None,162            "lng": float(gps.group(2)) if gps else None,163            "start_date": ld.get("startDate"),164            "end_date": ld.get("endDate") or ld.get("startDate"),165            "image": images[0] if images else "",166        }167168    def fetch(self) -> list[Event]:169        cards = self._list_cards()170        paths = [c["path"] for c in cards]171        cache = self.load_cache()172        now = time.time()173        current = set(paths)174        cache = {p: c for p, c in cache.items() if p in current}175176        to_fetch = sorted(177            (p for p in paths178             if p not in cache179             or now - cache[p].get("fetched_at", 0) > REFRESH_AFTER),180            key=lambda p: cache.get(p, {}).get("fetched_at", 0))[:CDE_FICHE_MAX]181182        def worker(path, session):183            return self._parse_fiche(184                path, session.get(BASE + path, timeout=20).text)185186        for path, row in self.fetch_many(187                to_fetch, worker, max_workers=4).items():188            if row is None:      # fiche cassée : on ne bloque pas la source189                print(f"[sorti-ka] cantonsdelest : fiche ignorée {path}")190                continue191            cache[path] = {"fetched_at": now, "row": row}192        self.save_cache(cache)193194        # émission : la carte (titre, catégorie, ville, date, image) porte195        # chaque occurrence ; la fiche (cache) enrichit description, adresse,196        # code postal, GPS et précise les dates (JSON-LD de l'occurrence)197        events: list[Event] = []198        seen: set[str] = set()199        for card in cards:200            if card["external_id"] in seen:201                continue202            seen.add(card["external_id"])203            fiche = (cache.get(card["path"]) or {}).get("row") or {}204            events.append(Event(205                source=self.source_id,206                external_id=card["external_id"],207                url=card["url"],208                title=card["title"] or fiche.get("title", ""),209                description=fiche.get("description", ""),210                raw_categories=[c for c in card["raw_categories"] if c]211                or fiche.get("raw_categories") or [],212                address=fiche.get("address", ""),213                city=card["city"] or fiche.get("city", ""),214                postal_code=fiche.get("postal_code", ""),215                tourist_region="Cantons-de-l'Est",216                lat=fiche.get("lat"),217                lng=fiche.get("lng"),218                start_date=fiche.get("start_date") or card["start_date"],219                end_date=fiche.get("end_date") or card["end_date"],220                image=card["image"] or fiche.get("image", ""),221            ))222        return events223