From 5d3899fdfb5c8254a1bea1011e243e0a82cbbc5c Mon Sep 17 00:00:00 2001 From: jerem Date: Mon, 15 Jun 2026 23:08:09 +0200 Subject: [PATCH] Auth headless durable via storage_state + refresh navigateur (homelab-ready) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capture_token s'appuie sur storage_state.json (cookies ~60j) en new_context : fonctionne headless, la SPA rafraîchit le token (contourne l'anti-bot OAuth) - session 'roule' (storage_state ré-exporté à chaque refresh) ; access token 30min, refresh token 60j - goto en domcontentloaded + attente (networkidle ne se déclenche jamais sur la SPA) - Dockerfile/Playwright alignés en 1.60.0 (chromium préinstallé) ; doc déploiement maj : session créée via attach_capture (login direct Playwright bloqué par anti-bot) --- Dockerfile | 3 +- README.md | 52 +++++++++++---------- hellofresh/auth.py | 112 +++++++++++++++++++++++++++++---------------- requirements.txt | 2 +- 4 files changed, 104 insertions(+), 65 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2c54a0d..6fc6ce1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ # Image Playwright officielle : Chromium + polices + deps système déjà présents. -FROM mcr.microsoft.com/playwright/python:v1.49.1-noble +# La version DOIT correspondre à playwright dans requirements.txt (chromium préinstallé). +FROM mcr.microsoft.com/playwright/python:v1.60.0-noble WORKDIR /app diff --git a/README.md b/README.md index c9cd18b..a9798e0 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,27 @@ Client MCP visé : **Hermes** (Nous Research), qui tourne sur le même homelab. > ⚠️ HelloFresh n'a pas d'API publique. AntiCoco s'appuie sur l'API interne `gw/` du site > (non documentée, susceptible de changer) — **usage strictement personnel**. -## État (testé en local, 2026-06) +## État (validé de bout en bout, 2026-06) -✅ **Lecture validée sur le menu réel** : login Playwright + token, menu de la semaine -(`menus-service/menus`) → détails en 1 appel batch (`recipes/recipes?ids=…`), filtrage coco -correct (4 recettes coco détectées sur 85, faux positifs des tags internes neutralisés), -scoring par préférences, gestion de la liste d'exclusion. Serveur MCP fonctionnel (handshake OK). +✅ **Boucle complète testée sur le vrai compte** : lecture du menu (`menus-service/menus` → +détails batch `recipes/recipes?ids=…`), filtrage coco (4/85 détectées, faux positifs des tags +internes neutralisés), proposition classée, et **écriture réelle réussie** (`PUT /v1/carts/{week}`, +HTTP 200) — sélection par index de course, ids de compte dérivés dynamiquement. -⏳ **Écriture (`hf_confirm_selection`) à finaliser** : l'endpoint d'enregistrement de la -sélection (`set_selection`) n'a pas pu être capturé (compte de test sans abonnement actif). -À découvrir via `discover_api.py` sur un compte avec une box modifiable (changer une recette -pour observer l'appel `PUT`/`POST`), puis renseigner `config/endpoints.json`. +✅ **Auth headless durable** : le token (30 min) est rafraîchi par un navigateur headless chargé +avec la session (`storage_state.json`, refresh ~60 j) — contourne la protection anti-bot des +endpoints OAuth. Aucune intervention pendant ~60 j ; la session « roule » à chaque refresh. + +> ⚠️ La connexion **directe** automatisée (Playwright/Chromium qui remplit le formulaire) est +> bloquée par l'anti-bot HelloFresh. La session se crée donc via **attache CDP à ton vrai Chrome** +> (`tools/attach_capture.py`), où le login marche normalement. ## Architecture ``` Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp) - ├─ hellofresh/auth.py login Playwright + capture du bearer token - ├─ hellofresh/api.py appels httpx vers le gateway HelloFresh + ├─ hellofresh/auth.py session storage_state + refresh token headless + ├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart ├─ hellofresh/filter.py exclusion (coco !) + scoring préférences └─ config/ excludes.json · prefs.json · endpoints.json ``` @@ -37,36 +40,39 @@ Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp) ```bash pip install -r requirements.txt playwright install chromium -cp .env.example .env # remplir HF_EMAIL / HF_PASSWORD (optionnels, fallback re-login) +cp .env.example .env ``` -### 2. Découvrir les endpoints (étape 0, en local, fenêtre visible) +### 2. Créer la session (login via TON Chrome, anti-bot contourné) +Lance ton Chrome avec un port de debug + profil dédié (ta fenêtre Chrome habituelle peut rester +ouverte), connecte-toi à HelloFresh (email + mot de passe), puis attache la capture : ```bash -ANTICOCO_HEADLESS=0 python tools/discover_api.py +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --remote-debugging-port=9222 --user-data-dir="$HOME/.hf-chrome-debug" \ + https://www.hellofresh.fr/my-account/deliveries/menu +python tools/attach_capture.py # capture trafic + exporte .session/storage_state.json ``` -Connecte-toi, ouvre le menu de la semaine, change une recette pour capturer l'écriture, puis -Entrée. Vérifie/complète ensuite `config/endpoints.json` (généré depuis le trafic capturé, -voir aussi `.session/discovery_log.json`). +`storage_state.json` (cookies, ~60 j) est la session réutilisable. `config/endpoints.json` est +déjà rempli ; rejoue `attach_capture` si l'API change (cf. `config/endpoints_discovered.json`). -### 3. Tester en local +### 3. Tester en local (headless, comme le homelab) ```bash -ANTICOCO_HEADLESS=0 python server.py # 1er run : login dans la fenêtre si besoin +python server.py # auth via storage_state, refresh token automatique ``` -Le login crée `.session/profile` (profil Playwright persistant) — réutilisé ensuite headless. ## Déploiement homelab (Docker) `.session/` et `.env` ne sont **jamais** versionnés. Workflow : ```bash -# 1. Sur le Mac : générer une session connectée (fenêtre visible) -ANTICOCO_HEADLESS=0 python server.py # se connecter, puis Ctrl-C +# 1. Sur le Mac : générer la session (cf. « Mise en route » §2 → .session/storage_state.json) # 2. Pousser le code git add -A && git commit -m "..." && git push # 3. Synchroniser la session vers le homelab (NON versionnée ; endpoints.json est dans git) -scp -r .session jerem@192.168.0.43:/AntiCoco/ +# storage_state.json suffit (le homelab tourne headless et rafraîchit le token tout seul). +scp .session/storage_state.json jerem@192.168.0.43:/AntiCoco/.session/ # 4. Sur le homelab : déployer ssh homelab diff --git a/hellofresh/auth.py b/hellofresh/auth.py index 02ec614..92740aa 100644 --- a/hellofresh/auth.py +++ b/hellofresh/auth.py @@ -139,56 +139,84 @@ def ensure_logged_in() -> bool: 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. +def _capture_from_context(ctx, roll_state: bool) -> dict: + """Charge le menu dans `ctx` et capture le bearer token (refraîchi par la SPA). - Retourne {"token": str, "gateways": [str], "captured_at": float}. + `roll_state=True` : ré-exporte storage_state.json après coup, pour faire « rouler » + la session (le refresh_token tourne ~60 j) sans intervention manuelle. + """ + observed = {"token": None, "gateways": set()} + 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) + a = req.headers.get("authorization") or req.headers.get("Authorization") + if a and a.lower().startswith("bearer "): + observed["token"] = a.split(" ", 1)[1].strip() + except Exception: + pass + + page.on("request", on_request) + page.goto(MENU_PAGE, wait_until="domcontentloaded", timeout=45000) + # SPA lourde : on attend que les appels gateway (et un éventuel refresh) partent, + # plutôt que networkidle qui ne se déclenche jamais. + for _ in range(15): + page.wait_for_timeout(1000) + if observed["token"]: + page.wait_for_timeout(1000) + break + + if not observed["token"]: + raise RuntimeError( + "Aucun bearer token capturé (session expirée ? refaire le login + re-sync). " + ) + + if roll_state: + try: + ctx.storage_state(path=str(STATE_PATH)) + except Exception: + pass + + 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 + + +def capture_token(force: bool = False) -> dict: + """Renvoie un bearer token frais (et les hôtes gateway), via navigateur. + + Préfère `storage_state.json` (cookies 60 j) → fonctionne en **headless** sur le homelab : + la SPA rafraîchit elle-même le token (contourne la protection anti-bot des endpoints + OAuth bruts). À défaut, retombe sur le profil persistant (login interactif). """ 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: + if STATE_PATH.exists(): + launch = {"headless": _headless()} + if _channel(): + launch["channel"] = _channel() + browser = pw.chromium.launch(**launch) + ctx = browser.new_context(storage_state=str(STATE_PATH), locale="fr-FR") + try: + return _capture_from_context(ctx, roll_state=True) + finally: + ctx.close() + browser.close() 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 + return _capture_from_context(ctx, roll_state=False) finally: ctx.close() @@ -248,6 +276,10 @@ def get_token(force: bool = False) -> str: tok = token_from_storage_state() if tok: return tok + # Refresh nécessaire : si on a une session synchronisée, le navigateur (même headless) + # la rafraîchit ; sinon login interactif via le profil persistant. + if STATE_PATH.exists(): + return capture_token(force=force)["token"] ensure_logged_in() return capture_token(force=force)["token"] diff --git a/requirements.txt b/requirements.txt index b3e06cd..23011b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ mcp>=1.2 -playwright>=1.45 +playwright==1.60.0 httpx>=0.28 python-dotenv>=1.0