Écriture de sélection câblée (PUT cart) + auth par cookie storage_state

Découvert via attache CDP au vrai Chrome (contourne le blocage automation) :
- set_selection = PUT /gw/v1/carts/{week}, body {meals:[{index,quantity}], extras:[]}
  sélection par index de course, params (customer/subscription/sku/cutoff) dérivés
  dynamiquement de /subscriptions + /deliveries (aucun id en dur)
- Recipe.course_index conservé depuis le menu pour le mapping id->index
- get_editable_weeks via /deliveries (modèle Delivery: cutoff, status, editable)
- Token lu depuis le cookie apiV2Auth (storage_state) -> auth sans navigateur, headless OK
- hf_confirm_selection: garde-fou coco + dry_run; tool attach_capture.py ajouté
- Dry-run validé: requête identique à l'appel réel capturé
This commit is contained in:
2026-06-15 22:57:36 +02:00
parent 30b950ec41
commit 051ecb50d8
9 changed files with 388 additions and 69 deletions

View File

@@ -20,7 +20,7 @@ from typing import Any
import httpx
from . import auth
from .models import Recipe, Week, _first
from .models import Delivery, Recipe, _first
ENDPOINTS_PATH = auth.ROOT / "config" / "endpoints.json"
BATCH_SIZE = 80 # nb max d'ids par appel détails (le menu en compte ~85)
@@ -112,8 +112,7 @@ class HelloFreshClient:
return resp
# --- API métier ---------------------------------------------------------
def get_editable_weeks(self) -> list[Week]:
"""Semaines de livraison de l'abonnement (vide si pas d'abonnement actif)."""
def _deliveries(self) -> list[Delivery]:
params = {
"country": self._country,
"locale": self._locale,
@@ -122,31 +121,64 @@ class HelloFreshClient:
}
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]
return [Delivery.from_api(w) for w in items]
def get_menu(self, week: str | None = None) -> list[Recipe]:
"""Recettes proposées pour une semaine (défaut : semaine courante), complètes.
def get_editable_weeks(self) -> list[Delivery]:
"""Semaines de livraison encore modifiables (vide si pas d'abonnement actif)."""
return [d for d in self._deliveries() if d.editable]
Deux appels : le menu (ids) puis le batch de détails (ingrédients/allergènes).
"""
week = week or current_week()
menu_params = {
def _subscription_info(self) -> dict[str, Any]:
"""Récupère {sub_id, customer_id, sku} depuis l'abonnement (pas de valeurs en dur)."""
ep = self._ep.get("subscriptions")
if not ep:
raise EndpointsNotConfigured("Endpoint 'subscriptions' manquant.")
data = self._request("GET", ep, params={"country": self._country}).json()
if isinstance(data, dict) and "id" in data:
sub = data
else:
items = data.get("items", []) if isinstance(data, dict) else (data or [])
if not items:
raise RuntimeError("Aucun abonnement actif sur ce compte.")
sub = items[0]
cust = sub.get("customer") or {}
prod = sub.get("product") or {}
return {"sub_id": str(sub.get("id", "")),
"customer_id": str(cust.get("id", "")),
"sku": str(prod.get("sku", ""))}
def _menu_courses(self, week: str) -> list[dict]:
"""Courses bruts du menu (chacun : index + recipe). Base de l'écriture."""
params = {
"country": self._country,
"locale": self._locale,
"product": self._product,
"weeks": week,
}
menu = self._request("GET", self._ep["menu"], params=menu_params).json()
menu = self._request("GET", self._ep["menu"], params=params).json()
items = menu.get("items", []) if isinstance(menu, dict) else []
if not items:
return items[0].get("courses", []) if items else []
def get_menu(self, week: str | None = None) -> list[Recipe]:
"""Recettes proposées pour une semaine (défaut : courante), complètes.
Deux appels : menu (ids + index de course) puis batch de détails
(ingrédients/allergènes). L'`index` de course est conservé pour l'écriture.
"""
week = week or current_week()
courses = self._menu_courses(week)
if not courses:
return []
courses = items[0].get("courses", [])
id_index: dict[str, int] = {}
ids: list[str] = []
for c in courses:
rid = (c.get("recipe") or {}).get("id")
if rid and rid not in ids:
if rid and rid not in id_index:
id_index[rid] = c.get("index")
ids.append(rid)
return _dedupe_by_name(self._fetch_details(ids))
recipes = self._fetch_details(ids)
for r in recipes:
r.course_index = id_index.get(r.id)
return _dedupe_by_name(recipes)
def _fetch_details(self, ids: list[str]) -> list[Recipe]:
"""Récupère les recettes complètes par batch d'ids."""
@@ -164,22 +196,59 @@ class HelloFreshClient:
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]:
"""ÉCRIT la sélection de recettes pour une semaine (après confirmation).
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.
Endpoint à découvrir sur un compte avec abonnement actif (cf. set_selection vide).
Renvoie (indices_trouvés, ids_inconnus).
"""
url = self._ep.get("set_selection") or ""
id_index = {(c.get("recipe") or {}).get("id"): c.get("index")
for c in self._menu_courses(week)}
indices, unknown = [], []
for rid in recipe_ids:
idx = id_index.get(rid)
if idx is None:
unknown.append(rid)
else:
indices.append(idx)
return indices, unknown
def set_selection(self, week: str, recipe_ids: list[str], dry_run: bool = False) -> dict[str, Any]:
"""ÉCRIT la sélection de recettes pour une semaine (PUT du cart hebdo).
La sélection se fait par `index` de course (mappé depuis les ids de recette).
`dry_run=True` construit la requête sans l'envoyer (pour vérification).
"""
url = (self._ep.get("set_selection") or "").replace("{week}", str(week))
if not url:
raise EndpointsNotConfigured(
"Endpoint 'set_selection' inconnu : à capturer via discover_api.py sur un "
"compte avec box active (modifier une recette pour observer l'appel PUT/POST)."
)
url = url.replace("{week}", str(week))
raise EndpointsNotConfigured("Endpoint 'set_selection' non configuré.")
indices, unknown = self.selection_indices(week, recipe_ids)
if unknown:
raise ValueError(f"Recette(s) absente(s) du menu {week}: {unknown}")
sub = self._subscription_info()
deliv = next((d for d in self._deliveries() if d.week == week), None)
if deliv and not deliv.editable:
raise ValueError(f"Semaine {week} non modifiable (statut {deliv.status}).")
params = {
"customer": sub["customer_id"],
"subscription": sub["sub_id"],
"product-sku": sub["sku"],
"week": week,
"cutoff_time": deliv.cutoff_date if deliv else "",
"update_quantity": "true",
"ignore_addons": "false",
"preference": self._ep.get("selection_preference", "quick"),
}
body = {"meals": [{"index": i, "quantity": 1} for i in indices], "extras": []}
if dry_run:
return {"dry_run": True, "url": url, "params": params, "body": body}
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, params=params, json=body)
try:
return resp.json()
return {"ok": True, "status": resp.status_code, "response": resp.json()}
except Exception:
return {"status": resp.status_code, "ok": True}
return {"ok": True, "status": resp.status_code}