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/atuvu.py : connecteur Atuvu.ca — calendrier culturel (Grand Montréal +)5# Source : atuvu.ca (théâtre, musique, humour, danse, expositions…)6# Extraction: GET direct d'abord (vérifié 200 depuis le nœud de prod,7# 2026-08-25 ; escalade anti-bot auto du BaseConnector), repli8# SCRAPFLY si défi Cloudflare — sitemaps XML publics9# (evenements_N_2000.xml, lastmod par fiche) puis pages fiche10# (h1, blocs .event-play : date FR + HEURE, salle, ville, prix).11# INCRÉMENTAL avec cache disque (data/atuvu_cache.json), version12# de parse « v » (patron lavitrine) : fiches nouvelles/modifiées13# d'abord, backfill v<2 (rows figés sans heure/prix par un14# parseur antérieur — constaté 2026-08-25), puis re-visite15# roulante 14 j des fiches à venir (les tarifs apparaissent16# souvent APRÈS la mise en vente, sans bump de lastmod),17# plafonné à ATUVU_MAX_FETCH pages/sync.18# Accès : robots.txt « User-Agent: * / Allow: / » + sitemap public.19# Prix : bloc .price-reg de la fiche (prix régulier) → price_min ;20# « Invitation gratuite » = offre membre, PAS gratuité → ignorée.21# Séances : 2 représentations le même jour = 2 événements (suffixe -HHMM22# dès la 2ᵉ séance du jour ; la 1ʳᵉ garde l'id historique).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import re27import time28from datetime import date, timedelta2930from ..normalize import clean_text, parse_date_fr, parse_time31from ..schema import Event32from .base import BaseConnector3334SITEMAP_INDEX = "https://atuvu.ca/atuvu_sitemap_index.xml"3536ATUVU_MAX_FETCH = int(__import__("os").environ.get("ATUVU_MAX_FETCH", "120")) # fiches/sync (coût Scrapfly)37LASTMOD_WINDOW_DAYS = 90 # fiches modifiées dans cette fenêtre seulement38SHARDS_TO_READ = 3 # derniers shards du sitemap (ids les plus récents)39PARSE_V = 2 # version du parseur (bump → backfill des rows figés)40REFRESH_AFTER = 14 * 86400 # re-visite roulante d'une fiche à venir (tarifs)4142# défi Cloudflare renvoyé en 200 (non escaladé par get()) → repli Scrapfly43_BLOCK_HINTS = ("just a moment", "attention required", "cf-challenge",44 "challenge-platform")4546_LOC_RE = re.compile(r"<loc>([^<]+)</loc>\s*(?:<lastmod>([^<]+)</lastmod>)?")47_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.S)48_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)49_OG_RE = {k: re.compile(rf'property="og:{k}" content="([^"]*)"')50 for k in ("image", "description")}51_PLAY_RE = re.compile(52 r'class="date">\s*([^<]+?)\s*</div>.*?class="place">\s*'53 r'<span>([^<]*)</span>(?:<br\s*/?>\s*<span>([^<]*)</span>)?', re.S)54_PRICE_RE = re.compile(r'class="price-reg[^"]*">\s*([\d.,]+)\s*\$')55_CAT_RE = re.compile(r'class="main_cat[^"]*">([^<]+)<')565758class AtuvuConnector(BaseConnector):59 source_id = "atuvu"60 request_delay = 1.06162 # -- extraction -------------------------------------------------------63 def _fetch_html(self, url: str) -> str:64 """GET direct d'abord (fonctionne depuis le nœud de prod, escalade65 anti-bot automatique du BaseConnector sur 403/429), repli Scrapfly si66 la page renvoyée est un défi Cloudflare servi en 200."""67 try:68 html = self.get(url).text69 low = html[:2000].lower()70 if not any(h in low for h in _BLOCK_HINTS):71 return html72 except Exception:73 pass74 return self.get_rendered(url, render_js=False)7576 # -- sitemap ----------------------------------------------------------77 def _recent_fiches(self) -> dict[str, str]:78 """URL de fiche → lastmod, pour les fiches modifiées récemment."""79 idx = self._fetch_html(SITEMAP_INDEX)80 shards = [u for u, _ in _LOC_RE.findall(idx) if "/evenements_" in u]81 cutoff = (date.today() - timedelta(days=LASTMOD_WINDOW_DAYS)).isoformat()82 fiches: dict[str, str] = {}83 for shard in shards[-SHARDS_TO_READ:]:84 xml = self._fetch_html(shard)85 for block in _URL_BLOCK_RE.findall(xml):86 m = _LOC_RE.search(block)87 if not m:88 continue89 url, lastmod = m.group(1), (m.group(2) or "")[:10]90 if "/billets-spectacle/" in url and lastmod >= cutoff:91 fiches[url] = lastmod92 return fiches9394 # -- fiche ------------------------------------------------------------95 def _parse_fiche(self, url: str, html: str) -> list[dict]:96 h1 = _H1_RE.search(html)97 title = clean_text(h1.group(1)) if h1 else ""98 if not title:99 return []100 image = (_OG_RE["image"].search(html) or [None, ""])[1]101 # og:description : déjà téléchargée (crédits Scrapfly) — Phase 2, on102 # l'affecte enfin à l'événement (elle était compilée mais jetée)…103 # SAUF quand c'est le gabarit marketing du site (« trouvez des billets104 # à tarif réduit… »), constaté sur fiche réelle : on ne publie pas de105 # texte boilerplate comme description d'événement. Le backfill se fait106 # au fil des re-scrapes lastmod (aucun crédit Scrapfly ajouté).107 description = clean_text((_OG_RE["description"].search(html)108 or [None, ""])[1])109 if "trouvez des billets" in description.lower():110 description = ""111 cats = [clean_text(c) for c in _CAT_RE.findall(html)][:2]112 price_m = _PRICE_RE.search(html)113 price = float(price_m.group(1).replace(",", ".")) if price_m else None114 slug = url.rstrip("/").rsplit("/", 1)[-1]115 rows = []116 seen_days: dict[str, int] = {}117 for date_txt, venue, city in _PLAY_RE.findall(html)[:10]:118 day = parse_date_fr(date_txt)119 if not day:120 continue121 hhmm = parse_time(date_txt) # « … - 20h00 » de la fiche122 # séances multiples le même jour : la 1ʳᵉ garde l'id historique123 # (zéro churn en base), les suivantes sont suffixées par l'heure124 # — sinon elles s'écrasaient mutuellement à l'ingestion.125 n = seen_days.get(day, 0)126 seen_days[day] = n + 1127 ext = (f"{slug}-{day}" if n == 0128 else f"{slug}-{day}-{(hhmm or f'x{n}').replace(':', '')}")129 rows.append({130 "external_id": ext,131 "start_time": hhmm,132 "url": url, "title": title, "image": image,133 "description": description,134 "raw_categories": cats, "venue": clean_text(venue),135 "city": clean_text(city or ""), "start_date": day,136 "end_date": day, "price_min": price,137 "price_label": f"{price:.2f} $ (prix régulier atuvu.ca)" if price else "",138 })139 return rows140141 # -- pipeline ---------------------------------------------------------142 def fetch(self) -> list[Event]:143 cache = self.load_cache()144 fiches = self._recent_fiches()145146 # retirer du cache les fiches disparues du sitemap récent147 cache = {u: c for u, c in cache.items() if u in fiches}148149 now = time.time()150 today = date.today().isoformat()151 to_fetch = [u for u, mod in fiches.items()152 if cache.get(u, {}).get("lastmod") != mod]153154 def _stale_rank(u: str) -> int | None:155 """0 = rows figés par un parseur < v2 (heure/prix manquants) ;156 1 = fiche à venir non re-visitée depuis REFRESH_AFTER (les tarifs157 apparaissent souvent après la mise en vente, sans bump de lastmod158 — constaté 2026-08-25) ; None = rien à faire."""159 entry = cache.get(u) or {}160 if entry.get("v", 1) < PARSE_V:161 return 0162 if (now - entry.get("fetched_at", 0) > REFRESH_AFTER163 and any((r.get("start_date") or "") >= today164 for r in entry.get("rows") or [])):165 return 1166 return None167168 backfill = sorted(169 (u for u in fiches170 if u not in set(to_fetch) and _stale_rank(u) is not None),171 key=lambda u: (_stale_rank(u), cache.get(u, {}).get("fetched_at", 0)))172 to_fetch = (to_fetch + backfill)[:ATUVU_MAX_FETCH]173 for url in to_fetch:174 try:175 html = self._fetch_html(url)176 cache[url] = {"lastmod": fiches[url], "fetched_at": time.time(),177 "v": PARSE_V, "rows": self._parse_fiche(url, html)}178 except Exception as exc: # une fiche cassée ne bloque pas la source179 print(f"[sorti-ka] atuvu : fiche ignorée {url} : {exc}")180 self.save_cache(cache)181182 events: list[Event] = []183 for entry in cache.values():184 for row in entry.get("rows", []):185 events.append(Event(186 source=self.source_id,187 external_id=row["external_id"],188 url=row["url"], title=row["title"],189 description=row.get("description", ""),190 raw_categories=row.get("raw_categories") or [],191 venue=row.get("venue", ""), city=row.get("city", ""),192 start_date=row.get("start_date"),193 start_time=row.get("start_time"),194 end_date=row.get("end_date"),195 price_min=row.get("price_min"),196 price_label=row.get("price_label", ""),197 is_free=False if row.get("price_min") else None,198 image=row.get("image", ""),199 ))200 return events201