Outils MCP async (fix Playwright/asyncio) + hf_account_info + auth vérifiée

- server.py : outils passés en async + déport thread (anyio.to_thread.run_sync).
  Le SDK mcp 1.27.2 appelle les outils sync directement dans la boucle asyncio,
  ce qui cassait l'API sync de Playwright. Transport configurable via
  ANTICOCO_TRANSPORT (défaut streamable-http, stdio pour Claude Code local).
- api.py : nouvelle méthode account_info() (client, abonnement, adresse,
  prochaine livraison) + outil MCP hf_account_info (lecture seule).
- auth.py : auth_status() valide désormais le token par un vrai appel API
  (200 vs 401) au lieu de supposer "token présent = connecté", et n'ouvre plus
  de navigateur. _is_logged_in() utilise un signal positif (cookie apiV2Auth
  non expiré) au lieu de l'absence de champ mot de passe. Supprime les faux
  positifs "connecté" sur session morte (important pour le homelab/Hermes).
This commit is contained in:
jerem
2026-06-18 11:31:56 +02:00
parent 5d3899fdfb
commit e37a27cc1a
3 changed files with 206 additions and 75 deletions

View File

@@ -20,6 +20,7 @@ import time
import urllib.parse
from pathlib import Path
import httpx
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parent.parent
@@ -32,6 +33,9 @@ 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"
# Endpoint gateway authentifié, léger, utilisé pour VÉRIFIER qu'un token est réellement
# accepté (200) ou non (401). Vérité de terrain, contre les faux positifs de détection UI.
VALIDATE_URL = f"{BASE_URL}/gw/api/customers/me/subscriptions"
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
@@ -56,12 +60,20 @@ def _is_gateway_request(url: str) -> bool:
def _is_logged_in(page) -> bool:
"""Pas connecté = un champ mot de passe est visible (page de login).
"""Connecté = cookie d'auth `apiV2Auth` présent avec un access_token non expiré.
Détection volontairement indépendante de la locale (sélecteur CSS, pas de texte).
Signal POSITIF (cookie réel) plutôt que l'absence d'un champ mot de passe : cette
dernière produisait des faux positifs en headless (page de login non rendue/redirigée,
bannière cookies) → la session paraissait valide alors qu'elle ne l'était pas.
"""
try:
return page.locator('input[type="password"]').count() == 0
for c in page.context.cookies():
if c.get("name") == "apiV2Auth":
data = json.loads(urllib.parse.unquote(c.get("value", "")))
tok = data.get("access_token")
if tok and _jwt_exp(tok) > time.time() + 60:
return True
return False
except Exception:
return False
@@ -284,16 +296,43 @@ def get_token(force: bool = False) -> str:
return capture_token(force=force)["token"]
def _token_works(token: str) -> bool:
"""Vrai si le token est réellement accepté par l'API gateway (appel léger, 200 vs 401).
Vérité de terrain : c'est ce qui empêche de déclarer « connecté » une session morte.
En cas d'erreur réseau (résultat indéterminable), renvoie False par prudence.
"""
try:
resp = httpx.get(
VALIDATE_URL,
params={"country": "FR"},
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=15.0,
)
except Exception:
return False
return resp.status_code == 200
def auth_status() -> dict:
"""État de connexion sans ouvrir de fenêtre si un token en cache est encore frais."""
"""État de connexion VÉRIFIÉ par un vrai appel API — pas de faux positif.
N'ouvre jamais de navigateur : on prend le meilleur token disponible (cache frais,
puis cookie storage_state) et on le valide contre l'API. Si aucun token n'est
exploitable ou s'il est rejeté, renvoie logged_in=False avec une consigne de re-login.
"""
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", [])}
if token_from_storage_state():
token = cached.get("token")
if token and _token_works(token):
age = int(time.time() - cached["captured_at"])
return {"logged_in": True, "source": "cache", "token_age_s": age,
"gateways": cached.get("gateways", [])}
tok = token_from_storage_state()
if tok and _token_works(tok):
return {"logged_in": True, "source": "storage_state"}
try:
ok = ensure_logged_in()
return {"logged_in": bool(ok), "source": "browser"}
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {
"logged_in": False,
"error": "Session HelloFresh absente ou expirée (aucun token valide accepté par l'API). "
"Refaire le login en local (ANTICOCO_HEADLESS=0) puis re-sync .session/.",
}