# ============================================================================== # Author: Simon-Pierre Boucher # File: src/main.py # Desc: Acteur Apify ka-tuxedo — billetteries Tuxedo (*.tuxedobillet.com). # Les tenants sont des SPA Angular sur Firebase (RTDB verrouillée, # 401 même avec jeton anonyme — vérifié 2026-08-21) : le rendu # navigateur est la seule voie. Playwright (Chromium) + proxy Apify # (résidentiel CA par défaut) rend chaque tenant et extrait les tuiles # .tuxCmsEdit__webShowTile : titre, sous-titre, date, image, lien # /main/. Un item de dataset par spectacle. # ============================================================================== from __future__ import annotations import asyncio from urllib.parse import urlparse from apify import Actor from playwright.async_api import async_playwright CONCURRENCY = 4 # pages simultanées (29 tenants ≈ 6 vagues) def _pw_proxy(url: str | None) -> dict | None: """URL de proxy Apify → dict Playwright (identifiants SÉPARÉS du server — Playwright ignore les credentials inline, cause de timeouts silencieux).""" if not url: return None p = urlparse(url) proxy = {"server": f"{p.scheme}://{p.hostname}:{p.port}"} if p.username: proxy["username"] = p.username proxy["password"] = p.password or "" return proxy TILE = ".tuxCmsEdit__webShowTile" EXTRACT_JS = """ () => Array.from(document.querySelectorAll('.tuxCmsEdit__webShowTile')).map(t => { const q = (sel) => { const e = t.querySelector(sel); return e ? e.textContent.trim() : ''; }; const a = t.querySelector('a[href^="/main/"]'); const img = t.querySelector('img.tuxCmsEdit__webShowTile--showImage'); return { slug: a ? a.getAttribute('href').replace('/main/', '') : '', title: q('.tuxCmsEdit__webShowTile--showTitle'), subtitle: q('.tuxCmsEdit__webShowTile--showSubtitle'), date_raw: q('.tuxCmsEdit__webShowTile--showDate'), image: img ? img.getAttribute('src') : '', }; }) """ async def main() -> None: async with Actor: inp = await Actor.get_input() or {} tenants = [t.strip().lower() for t in inp.get("tenants", []) if t.strip()] wait_ms = int(inp.get("renderWaitMs", 6000)) proxy_cfg = await Actor.create_proxy_configuration( actor_proxy_input=inp.get("proxyConfiguration")) proxy_url = await proxy_cfg.new_url() if proxy_cfg else None async with async_playwright() as pw: browser = await pw.chromium.launch( headless=True, proxy=_pw_proxy(proxy_url)) context = await browser.new_context( locale="fr-CA", viewport={"width": 1366, "height": 900}) sem = asyncio.Semaphore(CONCURRENCY) async def scrape(tenant: str) -> None: url = f"https://{tenant}.tuxedobillet.com/" async with sem: page = await context.new_page() try: await page.goto(url, wait_until="domcontentloaded", timeout=45_000) try: await page.wait_for_selector(TILE, timeout=25_000) except Exception: Actor.log.warning(f"{tenant}: aucune tuile rendue") await page.wait_for_timeout(wait_ms) # défilement : certaines listes chargent paresseusement for _ in range(6): await page.mouse.wheel(0, 2400) await page.wait_for_timeout(300) shows = await page.evaluate(EXTRACT_JS) seen: set[str] = set() for s in shows: if (not s["title"] or not s["slug"] or s["slug"] in seen): continue seen.add(s["slug"]) await Actor.push_data({ "tenant": tenant, "url": (f"https://{tenant}.tuxedobillet.com" f"/main/{s['slug']}"), **s, }) Actor.log.info(f"{tenant}: {len(seen)} spectacle(s)") except Exception as exc: Actor.log.exception(f"{tenant}: échec ({exc})") finally: await page.close() await asyncio.gather(*(scrape(t) for t in tenants)) await browser.close()