diff --git a/hellofresh/api.py b/hellofresh/api.py index 3688c29..2b1a6c4 100644 --- a/hellofresh/api.py +++ b/hellofresh/api.py @@ -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 = { diff --git a/hellofresh/auth.py b/hellofresh/auth.py index 92740aa..e202f8c 100644 --- a/hellofresh/auth.py +++ b/hellofresh/auth.py @@ -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/.", + } diff --git a/server.py b/server.py index 2f3ec31..86cc401 100644 --- a/server.py +++ b/server.py @@ -4,14 +4,17 @@ Client visé : Hermes (Nous Research), sur le même homelab. Transport streamabl sur 127.0.0.1:$ANTICOCO_PORT/mcp. Si Hermes n'accepte que le stdio, changer le `transport=` de `mcp.run()` ci-dessous (le reste du code est identique). -Les outils sont synchrones : FastMCP les exécute dans un thread worker, ce qui permet -d'utiliser l'API *synchrone* de Playwright (auth.py) sans conflit de boucle asyncio. +Le SDK MCP (>=1.x) appelle les outils sync DIRECTEMENT dans la boucle asyncio, ce qui +casse l'API *synchrone* de Playwright (auth.py). On déclare donc les outils en `async` +et on déporte leur corps bloquant dans un thread worker via `anyio.to_thread.run_sync`, +où aucune boucle asyncio ne tourne. Valable pour stdio comme pour streamable-http. """ from __future__ import annotations import os +import anyio from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP @@ -24,115 +27,145 @@ mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT) @mcp.tool() -def hf_auth_status() -> dict: +async def hf_auth_status() -> dict: """État de la connexion HelloFresh (utilise le token en cache s'il est frais).""" - return auth.auth_status() + return await anyio.to_thread.run_sync(auth.auth_status) @mcp.tool() -def hf_login() -> dict: +async def hf_login() -> dict: """S'assure d'être connecté et capture un bearer token frais. En headless (homelab), échoue si la session a expiré → refaire le login local. """ - ok = auth.ensure_logged_in() - if not ok: - return {"logged_in": False, "error": "login non établi (timeout ou session expirée)"} - info = auth.capture_token(force=True) - return {"logged_in": True, "gateways": info.get("gateways", [])} + def _impl() -> dict: + ok = auth.ensure_logged_in() + if not ok: + return {"logged_in": False, "error": "login non établi (timeout ou session expirée)"} + info = auth.capture_token(force=True) + return {"logged_in": True, "gateways": info.get("gateways", [])} + + return await anyio.to_thread.run_sync(_impl) @mcp.tool() -def hf_list_weeks() -> list[dict]: +async def hf_list_weeks() -> list[dict]: """Liste les semaines de l'abonnement encore modifiables (handle + dates).""" - with api.HelloFreshClient() as client: - weeks = client.get_editable_weeks() - return [{"week": d.week, "delivery_date": d.delivery_date, "cutoff_date": d.cutoff_date, - "status": d.status, "editable": d.editable} for d in weeks] + def _impl() -> list[dict]: + with api.HelloFreshClient() as client: + weeks = client.get_editable_weeks() + return [{"week": d.week, "delivery_date": d.delivery_date, "cutoff_date": d.cutoff_date, + "status": d.status, "editable": d.editable} for d in weeks] + + return await anyio.to_thread.run_sync(_impl) @mcp.tool() -def hf_get_menu(week: str = "") -> dict: +async def hf_account_info() -> dict: + """Infos du compte HelloFresh : client, abonnement, adresse, prochaine livraison. + + Lecture seule. Aucune donnée de paiement sensible n'est renvoyée (méthode seulement). + """ + def _impl() -> dict: + with api.HelloFreshClient() as client: + return client.account_info() + + return await anyio.to_thread.run_sync(_impl) + + +@mcp.tool() +async def hf_get_menu(week: str = "") -> dict: """Toutes les recettes proposées pour une semaine, chacune annotée. `week` vide = semaine courante (format 'YYYY-Www'). Chaque recette porte `contains_excluded` (true si ingrédient banni, coco en tête) et `matched_excludes`. """ - w = week or api.current_week() - with api.HelloFreshClient() as client: - recipes = client.get_menu(w) - hf_filter.annotate(recipes) - return { - "week": w, - "count": len(recipes), - "recipes": [r.summary() for r in recipes], - } + def _impl() -> dict: + w = week or api.current_week() + with api.HelloFreshClient() as client: + recipes = client.get_menu(w) + hf_filter.annotate(recipes) + return { + "week": w, + "count": len(recipes), + "recipes": [r.summary() for r in recipes], + } + + return await anyio.to_thread.run_sync(_impl) @mcp.tool() -def hf_propose(week: str = "", count: int = 0) -> dict: +async def hf_propose(week: str = "", count: int = 0) -> dict: """Shortlist de recettes SANS ingrédient exclu, classée par préférences. `week` vide = semaine courante. `count=0` renvoie toutes les recettes sûres. Étape « je propose » : rien n'est écrit ici — utiliser hf_confirm_selection() ensuite. """ - w = week or api.current_week() - with api.HelloFreshClient() as client: - recipes = client.get_menu(w) - safe = hf_filter.propose(recipes, count=count or None) - excluded = [r.summary() for r in recipes if r.contains_excluded] - return { - "week": w, - "proposed": [r.summary() for r in safe], - "excluded_for_coco_etc": excluded, - "note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).", - } + def _impl() -> dict: + w = week or api.current_week() + with api.HelloFreshClient() as client: + recipes = client.get_menu(w) + safe = hf_filter.propose(recipes, count=count or None) + excluded = [r.summary() for r in recipes if r.contains_excluded] + return { + "week": w, + "proposed": [r.summary() for r in safe], + "excluded_for_coco_etc": excluded, + "note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).", + } + + return await anyio.to_thread.run_sync(_impl) @mcp.tool() -def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False) -> dict: +async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False) -> dict: """ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation). Garde-fou : refuse toute recette contenant un ingrédient exclu (coco !). `dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification). """ - with api.HelloFreshClient() as client: - menu = client.get_menu(week) - hf_filter.annotate(menu) - by_id = {r.id: r for r in menu} - bad = [rid for rid in recipe_ids if rid in by_id and by_id[rid].contains_excluded] - if bad: - return { - "ok": False, - "error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).", - "offending_ids": bad, - } - unknown = [rid for rid in recipe_ids if rid not in by_id] - if unknown: - return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.", - "unknown_ids": unknown} - result = client.set_selection(week, recipe_ids, dry_run=dry_run) - return {"ok": True, "week": week, "selected": recipe_ids, "dry_run": dry_run, - "api_response": result} + def _impl() -> dict: + with api.HelloFreshClient() as client: + menu = client.get_menu(week) + hf_filter.annotate(menu) + by_id = {r.id: r for r in menu} + bad = [rid for rid in recipe_ids if rid in by_id and by_id[rid].contains_excluded] + if bad: + return { + "ok": False, + "error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).", + "offending_ids": bad, + } + unknown = [rid for rid in recipe_ids if rid not in by_id] + if unknown: + return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.", + "unknown_ids": unknown} + result = client.set_selection(week, recipe_ids, dry_run=dry_run) + return {"ok": True, "week": week, "selected": recipe_ids, "dry_run": dry_run, + "api_response": result} + + return await anyio.to_thread.run_sync(_impl) @mcp.tool() -def hf_get_excludes() -> list[str]: +async def hf_get_excludes() -> list[str]: """Liste actuelle des ingrédients exclus.""" - return hf_filter.load_excludes() + return await anyio.to_thread.run_sync(hf_filter.load_excludes) @mcp.tool() -def hf_add_exclude(term: str) -> list[str]: +async def hf_add_exclude(term: str) -> list[str]: """Ajoute un ingrédient à exclure. Renvoie la nouvelle liste.""" - return hf_filter.add_exclude(term) + return await anyio.to_thread.run_sync(hf_filter.add_exclude, term) @mcp.tool() -def hf_remove_exclude(term: str) -> list[str]: +async def hf_remove_exclude(term: str) -> list[str]: """Retire un ingrédient de la liste d'exclusion. Renvoie la nouvelle liste.""" - return hf_filter.remove_exclude(term) + return await anyio.to_thread.run_sync(hf_filter.remove_exclude, term) if __name__ == "__main__": - mcp.run(transport="streamable-http") + # Défaut homelab/Hermes : streamable-http. Pour Claude Code local : ANTICOCO_TRANSPORT=stdio. + transport = os.environ.get("ANTICOCO_TRANSPORT", "streamable-http") + mcp.run(transport=transport)