Files
AntiCoco/hellofresh/models.py
jerem 03e281a810 Sélection courante + favoris + images servables (sortie prête Hermes)
Deux outils MCP pour qu'Hermes n'ait plus de scripts à écrire :
- hf_next_delivery() : prochaine box RÉELLEMENT sélectionnée (≈4 recettes,
  pas le menu complet) + date/cutoff ; erreur stricte si introuvable
  (jamais de repli propose). Saute les semaines PAUSED via next_delivery.
- hf_favorites() : recettes favorites du compte. Champ is_favorite ajouté
  partout (hf_get_menu inclus).

Endpoints découverts (probe CDP) :
- sélection : GET /gw/my-deliveries/menu -> meals[].selection.quantity>0
- favoris   : GET /gw/cfs/v2/favorites/recipe -> items[].object_id
(GET /gw/v1/carts/{week} renvoie 404 : pas la lecture de sélection.)

Images : URLs recettes CloudFront (502) réécrites vers
img.hellofresh.com/.../hellofresh_s3/... (hellofresh/images.py),
appliqué dans Recipe.summary() -> profite à tous les outils.

README : procédure de ré-auth CDP clarifiée (refresh tokens rotatifs,
backups inutiles, page /login, profil Chrome dédié).

Outils de re-découverte : tools/probe_selection.py, tools/probe_menu_capture.py
2026-06-18 14:18:40 +02:00

182 lines
6.5 KiB
Python

"""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
import re
from dataclasses import dataclass, field, asdict
from typing import Any
from .images import fix_image_url
_ISO_DURATION = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?", re.IGNORECASE)
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
def _iso_duration_to_minutes(value: str) -> int | None:
"""Convertit une durée ISO 8601 ('PT1H30M', 'PT30M') en minutes entières.
Renvoie None si le format n'est pas reconnu (champ vide ou inattendu),
pour laisser le client distinguer « inconnu » de « 0 min ».
"""
if not value:
return None
m = _ISO_DURATION.fullmatch(value.strip())
if not m or not (m.group(1) or m.group(2)):
return None
hours = int(m.group(1) or 0)
minutes = int(m.group(2) or 0)
return hours * 60 + minutes
@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 = ""
course_index: int | None = None # index du course dans le menu (sert à l'écriture)
# 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
@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).
Inclut de quoi composer une carte (image, temps, allergènes) côté client
Telegram sans appel supplémentaire — cf. besoin de mise en forme Hermes.
"""
return {
"id": self.id,
"name": self.name,
"headline": self.headline,
"image_url": fix_image_url(self.image_url),
"prep_time": self.prep_time,
"prep_minutes": _iso_duration_to_minutes(self.prep_time),
"allergens": self.allergens,
"contains_excluded": self.contains_excluded,
"matched_excludes": self.matched_excludes,
"score": self.score,
"tags": self.tags,
"is_favorite": self.is_favorite,
}
@dataclass
class Delivery:
"""Semaine de livraison de l'abonnement (issue de /deliveries)."""
week: str
delivery_date: str = ""
cutoff_date: str = ""
status: str = ""
editable: bool = False
@classmethod
def from_api(cls, raw: dict[str, Any]) -> "Delivery":
actionable = bool(_first(raw, "actionable", "isEditable", default=False))
status = str(_first(raw, "status", default=""))
return cls(
week=str(_first(raw, "id", "week", "handle", default="")),
delivery_date=str(_first(raw, "deliveryDate", "date", default="")),
cutoff_date=str(_first(raw, "cutoffDate", "cutoff", default="")),
status=status,
editable=actionable and status.upper() not in ("SKIPPED", "DELIVERED", "CUTOFF"),
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@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