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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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__/
|
||||||
|
|||||||
16
README.md
16
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
|
> ⚠️ 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
12
config/endpoints.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
22
server.py
22
server.py
@@ -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).",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user