diff --git a/README.md b/README.md index 651968b..9453471 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,11 @@ intervention ni re-sync. Le navigateur headless ne sert plus que de filet de sec ``` Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp) + navigateur ──▶ server.py (UI admin, :9200/) ├─ hellofresh/auth.py session storage_state + refresh HTTP /gw/refresh ├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart ├─ hellofresh/filter.py exclusion (coco !) + scoring préférences + ├─ hellofresh/webui.py page d'admin + API JSON (édition à chaud) └─ config/ excludes.json · prefs.json · endpoints.json ``` @@ -129,10 +131,24 @@ Enregistrer AntiCoco dans la config MCP de Hermes (côté homelab), URL | `hf_next_delivery()` | **prochaine box réellement sélectionnée** (≈4 recettes) + date/cutoff, images servables — prêt Telegram | | `hf_favorites()` | recettes **favorites** du compte (images servables) | | `hf_get_menu(week)` | toutes les recettes, avec `contains_excluded` et `is_favorite` | -| `hf_propose(week, count=0)` | shortlist **sans coco**, classée par préférences | -| `hf_confirm_selection(week, recipe_ids)` | **écrit** la sélection (refuse la coco) | +| `hf_propose(week, count=0)` | shortlist **sans coco ni premium**, classée par préférences | +| `hf_confirm_selection(week, recipe_ids, allow_premium=False)` | **écrit** la sélection (refuse coco **et** recettes payantes hors abonnement) | | `hf_get_excludes()` / `hf_add_exclude(term)` / `hf_remove_exclude(term)` | gérer la liste d'exclusion | +Les recettes premium (supplément, `chargeSetting` côté HelloFresh) portent `is_premium` / +`surcharge_eur` dans chaque réponse. `hf_confirm_selection` les refuse par défaut ; +`allow_premium=True` accepte sciemment le surcoût. + +## Interface web d'admin + +Sur le **même port que MCP** : `http://127.0.0.1:9200/` (MCP reste sur `/mcp`). +Édition à chaud (prise en compte au prochain appel, sans redémarrage) de : +- la **liste d'exclusion** (coco & co) ; +- les **préférences** de scoring `liked` / `disliked`. + +Le port est bindé sur `127.0.0.1` du homelab : y accéder via tunnel SSH +(`ssh -L 9200:127.0.0.1:9200 homelab`) puis ouvrir `http://localhost:9200/`. + ## Configuration - `config/excludes.json` — ingrédients bannis (matching insensible casse/accents). Coco déjà listée. diff --git a/hellofresh/api.py b/hellofresh/api.py index aff72e0..188121a 100644 --- a/hellofresh/api.py +++ b/hellofresh/api.py @@ -228,15 +228,23 @@ class HelloFreshClient: if not courses: return [] id_index: dict[str, int] = {} + charge_by_id: dict[str, dict] = {} ids: list[str] = [] for c in courses: rid = (c.get("recipe") or {}).get("id") if rid and rid not in id_index: id_index[rid] = c.get("index") + cs = c.get("chargeSetting") + if isinstance(cs, dict): + charge_by_id[rid] = cs ids.append(rid) recipes = self._fetch_details(ids) for r in recipes: r.course_index = id_index.get(r.id) + cs = charge_by_id.get(r.id) + if cs: + r.surcharge_cents = int(cs.get("amount") or 0) + r.surcharge_reason = str(cs.get("reason") or "") return _dedupe_by_name(recipes) def _fetch_details(self, ids: list[str]) -> list[Recipe]: diff --git a/hellofresh/filter.py b/hellofresh/filter.py index c93e0c1..f3da5c0 100644 --- a/hellofresh/filter.py +++ b/hellofresh/filter.py @@ -38,7 +38,7 @@ def save_excludes(terms: list[str]) -> None: if EXCLUDES_PATH.exists(): existing = json.loads(EXCLUDES_PATH.read_text(encoding="utf-8")) existing["exclude"] = terms - EXCLUDES_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8") + EXCLUDES_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def add_exclude(term: str) -> list[str]: @@ -63,6 +63,17 @@ def load_prefs() -> dict: return {"liked": data.get("liked", []), "disliked": data.get("disliked", [])} +def save_prefs(liked: list[str], disliked: list[str]) -> dict: + """Écrit liked/disliked en préservant le commentaire éventuel du fichier.""" + existing = {} + if PREFS_PATH.exists(): + existing = json.loads(PREFS_PATH.read_text(encoding="utf-8")) + existing["liked"] = liked + existing["disliked"] = disliked + PREFS_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return {"liked": liked, "disliked": disliked} + + # --- application aux recettes ---------------------------------------------- def _exclusion_haystack(recipe: Recipe) -> str: """Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche. @@ -115,9 +126,14 @@ def annotate(recipes: list[Recipe]) -> list[Recipe]: return recipes -def propose(recipes: list[Recipe], count: int | None = None) -> list[Recipe]: - """Retire les recettes exclues (coco…) et classe le reste par score décroissant.""" +def propose(recipes: list[Recipe], count: int | None = None, + allow_premium: bool = False) -> list[Recipe]: + """Retire les recettes exclues (coco…) et payantes, classe le reste par score décroissant. + + `allow_premium=True` conserve les recettes à supplément (hors abonnement) dans la liste. + """ annotate(recipes) - safe = [r for r in recipes if not r.contains_excluded] + safe = [r for r in recipes + if not r.contains_excluded and (allow_premium or not r.is_premium)] safe.sort(key=lambda r: r.score, reverse=True) return safe[:count] if count else safe diff --git a/hellofresh/models.py b/hellofresh/models.py index fb59d7d..db4d724 100644 --- a/hellofresh/models.py +++ b/hellofresh/models.py @@ -52,12 +52,20 @@ class Recipe: image_url: str = "" prep_time: str = "" course_index: int | None = None # index du course dans le menu (sert à l'écriture) + # Supplément hors abonnement (rempli depuis le menu) : 0 = inclus, >0 = surcoût en centimes + surcharge_cents: int = 0 + surcharge_reason: str = "" # ex. "premium" (motif renvoyé par chargeSetting) # Champs calculés par le filtre (remplis plus tard) contains_excluded: bool = False matched_excludes: list[str] = field(default_factory=list) score: float = 0.0 is_favorite: bool = False # rempli par api (best-effort) depuis le service favoris + @property + def is_premium(self) -> bool: + """True si la recette coûte un supplément (non incluse dans l'abonnement).""" + return self.surcharge_cents > 0 + @classmethod def from_api(cls, raw: dict[str, Any]) -> "Recipe": """Construit une Recipe depuis un objet recette brut de l'API gateway. @@ -123,6 +131,9 @@ class Recipe: "image_url": fix_image_url(self.image_url), "prep_time": self.prep_time, "prep_minutes": _iso_duration_to_minutes(self.prep_time), + "is_premium": self.is_premium, + "surcharge_eur": round(self.surcharge_cents / 100, 2) if self.surcharge_cents else 0, + "surcharge_reason": self.surcharge_reason, "allergens": self.allergens, "contains_excluded": self.contains_excluded, "matched_excludes": self.matched_excludes, diff --git a/hellofresh/webui.py b/hellofresh/webui.py new file mode 100644 index 0000000..5a5fcc2 --- /dev/null +++ b/hellofresh/webui.py @@ -0,0 +1,275 @@ +"""Petite interface web d'administration, greffée sur le serveur FastMCP (même port). + +Permet d'éditer à la main les paramètres « métier » sans rebuild ni édition JSON : + - la liste des ingrédients à exclure (coco & co) ; + - les préférences de scoring (liked / disliked). + +Les écritures passent par `hellofresh.filter` (mêmes fichiers que le serveur MCP), +donc le serveur prend en compte les changements au prochain appel — pas de redémarrage. +Routes enregistrées via `register(mcp)` depuis server.py : + GET / -> page HTML + GET /api/config -> {excludes, prefs} + PUT /api/excludes-> sauve la liste d'exclusion + PUT /api/prefs -> sauve liked/disliked +""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response + +from . import filter as hf_filter + + +def _clean_list(value) -> list[str]: + """Normalise une entrée en liste de chaînes non vides, dédupliquées (ordre conservé).""" + if not isinstance(value, list): + return [] + out: list[str] = [] + seen: set[str] = set() + for v in value: + s = str(v).strip() + key = hf_filter.normalize(s) + if s and key not in seen: + seen.add(key) + out.append(s) + return out + + +async def _config(request: Request) -> Response: + return JSONResponse({ + "excludes": hf_filter.load_excludes(), + "prefs": hf_filter.load_prefs(), + }) + + +async def _put_excludes(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "JSON invalide"}, status_code=400) + terms = _clean_list(body.get("exclude")) + hf_filter.save_excludes(terms) + return JSONResponse({"exclude": terms}) + + +async def _put_prefs(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "JSON invalide"}, status_code=400) + liked = _clean_list(body.get("liked")) + disliked = _clean_list(body.get("disliked")) + saved = hf_filter.save_prefs(liked, disliked) + return JSONResponse(saved) + + +async def _index(request: Request) -> Response: + return HTMLResponse(_PAGE) + + +def register(mcp) -> None: + """Enregistre les routes web sur le serveur FastMCP.""" + mcp.custom_route("/", methods=["GET"], include_in_schema=False)(_index) + mcp.custom_route("/api/config", methods=["GET"], include_in_schema=False)(_config) + mcp.custom_route("/api/excludes", methods=["PUT"], include_in_schema=False)(_put_excludes) + mcp.custom_route("/api/prefs", methods=["PUT"], include_in_schema=False)(_put_prefs) + + +_PAGE = """ + +
+ + +Toute recette contenant l'un de ces termes (nom d'ingrédient ou allergène, sans accent ni casse) est refusée à la sélection.
+ +Mots-clés cherchés dans le nom, le titre et les ingrédients. « Aimé » remonte la recette dans les propositions, « pas aimé » la fait descendre (sans l'exclure).
+