API HelloFresh réelle câblée + filtrage coco validé en local

- Endpoints découverts: menu (menus-service) + détails batch (recipes/recipes)
- get_menu en 2 temps: menu (ids) -> batch détails (ingrédients/allergènes)
- Fix faux positifs: exclusion sur ingrédients/allergènes/nom, plus sur les tags
  (HelloFresh pose un tag interne 'coconut' sur ~la moitié des recettes)
- Token mis en cache (pas de navigateur si frais)
- endpoints.json versionné (sans secret), semaine optionnelle (défaut = courante)
- Testé: 4 recettes coco/85 détectées, shortlist classée, tous les outils MCP OK
- set_selection (écriture) reste à découvrir sur un compte avec box active
This commit is contained in:
2026-06-15 22:28:40 +02:00
parent b881111504
commit ef6bf9813a
9 changed files with 170 additions and 96 deletions

4
.gitignore vendored
View File

@@ -1,9 +1,9 @@
# Secrets & session — JAMAIS versionnés (syncés à la main vers le homelab)
# (.session/discovery_log.json peut contenir des saisies sensibles)
.env
.session/
# Données découvertes localement (regénérables via tools/discover_api.py)
config/endpoints.json
# config/endpoints.json EST versionné : pas de secret, fruit de la discovery.
# Python
__pycache__/

View File

@@ -9,6 +9,18 @@ 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)
**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).
**É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`.
## Architecture
```
@@ -53,8 +65,8 @@ ANTICOCO_HEADLESS=0 python server.py # se connecter, puis Ctrl-C
# 2. Pousser le code
git add -A && git commit -m "..." && git push
# 3. Synchroniser la session vers le homelab (NON versionnée)
scp -r .session config/endpoints.json jerem@192.168.0.43:<path>/AntiCoco/
# 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/
# 4. Sur le homelab : déployer
ssh homelab

12
config/endpoints.json Normal file
View File

@@ -0,0 +1,12 @@
{
"_comment": "Endpoints HelloFresh FR confirmés via discovery + tests (2026-06). L'API interne gw/ peut changer ; rejouer tools/discover_api.py si besoin. set_selection reste à découvrir sur un compte avec abonnement actif.",
"base": "https://www.hellofresh.fr/gw",
"country": "FR",
"locale": "fr-FR",
"product": "classic-box",
"menu": "https://www.hellofresh.fr/gw/menus-service/menus",
"recipe_details": "https://www.hellofresh.fr/gw/recipes/recipes",
"weeks": "https://www.hellofresh.fr/gw/api/customers/me/deliveries",
"set_selection": "",
"set_selection_method": "PUT"
}

View File

