From ef6bf9813a5b5b038874f34800a5b782b43b0516 Mon Sep 17 00:00:00 2001 From: jerem Date: Mon, 15 Jun 2026 22:28:40 +0200 Subject: [PATCH] =?UTF-8?q?API=20HelloFresh=20r=C3=A9elle=20c=C3=A2bl?= =?UTF-8?q?=C3=A9e=20+=20filtrage=20coco=20valid=C3=A9=20en=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 +- README.md | 16 ++++- config/endpoints.json | 12 ++++ config/excludes.json | 2 +- hellofresh/api.py | 160 +++++++++++++++++++++++------------------- hellofresh/auth.py | 12 +++- hellofresh/filter.py | 20 ++++-- server.py | 22 +++--- tools/discover_api.py | 18 +++-- 9 files changed, 170 insertions(+), 96 deletions(-) create mode 100644 config/endpoints.json diff --git a/.gitignore b/.gitignore index 7132f64..a21afea 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/README.md b/README.md index 05e4c39..c9cd18b 100644 --- a/README.md +++ b/README.md @@ -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:/AntiCoco/ +# 3. Synchroniser la session vers le homelab (NON versionnée ; endpoints.json est dans git) +scp -r .session jerem@192.168.0.43:/AntiCoco/ # 4. Sur le homelab : déployer ssh homelab diff --git a/config/endpoints.json b/config/endpoints.json new file mode 100644 index 0000000..f9102e4 --- /dev/null +++ b/config/endpoints.json @@ -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" +} diff --git a/config/excludes.json b/config/excludes.json index 62a999a..629b5c0 100644 --- a/config/excludes.json +++ b/config/excludes.json @@ -13,4 +13,4 @@ "rape de coco", "noix de coco rapee" ] -} +} \ No newline at end of file diff --git a/hellofresh/api.py b/hellofresh/api.py index bc7c786..af80b29 100644 --- a/hellofresh/api.py +++ b/hellofresh/api.py @@ -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") diff --git a/hellofresh/auth.py b/hellofresh/auth.py index 866825e..40a7c9e 100644 --- a/hellofresh/auth.py +++ b/hellofresh/auth.py @@ -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"] diff --git a/hellofresh/filter.py b/hellofresh/filter.py index 9dcbcd8..c93e0c1 100644 --- a/hellofresh/filter.py +++ b/hellofresh/filter.py @@ -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: diff --git a/server.py b/server.py index ace6d67..0fe2225 100644 --- a/server.py +++ b/server.py @@ -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).", diff --git a/tools/discover_api.py b/tools/discover_api.py index 1870a43..1f2db3c 100644 --- a/tools/discover_api.py +++ b/tools/discover_api.py @@ -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) - try: - input("\n>>> Quand tu as fini de naviguer, appuie sur Entrée pour générer le rapport...\n") - except (EOFError, KeyboardInterrupt): - pass + # 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()