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")