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/evenementsattractions.py : connecteur Événements Attractions Québec5# Source : répertoire consommateur des festivals/événements membres d'EAQ,6# publié sur quebecvacances.com (~350 fiches fr, toute la province).7# (Le site corporatif evenementsattractions.quebec ne liste que8# des formations aux membres — sondé 2026-08-25, sans intérêt.)9# Extraction: sitemap paginé (/sitemap.xml?page=1..N) → fiches10# /fr/evenements/<slug>, chacune avec JSON-LD Event complet11# (nom, description, dates ISO de la prochaine édition, lieu,12# adresse, image Cloudinary). Le site est derrière un anti-bot13# (403 en GET direct) → chaque GET passe par la chaîne de secours14# du BaseConnector (Oxylabs → Scrapfly ASP → Bright Data),15# d'où un cache INCRÉMENTAL agressif (data/16# evenementsattractions_cache.json) : nouvelles fiches d'abord,17# re-visite après 14 jours (festivals annuels stables), plafond18# EAQ_DETAIL_MAX fiches/sync.19# Accès : quebecvacances.com/robots.txt « User-agent: * / Allow: / » →20# crawl permis ; rate-limiting poli, identification honnête.21# Prix : non publié dans le JSON-LD → is_free inconnu (jamais inventé).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import json26import os27import re28import time2930from ..normalize import clean_text31from ..schema import Event32from .base import BaseConnector3334BASE = "https://www.quebecvacances.com"35SITEMAP_PAGES = 4 # /sitemap.xml?page=1..4 (sondé 2026-08-25)3637DETAIL_MAX = int(os.environ.get("EAQ_DETAIL_MAX", "80"))38REFRESH_AFTER = 14 * 86400 # festivals annuels : fiches stables3940_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")41_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',42 re.S)43# « Montréal (ville de) » / « Québec (ville de) » → nom de ville nu44_CITY_SUFFIX_RE = re.compile(r"\s*\((?:ville|municipalité|village|paroisse|"45 r"canton|mrc)[^)]*\)\s*$", re.I)464748def _utf8_text(resp) -> str:49 """Texte de la réponse décodé en UTF-8 réel.5051 Le site sert son HTML UTF-8 sans charset dans Content-Type ; certains52 backends de la chaîne de secours anti-bot décodent alors en latin-1 →53 mojibake (« siège » au lieu de « siège », constaté live 2026-08-25).54 On répare de façon déterministe : latin-1 est bijectif octet↔caractère,55 donc ré-encoder puis décoder en UTF-8 restaure le texte d'origine.56 """57 text = resp.text58 if "Ã" in text: # marqueur sûr de mojibake UTF-8/latin-159 try:60 return text.encode("latin-1").decode("utf-8")61 except (UnicodeEncodeError, UnicodeDecodeError):62 pass63 return text646566class EvenementsAttractionsConnector(BaseConnector):67 source_id = "evenementsattractions"68 request_delay = 1.06970 # -- découverte (sitemap paginé) -------------------------------------------71 def _fiches(self) -> list[str]:72 urls: list[str] = []73 for page in range(1, SITEMAP_PAGES + 1):74 try:75 xml = self.get(f"{BASE}/sitemap.xml", params={"page": page}).text76 except Exception as exc: # une page cassée ne bloque pas le reste77 print(f"[sorti-ka] evenementsattractions : sitemap p{page} "78 f"ignorée : {exc}")79 continue80 urls += [u for u in _LOC_RE.findall(xml)81 if "/fr/evenements/" in u]82 return sorted(set(urls))8384 # -- fiche --------------------------------------------------------------------85 def _parse_fiche(self, url: str, html: str) -> dict | None:86 data = None87 for blob in _LD_RE.findall(html):88 try:89 d = json.loads(blob.strip())90 except ValueError:91 continue92 if d.get("@type") == "Event" and d.get("startDate"):93 data = d94 break95 if not data:96 return None97 title = clean_text(data.get("name") or "")98 if not title:99 return None100 loc = data.get("location") or {}101 addr = loc.get("address") or {}102 city = _CITY_SUFFIX_RE.sub("", clean_text(addr.get("addressLocality")103 or ""))104 venue = clean_text(loc.get("name") or "")105 if venue == title: # la source répète le nom de l'événement106 venue = ""107 return {108 "title": title,109 "description": clean_text(data.get("description") or "")[:2000],110 "raw_categories": [title],111 "venue": venue,112 "address": clean_text(addr.get("streetAddress") or ""),113 "city": city,114 "postal_code": addr.get("postalCode") or "",115 "start_date": str(data.get("startDate") or "")[:10] or None,116 "end_date": str(data.get("endDate") or "")[:10] or None,117 "website": data.get("url") or "",118 "image": data.get("image") or "",119 }120121 # -- pipeline -------------------------------------------------------------------122 def fetch(self) -> list[Event]:123 cache = self.load_cache()124 urls = self._fiches()125 if urls: # sitemap indisponible → on garde le cache126 current = set(urls)127 cache = {u: c for u, c in cache.items() if u in current}128 else:129 urls = list(cache)130131 now = time.time()132 candidates = sorted(133 (u for u in urls134 if u not in cache135 or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),136 key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX]137138 for url in candidates: # séquentiel : chaque GET peut escalader139 try:140 row = self._parse_fiche(url, _utf8_text(self.get(url)))141 except Exception as exc: # fiche cassée : on ne bloque pas la source142 print(f"[sorti-ka] evenementsattractions : fiche ignorée "143 f"{url} : {exc}")144 row = cache.get(url, {}).get("row")145 cache[url] = {"fetched_at": now, "row": row}146 self.save_cache(cache)147148 events: list[Event] = []149 seen: set[str] = set()150 for url, entry in cache.items():151 row = entry.get("row")152 if not row:153 continue154 slug = url.rstrip("/").rsplit("/", 1)[-1]155 if slug in seen:156 continue157 seen.add(slug)158 events.append(Event(159 source=self.source_id,160 external_id=slug,161 url=url, title=row["title"],162 description=row.get("description", ""),163 raw_categories=row.get("raw_categories") or [],164 venue=row.get("venue", ""),165 address=row.get("address", ""),166 city=row.get("city", ""),167 postal_code=row.get("postal_code", ""),168 start_date=row.get("start_date"),169 end_date=row.get("end_date"),170 website=row.get("website", ""),171 image=row.get("image", ""),172 ))173 return events174