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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user