Auth headless durable via storage_state + refresh navigateur (homelab-ready)
- 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)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
52
README.md
52
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:<path>/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:<path>/AntiCoco/.session/
|
||||
|
||||
# 4. Sur le homelab : déployer
|
||||
ssh homelab
|
||||
|
||||
@@ -139,21 +139,13 @@ 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.
|
||||
"""
|
||||
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):
|
||||
@@ -162,26 +154,33 @@ def capture_token(force: bool = False) -> dict:
|
||||
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()
|
||||
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="networkidle", timeout=45000)
|
||||
page.wait_for_timeout(3000)
|
||||
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 _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."
|
||||
"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"]),
|
||||
@@ -189,6 +188,35 @@ def capture_token(force: bool = False) -> dict:
|
||||
}
|
||||
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)
|
||||
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:
|
||||
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"]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mcp>=1.2
|
||||
playwright>=1.45
|
||||
playwright==1.60.0
|
||||
httpx>=0.28
|
||||
python-dotenv>=1.0
|
||||
|
||||
Reference in New Issue
Block a user