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
This commit is contained in:
@@ -255,6 +255,69 @@ class HelloFreshClient:
|
||||
recipes.extend(Recipe.from_api(r) for r in raw)
|
||||
return recipes
|
||||
|
||||
def get_current_selection(self, week: str) -> list[Recipe]:
|
||||
"""Recettes RÉELLEMENT dans la box de la semaine (sélection courante, ≠ menu complet).
|
||||
|
||||
Source : `GET my-deliveries/menu` (BFF) → `meals[*]` ; un repas est sélectionné quand
|
||||
`selection.quantity > 0`. On en extrait les ids puis on récupère les détails complets
|
||||
(ingrédients/allergènes) comme pour le menu. Lève une exception si rien n'est
|
||||
sélectionné (box vide / non finalisée) — pas de repli vers une proposition.
|
||||
"""
|
||||
ep = self._ep.get("current_selection")
|
||||
if not ep:
|
||||
raise EndpointsNotConfigured("Endpoint 'current_selection' manquant.")
|
||||
sub = self._subscription_info()
|
||||
params = {
|
||||
"country": self._country,
|
||||
"locale": self._locale,
|
||||
"subscription": sub["sub_id"],
|
||||
"product-sku": sub["sku"],
|
||||
"week": week,
|
||||
}
|
||||
data = self._request("GET", ep, params=params).json()
|
||||
meals = data.get("meals", []) if isinstance(data, dict) else []
|
||||
idx_by_id: dict[str, int | None] = {}
|
||||
for m in meals:
|
||||
sel = m.get("selection")
|
||||
qty = sel.get("quantity") if isinstance(sel, dict) else None
|
||||
if qty and qty > 0:
|
||||
rid = (m.get("recipe") or {}).get("id")
|
||||
if rid:
|
||||
idx_by_id[str(rid)] = m.get("index")
|
||||
if not idx_by_id:
|
||||
raise RuntimeError(
|
||||
f"Aucune recette sélectionnée pour {week} (box vide ou non finalisée ?)."
|
||||
)
|
||||
recipes = self._fetch_details(list(idx_by_id))
|
||||
if not recipes:
|
||||
raise RuntimeError(f"Détails de la sélection {week} introuvables.")
|
||||
for r in recipes:
|
||||
r.course_index = idx_by_id.get(r.id)
|
||||
return recipes
|
||||
|
||||
def favorite_ids(self) -> set[str]:
|
||||
"""Ids des recettes favorites du compte (best-effort : set() si indispo)."""
|
||||
ep = self._ep.get("favorites")
|
||||
if not ep:
|
||||
return set()
|
||||
try:
|
||||
data = self._request("GET", ep, params={
|
||||
"country": self._country, "locale": self._locale, "ids": ""}).json()
|
||||
items = data.get("items", []) if isinstance(data, dict) else (data or [])
|
||||
return {str(it.get("object_id")) for it in items if it.get("object_id")}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def get_favorites(self) -> list[Recipe]:
|
||||
"""Recettes favorites du compte, complètes. Liste vide si aucun favori."""
|
||||
ids = self.favorite_ids()
|
||||
if not ids:
|
||||
return []
|
||||
recipes = self._fetch_details(list(ids))
|
||||
for r in recipes:
|
||||
r.is_favorite = True
|
||||
return recipes
|
||||
|
||||
def selection_indices(self, week: str, recipe_ids: list[str]) -> tuple[list[int], list[str]]:
|
||||
"""Mappe des ids de recette vers leurs index de course pour la semaine.
|
||||
|
||||
|
||||
73
hellofresh/images.py
Normal file
73
hellofresh/images.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Normalisation des URLs d'images recettes pour un rendu direct (Telegram, etc.).
|
||||
|
||||
L'API recettes renvoie des URLs CloudFront du type
|
||||
https://d3hvwccx09j84u.cloudfront.net/0,0/image/HF_....jpg
|
||||
qui répondent en 502 au téléchargement direct (hotlink protégé). Le CDN servable est
|
||||
https://img.hellofresh.com/<transfo>/hellofresh_s3/image/HF_....jpg
|
||||
où le segment `0,0` (largeur,hauteur) de CloudFront est remplacé par une transformation
|
||||
Cloudinary (`f_auto,fl_lossy,q_auto,w_1200`) et le chemin préfixé par `hellofresh_s3`.
|
||||
|
||||
Confirmé par probe (2026-06) : la variante img.hellofresh.com/.../hellofresh_s3/… renvoie 206 +
|
||||
image/jpeg, alors que l'URL CloudFront brute renvoie 502. Hôte/transfo configurables via
|
||||
config/endpoints.json (`image_cdn_host`, `image_cdn_transform`, `image_cdn_source_hosts`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from . import auth
|
||||
|
||||
_ENDPOINTS_PATH = auth.ROOT / "config" / "endpoints.json"
|
||||
# Segment de dimensionnement CloudFront en tête de chemin, ex. "/0,0/" ou "/200,200/".
|
||||
_SIZE_SEG = re.compile(r"^/\d+,\d+(?=/)")
|
||||
|
||||
_DEFAULTS = {
|
||||
"image_cdn_host": "img.hellofresh.com",
|
||||
"image_cdn_transform": "f_auto,fl_lossy,q_auto,w_1200",
|
||||
"image_cdn_source_hosts": ["d3hvwccx09j84u.cloudfront.net"],
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _cfg() -> tuple[str, str, tuple[str, ...]]:
|
||||
"""(host cible, transfo, hôtes source) lus depuis endpoints.json, avec défauts."""
|
||||
data: dict = {}
|
||||
try:
|
||||
data = json.loads(_ENDPOINTS_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
host = str(data.get("image_cdn_host") or _DEFAULTS["image_cdn_host"])
|
||||
transform = str(data.get("image_cdn_transform") or _DEFAULTS["image_cdn_transform"])
|
||||
sources = tuple(data.get("image_cdn_source_hosts") or _DEFAULTS["image_cdn_source_hosts"])
|
||||
return host, transform, sources
|
||||
|
||||
|
||||
def fix_image_url(url: str) -> str:
|
||||
"""Réécrit une URL d'image CloudFront HelloFresh vers le CDN servable.
|
||||
|
||||
- URL vide → "".
|
||||
- Déjà sur un hôte hellofresh.com → renvoyée telle quelle.
|
||||
- Hôte CloudFront connu (ou *.cloudfront.net) → host remplacé, segment de taille retiré,
|
||||
chemin préfixé par `hellofresh_s3` et la transformation insérée.
|
||||
- Tout autre hôte → renvoyée telle quelle (on ne casse rien).
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
host, transform, sources = _cfg()
|
||||
parts = urlsplit(url)
|
||||
netloc = parts.netloc.lower()
|
||||
|
||||
if "hellofresh.com" in netloc: # déjà servable (img/media.hellofresh.com)
|
||||
return url
|
||||
if netloc not in sources and not netloc.endswith(".cloudfront.net"):
|
||||
return url
|
||||
|
||||
path = _SIZE_SEG.sub("", parts.path) # "/0,0/image/X.jpg" -> "/image/X.jpg"
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
rest = path if path.startswith("/hellofresh_s3/") else "/hellofresh_s3" + path
|
||||
return f"https://{host}/{transform}{rest}"
|
||||
@@ -11,6 +11,8 @@ 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)
|
||||
|
||||
|
||||
@@ -54,6 +56,7 @@ class Recipe:
|
||||
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":
|
||||
@@ -117,7 +120,7 @@ class Recipe:
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"headline": self.headline,
|
||||
"image_url": self.image_url,
|
||||
"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,
|
||||
@@ -125,6 +128,7 @@ class Recipe:
|
||||
"matched_excludes": self.matched_excludes,
|
||||
"score": self.score,
|
||||
"tags": self.tags,
|
||||
"is_favorite": self.is_favorite,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user