Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
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