# Escadaweb — Login + 2FA + Forçage langue (Playwright) Documentation des briques réutilisables pour automatiser une session Escadaweb (`escadaweb.vs.ch`) : login Keycloak, code TOTP, forçage du français côté UI. Issu de [scripts/sync_esacada.py](scripts/sync_esacada.py). ## Pré-requis ``` pip install playwright pyotp playwright install chromium ``` ## Configuration (`data/settings.json`) Les identifiants et secrets ne sont pas hardcodés — ils viennent du fichier `data/settings.json` (édité depuis `/params` dans l'app). ```json { "escada_username": "prenom.nom@vs.ch", "escada_password": "•••", "totp_secret": "BASE32SECRET" } ``` Sans `escada_username` / `escada_password` → le script affiche la fenêtre Chromium et attend que tu te connectes à la main (utile pour le 1er run, où le profil persistant Chromium se construit). Sans `totp_secret` → le code TOTP doit être saisi à la main. Pour le récupérer la 1ère fois, scanne le QR proposé par Escadaweb avec [Aegis/Authy/etc.] **ET** copie le secret BASE32 sous-jacent (souvent caché derrière un lien "Saisie manuelle"). ## URLs Escadaweb ```python BASE_URL = "https://escadaweb.vs.ch" LEHRPERSONEN_URL = f"{BASE_URL}/Lehrpersonen" CLASSES_URL = f"{BASE_URL}/Lehrpersonen/ViewKlassen.aspx" EINSTELLUNGEN_URL = f"{BASE_URL}/Lehrpersonen/Dialogs/DlgEinstellungen.aspx" ``` Le subpath `/Lehrpersonen/` est **obligatoire** — sans lui le SSO ne sert pas la bonne app et le login échoue silencieusement. ## Profil Chromium persistant Indispensable : permet de garder le cookie de session Keycloak entre les runs, sinon tu refais 2FA à chaque fois. ```python from pathlib import Path from playwright.sync_api import sync_playwright PROFILE_DIR = Path("data/browser_profile") # adapter au projet def _launch_context(): PROFILE_DIR.mkdir(parents=True, exist_ok=True) pw = sync_playwright().start() ctx = pw.chromium.launch_persistent_context( str(PROFILE_DIR), headless=True, # False pour debug visuel args=["--start-maximized", "--disable-popup-blocking"], accept_downloads=True, ) page = ctx.pages[0] if ctx.pages else ctx.new_page() return pw, ctx, page ``` Note : `launch_persistent_context` ouvre toujours un seul context ; on récupère le premier page existant pour éviter d'avoir un onglet vide en plus du nôtre. ## Code à coller ### Helpers settings ```python import json from pathlib import Path _ROOT = Path(__file__).resolve().parent.parent # adapter au projet def _load_settings() -> dict: p = _ROOT / "data" / "settings.json" if not p.exists(): return {} try: return json.loads(p.read_text(encoding="utf-8")) except Exception: return {} def _load_totp_secret() -> str | None: return _load_settings().get("totp_secret") or None def _load_escada_creds() -> tuple[str, str]: s = _load_settings() return s.get("escada_username", ""), s.get("escada_password", "") ``` ### 1) Remplir le formulaire Keycloak Sélecteurs exacts du formulaire SSO (edusso.apps.vs.ch) : ```html ``` ```python from playwright.sync_api import Page def _try_fill_login(page: Page) -> bool: username, password = _load_escada_creds() if not username or not password: return False try: page.wait_for_selector("input#username", state="visible", timeout=5_000) page.wait_for_selector("input#password", state="visible", timeout=2_000) page.locator("input#username").fill(username) page.locator("input#password").fill(password) try: page.locator("input#kc-login").click(timeout=2_000) except Exception: page.locator("input#password").press("Enter") return True except Exception: return False ``` ### 2) Remplir le code TOTP Le champ OTP de Keycloak est rendu **caché** par CSS avant la vérification — un `page.fill()` Playwright standard échoue ("locator not visible"). On contourne via `page.evaluate()` (JS pur) qui injecte la valeur et déclenche les events. ```python import pyotp def _try_fill_totp(page: Page, secret: str) -> bool: try: code = pyotp.TOTP(secret).now() result = page.evaluate("""(code) => { const inp = document.querySelector('#otp') || document.querySelector('[name="otp"]') || document.querySelector('[autocomplete="one-time-code"]') || document.querySelector('input[type="text"]:not([type="hidden"])'); if (!inp) return 'not_found'; inp.value = code; inp.dispatchEvent(new Event('input', {bubbles: true})); inp.dispatchEvent(new Event('change', {bubbles: true})); return 'filled'; }""", code) if result != "filled": return False submitted = page.evaluate("""() => { const btn = document.querySelector('input[type="submit"]') || document.querySelector('button[type="submit"]'); if (btn) { btn.click(); return 'clicked'; } const form = document.querySelector('form'); if (form) { form.submit(); return 'submitted'; } return 'no_submit'; }""") return submitted in ("clicked", "submitted") except Exception: return False ``` ### 3) Forcer le français côté UI Tout le parsing du HTML (libellés boutons, en-têtes de tableau, etc.) suppose le français. Si l'utilisateur a la session en allemand, on bascule via `DlgEinstellungen` : ```python import sys _lang_ok = False # mémo process : évite de re-naviguer à chaque appel def _ensure_french_language(page: Page) -> None: global _lang_ok if _lang_ok: return page.goto(EINSTELLUNGEN_URL, wait_until="domcontentloaded", timeout=15_000) try: page.wait_for_load_state("networkidle", timeout=8_000) except Exception: pass inp_loc = page.locator("#ContentPlaceHolderSite_DropDownList_sprache_I") try: inp_loc.wait_for(state="visible", timeout=8_000) except Exception: print("ERR [LANG] Dropdown langue introuvable — arrêt.") sys.exit(1) cur_val = inp_loc.input_value() if cur_val != "français": page.evaluate("""() => { const inp = document.querySelector('#ContentPlaceHolderSite_DropDownList_sprache_I'); if (inp) { inp.value = 'français'; ASPx.ETextChanged('ContentPlaceHolderSite_DropDownList_sprache'); } }""") page.locator("span.dx-vam:has-text('Speichern')").first.click() try: page.wait_for_load_state("networkidle", timeout=10_000) except Exception: pass # Attendre que le grid soit prêt avant de rendre la main try: page.wait_for_selector( "a[href*='ViewAbsenzenErweitert']", state="attached", timeout=30_000 ) except Exception: pass else: page.goto(CLASSES_URL, wait_until="domcontentloaded", timeout=15_000) _lang_ok = True ``` ⚠️ Le bouton "Enregistrer" est resté libellé `Speichern` (allemand) même après changement de langue côté UI — bug d'Escadaweb. Le sélecteur ci-dessus matche ce label tel quel. ### 4) Orchestrateur ```python import time from playwright.sync_api import TimeoutError as PWTimeout, Error as PWError def _ensure_logged_in(page: Page) -> None: """Si on est déjà sur ViewKlassen, juste vérifier la langue. Sinon, boucle pendant 5 min : remplit login + TOTP automatiquement (si secrets configurés), ou laisse l'utilisateur taper en manuel.""" if "ViewKlassen" in page.url: _ensure_french_language(page) return _totp_secret = _load_totp_secret() _username, _password = _load_escada_creds() cur = page.url.lower() if "login" not in cur and "logon" not in cur and "viewklassen" not in cur: page.goto(LEHRPERSONEN_URL) deadline = time.time() + 300 # 5 min _last_login = 0.0 _last_totp = 0.0 while time.time() < deadline: try: if "ViewKlassen" in page.url: _ensure_french_language(page) return if _username and _password and (time.time() - _last_login) > 5: if _try_fill_login(page): _last_login = time.time() try: page.wait_for_load_state("networkidle", timeout=8_000) except (PWTimeout, PWError): pass if _totp_secret and (time.time() - _last_totp) > 5: if _try_fill_totp(page, _totp_secret): _last_totp = time.time() try: page.wait_for_url("**ViewKlassen**", timeout=10_000) return except (PWTimeout, PWError): pass page.wait_for_timeout(800) except PWError: if "ViewKlassen" in page.url: _ensure_french_language(page) return print("ERR Délai de connexion dépassé (5 min).") sys.exit(1) ``` ## Exemple minimal ```python pw, ctx, page = _launch_context() try: page.goto(CLASSES_URL) _ensure_logged_in(page) # → à ce point : session active, langue = français, on est sur # /Lehrpersonen/ViewKlassen.aspx. Le reste du script peut tourner. print("Connecté :", page.url) finally: ctx.close() pw.stop() ``` ## Pièges et points d'attention - **Toujours `page.goto(CLASSES_URL)` avant `_ensure_logged_in()`**. Si on attaque une autre page (par ex. `ViewLernende`) sans session, on rebondit sur une URL Keycloak où les sélecteurs de login ne sont pas les mêmes, et `_try_fill_login` échoue. - **Throttling** : on attend 5 s entre deux tentatives de fill login / TOTP pour ne pas spammer le serveur ni déclencher un anti-bot. - **Pas de `time.sleep()` long** : utiliser `page.wait_for_timeout(ms)` (Playwright) ou `wait_for_load_state` pour laisser le navigateur traiter ses événements. - **`headless=True`** convient pour la prod ; pour debug, passer `headless=False` permet de voir ce qui se passe. - **Le profil Chromium persistant** garde la session vivante des jours (cookie SSO long terme). Si tu supprimes `data/browser_profile/`, tu repars de zéro et le 2FA sera redemandé. - **Multi-instance** : un profil persistant Chromium ne peut être ouvert que par **un seul process à la fois**. Si tu paralléllises, copier le profil ou utiliser `launch()` + cookies exportés. ## Fichiers de référence - [scripts/sync_esacada.py](scripts/sync_esacada.py) — implémentation d'origine (sync des absences) - [scripts/push_notices.py](scripts/push_notices.py) — réutilise `_ensure_logged_in` via import - [scripts/push_to_escada.py](scripts/push_to_escada.py) — idem