"""Authentification HelloFresh via Playwright + capture du bearer token. Stratégie (cf. plan) : - **Login local d'abord** : sur le Mac, fenêtre visible (`headless=False`) → l'utilisateur se connecte (captcha/2FA gérés à la main). La session est persistée dans `.session/profile`. - **Homelab** : `headless=True`, réutilise la session synchronisée. `HF_EMAIL`/`HF_PASSWORD` servent uniquement de fallback de re-login auto si la session a expiré. - **Token** : on intercepte l'en-tête `Authorization: Bearer …` envoyé aux hôtes gateway et on le met en cache dans `.session/token.json`. `api.py` le réutilise pour les appels httpx. Pattern persistant repris d'`Automood/scraper.py` (`launch_persistent_context`). """ from __future__ import annotations import json import os import time from pathlib import Path from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parent.parent SESSION_DIR = ROOT / ".session" PROFILE_DIR = SESSION_DIR / "profile" TOKEN_CACHE = SESSION_DIR / "token.json" BASE_URL = "https://www.hellofresh.fr" # Page qui déclenche des appels gateway authentifiés (menu de la semaine). MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu" ACCOUNT_PAGE = f"{BASE_URL}/my-account" ATTENTE_LOGIN_S = 180 # temps laissé pour un login manuel (captcha / 2FA) TOKEN_TTL_S = 30 * 60 # on rafraîchit le token au-delà de 30 min par prudence def _headless() -> bool: return os.environ.get("ANTICOCO_HEADLESS", "1") not in ("0", "false", "False", "") def _is_gateway_request(url: str) -> bool: return ("/gw/" in url or url.startswith("https://gw.")) and "hellofresh" in url def _is_logged_in(page) -> bool: """Pas connecté = un champ mot de passe est visible (page de login). Détection volontairement indépendante de la locale (sélecteur CSS, pas de texte). """ try: return page.locator('input[type="password"]').count() == 0 except Exception: return False def _auto_login(page) -> bool: """Tente un login automatique avec HF_EMAIL/HF_PASSWORD. Best-effort. Les sélecteurs exacts du formulaire HelloFresh sont à confirmer ; on cible les champs standards. En cas d'échec (captcha, sélecteurs changés), renvoie False et on retombe sur le login manuel. """ email = os.environ.get("HF_EMAIL") password = os.environ.get("HF_PASSWORD") if not email or not password: return False try: page.fill('input[type="email"], input[name="email"], input#email', email, timeout=8000) page.fill('input[type="password"], input[name="password"]', password, timeout=8000) page.click('button[type="submit"], button[data-test-id="login-submit"]', timeout=8000) page.wait_for_timeout(4000) return _is_logged_in(page) except Exception: return False def _open_context(pw): return pw.chromium.launch_persistent_context( user_data_dir=str(PROFILE_DIR), headless=_headless(), locale="fr-FR", viewport={"width": 1280, "height": 900}, ) def ensure_logged_in() -> bool: """Garantit une session connectée dans le profil persistant. - Si déjà connecté : retourne True immédiatement. - Sinon, tente l'auto-login (env) ; à défaut attend un login manuel (fenêtre visible). Retourne True si la session est établie. """ SESSION_DIR.mkdir(parents=True, exist_ok=True) with sync_playwright() as pw: ctx = _open_context(pw) try: page = ctx.pages[0] if ctx.pages else ctx.new_page() page.goto(ACCOUNT_PAGE, wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(2000) if _is_logged_in(page): return True # Fallback 1 : auto-login si identifiants fournis. if _auto_login(page): page.wait_for_timeout(2000) return True # Fallback 2 : login manuel (uniquement utile en fenêtre visible). if _headless(): raise RuntimeError( "Session HelloFresh expirée et auto-login impossible en headless. " "Refaire le login en local (ANTICOCO_HEADLESS=0) puis re-sync .session/." ) debut = time.time() while time.time() - debut < ATTENTE_LOGIN_S: if _is_logged_in(page): page.wait_for_timeout(2000) return True page.wait_for_timeout(2000) return False finally: ctx.close() def capture_token(force: bool = False) -> dict: """Capture (ou relit depuis le cache) le bearer token et les hôtes gateway observés. Retourne {"token": str, "gateways": [str], "captured_at": float}. """ cached = _read_token_cache() if cached and not force and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S: return cached SESSION_DIR.mkdir(parents=True, exist_ok=True) observed = {"token": None, "gateways": set()} with sync_playwright() as pw: ctx = _open_context(pw) try: page = ctx.pages[0] if ctx.pages else ctx.new_page() def on_request(req): try: if not _is_gateway_request(req.url): return base = req.url.split("/gw/")[0] + "/gw" if "/gw/" in req.url else req.url observed["gateways"].add(base) auth = req.headers.get("authorization") or req.headers.get("Authorization") if auth and auth.lower().startswith("bearer "): observed["token"] = auth.split(" ", 1)[1].strip() except Exception: pass page.on("request", on_request) page.goto(MENU_PAGE, wait_until="networkidle", timeout=45000) page.wait_for_timeout(3000) if not _is_logged_in(page): raise RuntimeError( "Non connecté lors de la capture du token. Lancer ensure_logged_in() d'abord." ) if not observed["token"]: raise RuntimeError( "Aucun bearer token capturé. Vérifier MENU_PAGE / le pattern gateway, " "ou rejouer tools/discover_api.py." ) result = { "token": observed["token"], "gateways": sorted(observed["gateways"]), "captured_at": time.time(), } TOKEN_CACHE.write_text(json.dumps(result, indent=2), encoding="utf-8") return result finally: ctx.close() def _read_token_cache() -> dict | None: if TOKEN_CACHE.exists(): try: return json.loads(TOKEN_CACHE.read_text(encoding="utf-8")) except Exception: return None return None def get_token(force: bool = False) -> str: """Renvoie un bearer token valide. Si un token en cache est encore frais, on l'utilise sans ouvrir de navigateur. Sinon on s'assure d'être connecté puis on en capture un neuf. """ if not force: cached = _read_token_cache() if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S: return cached["token"] ensure_logged_in() return capture_token(force=force)["token"] def auth_status() -> dict: """État de connexion sans ouvrir de fenêtre si un token en cache est encore frais.""" cached = _read_token_cache() if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S: age = int(time.time() - cached["captured_at"]) return {"logged_in": True, "source": "cache", "token_age_s": age, "gateways": cached.get("gateways", [])} try: ok = ensure_logged_in() return {"logged_in": bool(ok), "source": "browser"} except Exception as e: return {"logged_in": False, "error": str(e)}