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

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:
raise EndpointsNotConfigured(
f"Endpoints manquants dans {ENDPOINTS_PATH}: {missing}. "
"Compléter via tools/discover_api.py."
)
for key in ("menu", "recipe_details"):
if not data.get(key):
raise EndpointsNotConfigured(
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: