# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/atuvu.py : connecteur Atuvu.ca — calendrier culturel (Grand Montréal +) # Source : atuvu.ca (théâtre, musique, humour, danse, expositions…) # Extraction: GET direct d'abord (vérifié 200 depuis le nœud de prod, # 2026-08-25 ; escalade anti-bot auto du BaseConnector), repli # SCRAPFLY si défi Cloudflare — sitemaps XML publics # (evenements_N_2000.xml, lastmod par fiche) puis pages fiche # (h1, blocs .event-play : date FR + HEURE, salle, ville, prix). # INCRÉMENTAL avec cache disque (data/atuvu_cache.json), version # de parse « v » (patron lavitrine) : fiches nouvelles/modifiées # d'abord, backfill v<2 (rows figés sans heure/prix par un # parseur antérieur — constaté 2026-08-25), puis re-visite # roulante 14 j des fiches à venir (les tarifs apparaissent # souvent APRÈS la mise en vente, sans bump de lastmod), # plafonné à ATUVU_MAX_FETCH pages/sync. # Accès : robots.txt « User-Agent: * / Allow: / » + sitemap public. # Prix : bloc .price-reg de la fiche (prix régulier) → price_min ; # « Invitation gratuite » = offre membre, PAS gratuité → ignorée. # Séances : 2 représentations le même jour = 2 événements (suffixe -HHMM # dès la 2ᵉ séance du jour ; la 1ʳᵉ garde l'id historique). # ----------------------------------------------------------------------------- from __future__ import annotations import re import time from datetime import date, timedelta from ..normalize import clean_text, parse_date_fr, parse_time from ..schema import Event from .base import BaseConnector SITEMAP_INDEX = "https://atuvu.ca/atuvu_sitemap_index.xml" ATUVU_MAX_FETCH = int(__import__("os").environ.get("ATUVU_MAX_FETCH", "120")) # fiches/sync (coût Scrapfly) LASTMOD_WINDOW_DAYS = 90 # fiches modifiées dans cette fenêtre seulement SHARDS_TO_READ = 3 # derniers shards du sitemap (ids les plus récents) PARSE_V = 2 # version du parseur (bump → backfill des rows figés) REFRESH_AFTER = 14 * 86400 # re-visite roulante d'une fiche à venir (tarifs) # défi Cloudflare renvoyé en 200 (non escaladé par get()) → repli Scrapfly _BLOCK_HINTS = ("just a moment", "attention required", "cf-challenge", "challenge-platform") _LOC_RE = re.compile(r"([^<]+)\s*(?:([^<]+))?") _URL_BLOCK_RE = re.compile(r"(.*?)", re.S) _H1_RE = re.compile(r"]*>(.*?)", re.S) _OG_RE = {k: re.compile(rf'property="og:{k}" content="([^"]*)"') for k in ("image", "description")} _PLAY_RE = re.compile( r'class="date">\s*([^<]+?)\s*.*?class="place">\s*' r'([^<]*)(?:\s*([^<]*))?', re.S) _PRICE_RE = re.compile(r'class="price-reg[^"]*">\s*([\d.,]+)\s*\$') _CAT_RE = re.compile(r'class="main_cat[^"]*">([^<]+)<') class AtuvuConnector(BaseConnector): source_id = "atuvu" request_delay = 1.0 # -- extraction ------------------------------------------------------- def _fetch_html(self, url: str) -> str: """GET direct d'abord (fonctionne depuis le nœud de prod, escalade anti-bot automatique du BaseConnector sur 403/429), repli Scrapfly si la page renvoyée est un défi Cloudflare servi en 200.""" try: html = self.get(url).text low = html[:2000].lower() if not any(h in low for h in _BLOCK_HINTS): return html except Exception: pass return self.get_rendered(url, render_js=False) # -- sitemap ---------------------------------------------------------- def _recent_fiches(self) -> dict[str, str]: """URL de fiche → lastmod, pour les fiches modifiées récemment.""" idx = self._fetch_html(SITEMAP_INDEX) shards = [u for u, _ in _LOC_RE.findall(idx) if "/evenements_" in u] cutoff = (date.today() - timedelta(days=LASTMOD_WINDOW_DAYS)).isoformat() fiches: dict[str, str] = {} for shard in shards[-SHARDS_TO_READ:]: xml = self._fetch_html(shard) for block in _URL_BLOCK_RE.findall(xml): m = _LOC_RE.search(block) if not m: continue url, lastmod = m.group(1), (m.group(2) or "")[:10] if "/billets-spectacle/" in url and lastmod >= cutoff: fiches[url] = lastmod return fiches # -- fiche ------------------------------------------------------------ def _parse_fiche(self, url: str, html: str) -> list[dict]: h1 = _H1_RE.search(html) title = clean_text(h1.group(1)) if h1 else "" if not title: return [] image = (_OG_RE["image"].search(html) or [None, ""])[1] # og:description : déjà téléchargée (crédits Scrapfly) — Phase 2, on # l'affecte enfin à l'événement (elle était compilée mais jetée)… # SAUF quand c'est le gabarit marketing du site (« trouvez des billets # à tarif réduit… »), constaté sur fiche réelle : on ne publie pas de # texte boilerplate comme description d'événement. Le backfill se fait # au fil des re-scrapes lastmod (aucun crédit Scrapfly ajouté). description = clean_text((_OG_RE["description"].search(html) or [None, ""])[1]) if "trouvez des billets" in description.lower(): description = "" cats = [clean_text(c) for c in _CAT_RE.findall(html)][:2] price_m = _PRICE_RE.search(html) price = float(price_m.group(1).replace(",", ".")) if price_m else None slug = url.rstrip("/").rsplit("/", 1)[-1] rows = [] seen_days: dict[str, int] = {} for date_txt, venue, city in _PLAY_RE.findall(html)[:10]: day = parse_date_fr(date_txt) if not day: continue hhmm = parse_time(date_txt) # « … - 20h00 » de la fiche # séances multiples le même jour : la 1ʳᵉ garde l'id historique # (zéro churn en base), les suivantes sont suffixées par l'heure # — sinon elles s'écrasaient mutuellement à l'ingestion. n = seen_days.get(day, 0) seen_days[day] = n + 1 ext = (f"{slug}-{day}" if n == 0 else f"{slug}-{day}-{(hhmm or f'x{n}').replace(':', '')}") rows.append({ "external_id": ext, "start_time": hhmm, "url": url, "title": title, "image": image, "description": description, "raw_categories": cats, "venue": clean_text(venue), "city": clean_text(city or ""), "start_date": day, "end_date": day, "price_min": price, "price_label": f"{price:.2f} $ (prix régulier atuvu.ca)" if price else "", }) return rows # -- pipeline --------------------------------------------------------- def fetch(self) -> list[Event]: cache = self.load_cache() fiches = self._recent_fiches() # retirer du cache les fiches disparues du sitemap récent cache = {u: c for u, c in cache.items() if u in fiches} now = time.time() today = date.today().isoformat() to_fetch = [u for u, mod in fiches.items() if cache.get(u, {}).get("lastmod") != mod] def _stale_rank(u: str) -> int | None: """0 = rows figés par un parseur < v2 (heure/prix manquants) ; 1 = fiche à venir non re-visitée depuis REFRESH_AFTER (les tarifs apparaissent souvent après la mise en vente, sans bump de lastmod — constaté 2026-08-25) ; None = rien à faire.""" entry = cache.get(u) or {} if entry.get("v", 1) < PARSE_V: return 0 if (now - entry.get("fetched_at", 0) > REFRESH_AFTER and any((r.get("start_date") or "") >= today for r in entry.get("rows") or [])): return 1 return None backfill = sorted( (u for u in fiches if u not in set(to_fetch) and _stale_rank(u) is not None), key=lambda u: (_stale_rank(u), cache.get(u, {}).get("fetched_at", 0))) to_fetch = (to_fetch + backfill)[:ATUVU_MAX_FETCH] for url in to_fetch: try: html = self._fetch_html(url) cache[url] = {"lastmod": fiches[url], "fetched_at": time.time(), "v": PARSE_V, "rows": self._parse_fiche(url, html)} except Exception as exc: # une fiche cassée ne bloque pas la source print(f"[sorti-ka] atuvu : fiche ignorée {url} : {exc}") self.save_cache(cache) events: list[Event] = [] for entry in cache.values(): for row in entry.get("rows", []): events.append(Event( source=self.source_id, external_id=row["external_id"], url=row["url"], title=row["title"], description=row.get("description", ""), raw_categories=row.get("raw_categories") or [], venue=row.get("venue", ""), city=row.get("city", ""), start_date=row.get("start_date"), start_time=row.get("start_time"), end_date=row.get("end_date"), price_min=row.get("price_min"), price_label=row.get("price_label", ""), is_free=False if row.get("price_min") else None, image=row.get("image", ""), )) return events