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:
@@ -146,6 +146,65 @@ class HelloFreshClient:
|
||||
"customer_id": str(cust.get("id", "")),
|
||||
"sku": str(prod.get("sku", ""))}
|
||||
|
||||
def account_info(self) -> dict[str, Any]:
|
||||
"""Résumé lisible du compte : client, abonnement, adresse, prochaine livraison."""
|
||||
ep = self._ep.get("subscriptions")
|
||||
if not ep:
|
||||
raise EndpointsNotConfigured("Endpoint 'subscriptions' manquant.")
|
||||
data = self._request("GET", ep, params={"country": self._country}).json()
|
||||
items = data.get("items", []) if isinstance(data, dict) else (data or [])
|
||||
if not items:
|
||||
raise RuntimeError("Aucun abonnement actif sur ce compte.")
|
||||
sub = items[0]
|
||||
cust = sub.get("customer") or {}
|
||||
ptype = sub.get("productType") or {}
|
||||
prod = sub.get("product") or {}
|
||||
ship = sub.get("shippingAddress") or {}
|
||||
|
||||
def _addr(a: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": " ".join(p for p in (a.get("firstName"), a.get("lastName")) if p),
|
||||
"address": a.get("address1"),
|
||||
"postcode": a.get("postcode"),
|
||||
"city": a.get("city"),
|
||||
"country": (a.get("country") or {}).get("iso2Code"),
|
||||
"phone": a.get("phone"),
|
||||
}
|
||||
|
||||
unit_price = prod.get("unitPrice")
|
||||
return {
|
||||
"customer": {
|
||||
"id": cust.get("id"),
|
||||
"email": cust.get("email"),
|
||||
"first_name": cust.get("firstName"),
|
||||
"last_name": cust.get("lastName"),
|
||||
"locale": cust.get("locale"),
|
||||
"loyalty_points": (cust.get("loyalty") or {}).get("value"),
|
||||
},
|
||||
"subscription": {
|
||||
"id": sub.get("id"),
|
||||
"active": sub.get("isActive"),
|
||||
"paused_at": sub.get("pausedAt"),
|
||||
"canceled_at": sub.get("canceledAt"),
|
||||
"blocked": sub.get("isBlocked"),
|
||||
"sku": prod.get("sku") or ptype.get("handle"),
|
||||
"product_name": ptype.get("productName"),
|
||||
"meals": (ptype.get("specs") or {}).get("meals"),
|
||||
"people": (ptype.get("specs") or {}).get("size"),
|
||||
"box_price_eur": unit_price / 100 if isinstance(unit_price, int) else None,
|
||||
"preset": sub.get("preset"),
|
||||
"delivery_weekday": sub.get("deliveryWeekday"),
|
||||
"delivery_interval": sub.get("deliveryInterval"),
|
||||
"payment_method": sub.get("paymentMethod"),
|
||||
},
|
||||
"shipping_address": _addr(ship),
|
||||
"next_delivery": {
|
||||
"week": sub.get("nextDeliveryWeek"),
|
||||
"date": sub.get("nextDelivery"),
|
||||
"cutoff": sub.get("nextCutoffDate"),
|
||||
},
|
||||
}
|
||||
|
||||
def _menu_courses(self, week: str) -> list[dict]:
|
||||
"""Courses bruts du menu (chacun : index + recipe). Base de l'écriture."""
|
||||
params = {
|
||||
|
||||
@@ -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/.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user