Files
AntiCoco/hellofresh/auth.py
jerem ef6bf9813a API HelloFresh réelle câblée + filtrage coco validé en local
- Endpoints découverts: menu (menus-service) + détails batch (recipes/recipes)
- get_menu en 2 temps: menu (ids) -> batch détails (ingrédients/allergènes)
- Fix faux positifs: exclusion sur ingrédients/allergènes/nom, plus sur les tags
  (HelloFresh pose un tag interne 'coconut' sur ~la moitié des recettes)
- Token mis en cache (pas de navigateur si frais)
- endpoints.json versionné (sans secret), semaine optionnelle (défaut = courante)
- Testé: 4 recettes coco/85 détectées, shortlist classée, tous les outils MCP OK
- set_selection (écriture) reste à découvrir sur un compte avec box active
2026-06-15 22:28:40 +02:00

213 lines
7.8 KiB
Python

"""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)}