11 KiB
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.
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).
{
"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
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.
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
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) :
<input id="username" name="username" type="text">
<input id="password" name="password" type="password">
<input id="kc-login" name="login" type="submit">
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.
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 :
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
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
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 : utiliserpage.wait_for_timeout(ms)(Playwright) ouwait_for_load_statepour laisser le navigateur traiter ses événements. headless=Trueconvient pour la prod ; pour debug, passerheadless=Falsepermet 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 — implémentation d'origine (sync des absences)
- scripts/push_notices.py — réutilise
_ensure_logged_invia import - scripts/push_to_escada.py — idem