AntiCoco: serveur MCP HelloFresh sans noix de coco
- Auth Playwright (login local, session persistee, capture du bearer token) - Client httpx vers l'API interne (endpoints via discover_api.py) - Filtre d'exclusion insensible aux accents (coco & co) - Serveur FastMCP (streamable-http) + outils hf_* - Docker + compose pour deploiement homelab
This commit is contained in:
124
hellofresh/models.py
Normal file
124
hellofresh/models.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Modèles de données HelloFresh, indépendants de la forme exacte de l'API interne.
|
||||
|
||||
Les dataclasses sont volontairement tolérantes : `Recipe.from_api` mappe au mieux les
|
||||
champs des réponses gateway (qui varient selon les endpoints découverts) vers une forme
|
||||
stable utilisée par le filtre et le serveur MCP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _first(d: dict, *keys, default=None):
|
||||
"""Renvoie la première clé présente et non vide parmi `keys`."""
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v not in (None, "", [], {}):
|
||||
return v
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recipe:
|
||||
id: str
|
||||
name: str
|
||||
headline: str = ""
|
||||
ingredients: list[str] = field(default_factory=list)
|
||||
allergens: list[str] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
image_url: str = ""
|
||||
prep_time: str = ""
|
||||
# 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
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, raw: dict[str, Any]) -> "Recipe":
|
||||
"""Construit une Recipe depuis un objet recette brut de l'API gateway.
|
||||
|
||||
Les noms de champs HelloFresh varient ; on tente plusieurs alias. À ajuster
|
||||
une fois la forme réelle confirmée via discover_api.py.
|
||||
"""
|
||||
ingredients = []
|
||||
for ing in _first(raw, "ingredients", default=[]) or []:
|
||||
if isinstance(ing, dict):
|
||||
name = _first(ing, "name", "label", "title")
|
||||
if name:
|
||||
ingredients.append(str(name))
|
||||
elif isinstance(ing, str):
|
||||
ingredients.append(ing)
|
||||
|
||||
allergens = []
|
||||
for al in _first(raw, "allergens", default=[]) or []:
|
||||
if isinstance(al, dict):
|
||||
name = _first(al, "name", "label", "title")
|
||||
if name:
|
||||
allergens.append(str(name))
|
||||
elif isinstance(al, str):
|
||||
allergens.append(al)
|
||||
|
||||
tags = []
|
||||
for tg in _first(raw, "tags", "labels", default=[]) or []:
|
||||
if isinstance(tg, dict):
|
||||
name = _first(tg, "name", "label", "text")
|
||||
if name:
|
||||
tags.append(str(name))
|
||||
elif isinstance(tg, str):
|
||||
tags.append(tg)
|
||||
|
||||
image = _first(raw, "imageLink", "image", "imageUrl", "cardLink", default="")
|
||||
if isinstance(image, dict):
|
||||
image = _first(image, "url", "link", default="")
|
||||
|
||||
return cls(
|
||||
id=str(_first(raw, "id", "uuid", "recipeId", default="")),
|
||||
name=str(_first(raw, "name", "title", default="(sans nom)")),
|
||||
headline=str(_first(raw, "headline", "subtitle", "description", default="")),
|
||||
ingredients=ingredients,
|
||||
allergens=allergens,
|
||||
tags=tags,
|
||||
image_url=str(image or ""),
|
||||
prep_time=str(_first(raw, "prepTime", "totalTime", "time", default="")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
"""Version compacte pour les réponses MCP (moins de tokens)."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"headline": self.headline,
|
||||
"contains_excluded": self.contains_excluded,
|
||||
"matched_excludes": self.matched_excludes,
|
||||
"score": self.score,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Week:
|
||||
id: str # handle de semaine, ex. "2026-W25"
|
||||
delivery_date: str = ""
|
||||
editable: bool = False
|
||||
max_selectable: int = 0
|
||||
recipes: list[Recipe] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, raw: dict[str, Any], recipes: list[Recipe] | None = None) -> "Week":
|
||||
return cls(
|
||||
id=str(_first(raw, "id", "week", "handle", "yearWeek", default="")),
|
||||
delivery_date=str(_first(raw, "deliveryDate", "date", default="")),
|
||||
editable=bool(_first(raw, "editable", "isEditable", "menuEditable", default=False)),
|
||||
max_selectable=int(_first(raw, "maxSelectable", "numberOfSelections", default=0) or 0),
|
||||
recipes=recipes or [],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["recipes"] = [r.summary() for r in self.recipes]
|
||||
return d
|
||||
Reference in New Issue
Block a user