@@ -1,17 +1,20 @@
"""Client httpx vers l'API interne HelloFresh.
"""Client httpx vers l'API interne HelloFresh (région FR).
Les URLs réelles ne sont pas codées en dur : elles viennent de `config/endpoints.json`,
généré/validé via `tools/discover_api.py`. Tant que ce fichier n'est pas rempli, les
appels lèvent une erreur explicite.
Flux confirmé par discovery + tests (cf. config/endpoints.json) :
1. MENU GET menus-service/menus?country=FR&locale=fr-FR&weeks={week}&product=classic-box
-> items[0].courses[*].recipe (ids + nom, MAIS sans ingrédients)
2. DÉTAILS GET recipes/recipes?ids=a,b,c&country=FR&locale=fr-FR&take=N
-> items[*] (recettes complètes : ingrédients + allergènes) — 1 seul appel
3. SEMAINES GET api/customers/me/deliveries (vide si pas d'abonnement actif)
Le token bearer est fourni par `auth.get_token()`. Les réponses (forme variable) sont
mappées vers `models.Recipe` / `models.Week` de façon tolérante.
Le token bearer vient d'`auth.get_token()`. `set_selection` (écriture) reste à découvrir
sur un compte avec abonnement actif → l'appel lève une erreur explicite tant qu'il est vide.
"""
from __future__ import annotations
import datetime
import json
from pathlib import Path
from typing import Any
import httpx
@@ -20,6 +23,7 @@ from . import auth
from .models import Recipe, Week, _first
ENDPOINTS_PATH = auth.ROOT / "config" / "endpoints.json"
BATCH_SIZE = 80 # nb max d'ids par appel détails (le menu en compte ~85)
DEFAULT_HEADERS = {
"Accept": "application/json",
@@ -43,22 +47,31 @@ def _load_endpoints() -> dict:
f"{ENDPOINTS_PATH} absent. Lancer `python tools/discover_api.py` pour le générer."
)
data = json.loads(ENDPOINTS_PATH.read_text(encoding="utf-8"))
missing = [k for k in ("weeks", "menu", "set_selection") if not data.get(k)]
if missing:
for key in ("menu", "recipe_details"):
if not data.get(key):
raise EndpointsNotConfigured(
f"Endpoints manquants dans {ENDPOINTS_PATH}: {missing}. "
"Compléter via tools/discover_api.py."
f"Endpoint '{key}' manquant dans {ENDPOINTS_PATH}. Rejouer discover_api.py."
)
return data
def current_week(offset: int = 0) -> str:
"""Handle de semaine ISO 'YYYY-Www' (offset en nb de semaines)."""
d = datetime.date.today() + datetime.timedelta(weeks=offset)
y, w, _ = d.isocalendar()
return f"{y}-W{w:02d}"
class HelloFreshClient:
def __init__(self, token: str | None = None):
self._endpoints = _load_endpoints()
self._ep = _load_endpoints()
self._country = self._ep.get("country", "FR")
self._locale = self._ep.get("locale", "fr-FR")
self._product = self._ep.get("product", "classic-box")
self._token = token or auth.get_token()
self._client = httpx.Client(
headers={**DEFAULT_HEADERS, "Authorization": f"Bearer {self._token}"},
timeout=30.0,
timeout=60.0,
follow_redirects=True,
)
@@ -71,11 +84,10 @@ class HelloFreshClient:
def __exit__(self, *exc) -> None:
self.close()
# --- requêtes bas niveau avec re-auth transparent sur 401 ---------------
# --- requête bas niveau avec re-auth transparent sur 401 ---------------
def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
resp = self._client.request(method, url, **kwargs)
if resp.status_code == 401:
# Token expiré → on en capture un neuf et on rejoue une fois.
self._token = auth.get_token(force=True)
self._client.headers["Authorization"] = f"Bearer {self._token}"
resp = self._client.request(method, url, **kwargs)
@@ -84,67 +96,73 @@ class HelloFreshClient:
# --- API métier ---------------------------------------------------------
def get_editable_weeks(self) -> list[Week]:
"""Liste les semaines de l'abonnement encore modifiables."""
resp = self._request("GET", self._endpoints["weeks"])
data = resp.json()
raw_weeks = _extract_list(data, "weeks", "deliveries", "items", "data")
weeks = [Week.from_api(w) for w in raw_weeks]
editable = [w for w in weeks if w.editable] or weeks
return editable
"""Semaines de livraison de l'abonnement (vide si pas d'abonnement actif)."""
params = {
"country": self._country,
"locale": self._locale,
"rangeStart": current_week(0),
"rangeEnd": current_week(10),
}
data = self._request("GET", self._ep["weeks"], params=params).json()
items = data.get("items", []) if isinstance(data, dict) else (data or [])
return [Week.from_api(w) for w in items]
def get_menu(self, week: str) -> list[Recipe]:
"""Recettes proposées pour une semaine donnée."""
url = self._endpoints["menu"].replace("{week}", str(week))
resp = self._request("GET", url, params=None if "{week}" in self._endpoints["menu"] else {"week": week})
data = resp.json()
raw_recipes = _extract_recipes(data)
return [Recipe.from_api(r) for r in raw_recipes]
def get_menu(self, week: str | None = None) -> list[Recipe]:
"""Recettes proposées pour une semaine (défaut : semaine courante), complètes.
Deux appels : le menu (ids) puis le batch de détails (ingrédients/allergènes).
"""
week = week or current_week()
menu_params = {
"country": self._country,
"locale": self._locale,
"product": self._product,
"weeks": week,
}
menu = self._request("GET", self._ep["menu"], params=menu_params).json()
items = menu.get("items", []) if isinstance(menu, dict) else []
if not items:
return []
courses = items[0].get("courses", [])
ids: list[str] = []
for c in courses:
rid = (c.get("recipe") or {}).get("id")
if rid and rid not in ids:
ids.append(rid)
return self._fetch_details(ids)
def _fetch_details(self, ids: list[str]) -> list[Recipe]:
"""Récupère les recettes complètes par batch d'ids."""
recipes: list[Recipe] = []
for i in range(0, len(ids), BATCH_SIZE):
chunk = ids[i:i + BATCH_SIZE]
params = {
"ids": ",".join(chunk),
"country": self._country,
"locale": self._locale,
"take": str(len(chunk)),
}
data = self._request("GET", self._ep["recipe_details"], params=params).json()
raw = data.get("items", []) if isinstance(data, dict) else (data or [])
recipes.extend(Recipe.from_api(r) for r in raw)
return recipes
def set_selection(self, week: str, recipe_ids: list[str]) -> dict[str, Any]:
"""Enregistre la sélection de recettes pour une semaine (écriture).
"""ÉCRIT la sélection de recettes pour une semaine (après confirmation).
N'est appelé qu'après confirmation côté serveur MCP. La forme du payload
dépend de l'endpoint découvert ; on envoie une structure courante, à ajuster
selon discover_api.py si nécessaire.
Endpoint à découvrir sur un compte avec abonnement actif (cf. set_selection vide).
"""
url = self._endpoints["set_selection"].replace("{week}", str(week))
method = self._endpoints.get("set_selection_method", "PUT").upper()
payload = {
"week": week,
"recipes": [{"id": rid, "quantity": 1} for rid in recipe_ids],
}
url = self._ep.get("set_selection") or ""
if not url:
raise EndpointsNotConfigured(
"Endpoint 'set_selection' inconnu : à capturer via discover_api.py sur un "
"compte avec box active (modifier une recette pour observer l'appel PUT/POST)."
)
url = url.replace("{week}", str(week))
method = self._ep.get("set_selection_method", "PUT").upper()
payload = {"week": week, "recipes": [{"id": rid, "quantity": 1} for rid in recipe_ids]}
resp = self._request(method, url, json=payload)
try:
return resp.json()
except Exception:
return {"status": resp.status_code, "ok": True}
def _extract_list(data: Any, *keys: str) -> list[dict]:
if isinstance(data, list):
return data
if isinstance(data, dict):
found = _first(data, *keys, default=None)
if isinstance(found, list):
return found
# Parfois imbriqué sous "data"/"items"
for v in data.values():
if isinstance(v, list):
return v
return []
def _extract_recipes(data: Any) -> list[dict]:
"""Extrait la liste de recettes, qui peut être imbriquée dans des 'courses'."""
if isinstance(data, dict):
courses = _first(data, "courses", "modules", "items", default=None)
if isinstance(courses, list):
recipes = []
for c in courses:
if isinstance(c, dict) and "recipe" in c and isinstance(c["recipe"], dict):
recipes.append(c["recipe"])
elif isinstance(c, dict):
recipes.append(c)
if recipes:
return recipes
return _extract_list(data, "recipes", "items", "data")

View File

@@ -27,7 +27,7 @@ TOKEN_CACHE = SESSION_DIR / "token.json"
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-menu"
MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu"
ACCOUNT_PAGE = f"{BASE_URL}/my-account"
ATTENTE_LOGIN_S = 180 # temps laissé pour un login manuel (captcha / 2FA)
@@ -186,7 +186,15 @@ def _read_token_cache() -> dict | None:
def get_token(force: bool = False) -> str:
"""Renvoie un bearer token valide, en s'assurant d'être connecté au préalable."""
"""Renvoie un bearer token valide.
Si un token en cache est encore frais, on l'utilise sans ouvrir de navigateur.
Sinon on s'assure d'être connecté puis on en capture un neuf.
"""
if not force:
cached = _read_token_cache()
if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S:
return cached["token"]
ensure_logged_in()
return capture_token(force=force)["token"]

View File

@@ -64,15 +64,27 @@ def load_prefs() -> dict:
# --- application aux recettes ----------------------------------------------
def _recipe_haystack(recipe: Recipe) -> str:
parts = [recipe.name, recipe.headline, *recipe.ingredients, *recipe.allergens, *recipe.tags]
def _exclusion_haystack(recipe: Recipe) -> str:
"""Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche.
On EXCLUT volontairement les `tags` : HelloFresh y pose des tags internes non
affichés (ex. un tag `coconut` présent sur des dizaines de recettes sans coco),
ce qui provoquait des faux positifs massifs.
"""
parts = [recipe.name, recipe.headline, *recipe.ingredients, *recipe.allergens]
return normalize(" | ".join(p for p in parts if p))
def _pref_haystack(recipe: Recipe) -> str:
"""Pour le scoring de préférences, les tags sont utiles (catégories de cuisine…)."""
parts = [recipe.name, recipe.headline, *recipe.ingredients, *recipe.tags]
return normalize(" | ".join(p for p in parts if p))
def mark_excluded(recipe: Recipe, excludes: list[str] | None = None) -> Recipe:
"""Remplit `contains_excluded` et `matched_excludes` sur la recette."""
excludes = excludes if excludes is not None else load_excludes()
hay = _recipe_haystack(recipe)
hay = _exclusion_haystack(recipe)
matched = [term for term in excludes if normalize(term) and normalize(term) in hay]
recipe.matched_excludes = matched
recipe.contains_excluded = bool(matched)
@@ -81,7 +93,7 @@ def mark_excluded(recipe: Recipe, excludes: list[str] | None = None) -> Recipe:
def score(recipe: Recipe, prefs: dict | None = None) -> float:
prefs = prefs or load_prefs()
hay = _recipe_haystack(recipe)
hay = _pref_haystack(recipe)
s = 0.0
for kw in prefs.get("liked", []):
if normalize(kw) and normalize(kw) in hay:

View File

@@ -52,35 +52,37 @@ def hf_list_weeks() -> list[dict]:
@mcp.tool()
def hf_get_menu(week: str) -> dict:
def hf_get_menu(week: str = "") -> dict:
"""Toutes les recettes proposées pour une semaine, chacune annotée.
Chaque recette porte `contains_excluded` (true si elle contient un ingrédient banni,
coco en tête) et `matched_excludes` (quels termes ont matché).
`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(week)
recipes = client.get_menu(w)
hf_filter.annotate(recipes)
return {
"week": week,
"week": w,
"count": len(recipes),
"recipes": [r.summary() for r in recipes],
}
@mcp.tool()
def hf_propose(week: str, count: int = 0) -> dict:
def hf_propose(week: str = "", count: int = 0) -> dict:
"""Shortlist de recettes SANS ingrédient exclu, classée par préférences.
`count=0` renvoie toutes les recettes sûres. Étape « je propose » : rien n'est écrit
sur le compte ici — utiliser hf_confirm_selection() ensuite.
`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(week)
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": week,
"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).",

View File

@@ -24,6 +24,7 @@ Sortie :
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
@@ -71,10 +72,19 @@ def main() -> None:
print("Ouvre le menu de la semaine, change une recette si tu veux capturer l'écriture.")
page.goto(auth.BASE_URL + "/my-account", wait_until="domcontentloaded", timeout=30000)
# Si on a un vrai terminal : on attend Entrée. Sinon (lancé en arrière-plan,
# sans TTY) : on attend une durée fixe pour laisser le temps de se connecter
# et de naviguer, tout en continuant à logger les requêtes.
if sys.stdin and sys.stdin.isatty():
try:
input("\n>>> Quand tu as fini de naviguer, appuie sur Entrée pour générer le rapport...\n")
except (EOFError, KeyboardInterrupt):
pass
else:
wait_s = int(os.environ.get("ANTICOCO_DISCOVER_WAIT", "240"))
print(f"\n>>> Pas de terminal interactif : capture pendant {wait_s}s. "
"Connecte-toi et navigue dans la fenêtre ouverte...")
page.wait_for_timeout(wait_s * 1000)
ctx.close()