SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
9.9 KB · 201 lines python
Raw Blame History
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