# -----------------------------------------------------------------------------
# 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