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) # Secrets & session — JAMAIS versionnés (syncés à la main vers le homelab)
# (.session/discovery_log.json peut contenir des saisies sensibles)
.env .env
.session/ .session/
# Données découvertes localement (regénérables via tools/discover_api.py) # config/endpoints.json EST versionné : pas de secret, fruit de la discovery.
config/endpoints.json
# Python # Python
__pycache__/ __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 > ⚠️ 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**. > (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 ## Architecture
``` ```
@@ -53,8 +65,8 @@ ANTICOCO_HEADLESS=0 python server.py # se connecter, puis Ctrl-C
# 2. Pousser le code # 2. Pousser le code
git add -A && git commit -m "..." && git push git add -A && git commit -m "..." && git push
# 3. Synchroniser la session vers le homelab (NON versionnée) # 3. Synchroniser la session vers le homelab (NON versionnée ; endpoints.json est dans git)
scp -r .session config/endpoints.json jerem@192.168.0.43:<path>/AntiCoco/ scp -r .session jerem@192.168.0.43:<path>/AntiCoco/
# 4. Sur le homelab : déployer # 4. Sur le homelab : déployer
ssh homelab 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`, Flux confirmé par discovery + tests (cf. config/endpoints.json) :
généré/validé via `tools/discover_api.py`. Tant que ce fichier n'est pas rempli, les 1. MENU GET menus-service/menus?country=FR&locale=fr-FR&weeks={week}&product=classic-box
appels lèvent une erreur explicite. -> 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 Le token bearer vient d'`auth.get_token()`. `set_selection` (écriture) reste à découvrir
mappées vers `models.Recipe` / `models.Week` de façon tolérante. sur un compte avec abonnement actif → l'appel lève une erreur explicite tant qu'il est vide.
""" """
from __future__ import annotations from __future__ import annotations
import datetime
import json import json
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
@@ -20,6 +23,7 @@ from . import auth
from .models import Recipe, Week, _first from .models import Recipe, Week, _first
ENDPOINTS_PATH = auth.ROOT / "config" / "endpoints.json" 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 = { DEFAULT_HEADERS = {
"Accept": "application/json", "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." 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")) data = json.loads(ENDPOINTS_PATH.read_text(encoding="utf-8"))
missing = [k for k in ("weeks", "menu", "set_selection") if not data.get(k)] for key in ("menu", "recipe_details"):
if missing: if not data.get(key):
raise EndpointsNotConfigured( raise EndpointsNotConfigured(
f"Endpoints manquants dans {ENDPOINTS_PATH}: {missing}. " f"Endpoint '{key}' manquant dans {ENDPOINTS_PATH}. Rejouer discover_api.py."
"Compléter via tools/discover_api.py." )
)
return data 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: class HelloFreshClient:
def __init__(self, token: str | None = None): 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._token = token or auth.get_token()
self._client = httpx.Client( self._client = httpx.Client(
headers={**DEFAULT_HEADERS, "Authorization": f"Bearer {self._token}"}, headers={**DEFAULT_HEADERS, "Authorization": f"Bearer {self._token}"},
timeout=30.0, timeout=60.0,
follow_redirects=True, follow_redirects=True,
) )
@@ -71,11 +84,10 @@ class HelloFreshClient:
def __exit__(self, *exc) -> None: def __exit__(self, *exc) -> None:
self.close() 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: def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
resp = self._client.request(method, url, **kwargs) resp = self._client.request(method, url, **kwargs)
if resp.status_code == 401: 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._token = auth.get_token(force=True)
self._client.headers["Authorization"] = f"Bearer {self._token}" self._client.headers["Authorization"] = f"Bearer {self._token}"
resp = self._client.request(method, url, **kwargs) resp = self._client.request(method, url, **kwargs)
@@ -84,67 +96,73 @@ class HelloFreshClient:
# --- API métier --------------------------------------------------------- # --- API métier ---------------------------------------------------------
def get_editable_weeks(self) -> list[Week]: def get_editable_weeks(self) -> list[Week]:
"""Liste les semaines de l'abonnement encore modifiables.""" """Semaines de livraison de l'abonnement (vide si pas d'abonnement actif)."""
resp = self._request("GET", self._endpoints["weeks"]) params = {
data = resp.json() "country": self._country,
raw_weeks = _extract_list(data, "weeks", "deliveries", "items", "data") "locale": self._locale,
weeks = [Week.from_api(w) for w in raw_weeks] "rangeStart": current_week(0),
editable = [w for w in weeks if w.editable] or weeks "rangeEnd": current_week(10),
return editable }
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]: def get_menu(self, week: str | None = None) -> list[Recipe]:
"""Recettes proposées pour une semaine donnée.""" """Recettes proposées pour une semaine (défaut : semaine courante), complètes.
url = self._endpoints["menu"].replace("{week}", str(week))
resp = self._request("GET", url, params=None if "{week}" in self._endpoints["menu"] else {"week": week}) Deux appels : le menu (ids) puis le batch de détails (ingrédients/allergènes).
data = resp.json() """
raw_recipes = _extract_recipes(data) week = week or current_week()
return [Recipe.from_api(r) for r in raw_recipes] 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]: 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 Endpoint à découvrir sur un compte avec abonnement actif (cf. set_selection vide).
dépend de l'endpoint découvert ; on envoie une structure courante, à ajuster
selon discover_api.py si nécessaire.
""" """
url = self._endpoints["set_selection"].replace("{week}", str(week)) url = self._ep.get("set_selection") or ""
method = self._endpoints.get("set_selection_method", "PUT").upper() if not url:
payload = { raise EndpointsNotConfigured(
"week": week, "Endpoint 'set_selection' inconnu : à capturer via discover_api.py sur un "
"recipes": [{"id": rid, "quantity": 1} for rid in recipe_ids], "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) resp = self._request(method, url, json=payload)
try: try:
return resp.json() return resp.json()
except Exception: except Exception:
return {"status": resp.status_code, "ok": True} 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" BASE_URL = "https://www.hellofresh.fr"
# Page qui déclenche des appels gateway authentifiés (menu de la semaine). # 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" ACCOUNT_PAGE = f"{BASE_URL}/my-account"
ATTENTE_LOGIN_S = 180 # temps laissé pour un login manuel (captcha / 2FA) 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: 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() ensure_logged_in()
return capture_token(force=force)["token"] return capture_token(force=force)["token"]

View File

@@ -64,15 +64,27 @@ def load_prefs() -> dict:
# --- application aux recettes ---------------------------------------------- # --- application aux recettes ----------------------------------------------
def _recipe_haystack(recipe: Recipe) -> str: def _exclusion_haystack(recipe: Recipe) -> str:
parts = [recipe.name, recipe.headline, *recipe.ingredients, *recipe.allergens, *recipe.tags] """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)) return normalize(" | ".join(p for p in parts if p))
def mark_excluded(recipe: Recipe, excludes: list[str] | None = None) -> Recipe: def mark_excluded(recipe: Recipe, excludes: list[str] | None = None) -> Recipe:
"""Remplit `contains_excluded` et `matched_excludes` sur la recette.""" """Remplit `contains_excluded` et `matched_excludes` sur la recette."""
excludes = excludes if excludes is not None else load_excludes() 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] matched = [term for term in excludes if normalize(term) and normalize(term) in hay]
recipe.matched_excludes = matched recipe.matched_excludes = matched
recipe.contains_excluded = bool(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: def score(recipe: Recipe, prefs: dict | None = None) -> float:
prefs = prefs or load_prefs() prefs = prefs or load_prefs()
hay = _recipe_haystack(recipe) hay = _pref_haystack(recipe)
s = 0.0 s = 0.0
for kw in prefs.get("liked", []): for kw in prefs.get("liked", []):
if normalize(kw) and normalize(kw) in hay: if normalize(kw) and normalize(kw) in hay:

View File

@@ -52,35 +52,37 @@ def hf_list_weeks() -> list[dict]:
@mcp.tool() @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. """Toutes les recettes proposées pour une semaine, chacune annotée.
Chaque recette porte `contains_excluded` (true si elle contient un ingrédient banni, `week` vide = semaine courante (format 'YYYY-Www'). Chaque recette porte
coco en tête) et `matched_excludes` (quels termes ont matché). `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: with api.HelloFreshClient() as client:
recipes = client.get_menu(week) recipes = client.get_menu(w)
hf_filter.annotate(recipes) hf_filter.annotate(recipes)
return { return {
"week": week, "week": w,
"count": len(recipes), "count": len(recipes),
"recipes": [r.summary() for r in recipes], "recipes": [r.summary() for r in recipes],
} }
@mcp.tool() @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. """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 `week` vide = semaine courante. `count=0` renvoie toutes les recettes sûres.
sur le compte ici — utiliser hf_confirm_selection() ensuite. Étape « je propose » : rien n'est écrit ici — utiliser hf_confirm_selection() ensuite.
""" """
w = week or api.current_week()
with api.HelloFreshClient() as client: with api.HelloFreshClient() as client:
recipes = client.get_menu(week) recipes = client.get_menu(w)
safe = hf_filter.propose(recipes, count=count or None) safe = hf_filter.propose(recipes, count=count or None)
excluded = [r.summary() for r in recipes if r.contains_excluded] excluded = [r.summary() for r in recipes if r.contains_excluded]
return { return {
"week": week, "week": w,
"proposed": [r.summary() for r in safe], "proposed": [r.summary() for r in safe],
"excluded_for_coco_etc": excluded, "excluded_for_coco_etc": excluded,
"note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).", "note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).",

View File

@@ -24,6 +24,7 @@ Sortie :
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
from pathlib import Path 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.") 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) page.goto(auth.BASE_URL + "/my-account", wait_until="domcontentloaded", timeout=30000)
try: # Si on a un vrai terminal : on attend Entrée. Sinon (lancé en arrière-plan,
input("\n>>> Quand tu as fini de naviguer, appuie sur Entrée pour générer le rapport...\n") # sans TTY) : on attend une durée fixe pour laisser le temps de se connecter
except (EOFError, KeyboardInterrupt): # et de naviguer, tout en continuant à logger les requêtes.
pass 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() ctx.close()