É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:
@@ -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}
|
||||
|
||||
@@ -13,9 +13,11 @@ Pattern persistant repris d'`Automood/scraper.py` (`launch_persistent_context`).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
@@ -24,6 +26,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
SESSION_DIR = ROOT / ".session"
|
||||
PROFILE_DIR = SESSION_DIR / "profile"
|
||||
TOKEN_CACHE = SESSION_DIR / "token.json"
|
||||
STATE_PATH = SESSION_DIR / "storage_state.json"
|
||||
|
||||
BASE_URL = "https://www.hellofresh.fr"
|
||||
# Page qui déclenche des appels gateway authentifiés (menu de la semaine).
|
||||
@@ -38,6 +41,16 @@ def _headless() -> bool:
|
||||
return os.environ.get("ANTICOCO_HEADLESS", "1") not in ("0", "false", "False", "")
|
||||
|
||||
|
||||
def _channel() -> str | None:
|
||||
"""Canal navigateur : 'chrome' en local (évite les blocages du Chromium bundlé),
|
||||
vide/None sur le homelab headless (image Docker = chromium Playwright).
|
||||
|
||||
Défini par ANTICOCO_BROWSER_CHANNEL ('chrome', 'msedge', ou '' pour chromium).
|
||||
"""
|
||||
ch = os.environ.get("ANTICOCO_BROWSER_CHANNEL", "")
|
||||
return ch or None
|
||||
|
||||
|
||||
def _is_gateway_request(url: str) -> bool:
|
||||
return ("/gw/" in url or url.startswith("https://gw.")) and "hellofresh" in url
|
||||
|
||||
@@ -75,12 +88,16 @@ def _auto_login(page) -> bool:
|
||||
|
||||
|
||||
def _open_context(pw):
|
||||
return pw.chromium.launch_persistent_context(
|
||||
kwargs = dict(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=_headless(),
|
||||
locale="fr-FR",
|
||||
viewport={"width": 1280, "height": 900},
|
||||
)
|
||||
channel = _channel()
|
||||
if channel:
|
||||
kwargs["channel"] = channel
|
||||
return pw.chromium.launch_persistent_context(**kwargs)
|
||||
|
||||
|
||||
def ensure_logged_in() -> bool:
|
||||
@@ -185,16 +202,52 @@ def _read_token_cache() -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_token(force: bool = False) -> str:
|
||||
"""Renvoie un bearer token valide.
|
||||
def _jwt_exp(token: str) -> float:
|
||||
"""Renvoie le timestamp d'expiration d'un JWT (0 si indéterminable)."""
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4) # padding base64url
|
||||
data = json.loads(base64.urlsafe_b64decode(payload))
|
||||
return float(data.get("exp", 0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
Si un token en cache est encore frais, on l'utilise sans ouvrir de navigateur.
|
||||
Sinon on s'assure d'être connecté puis on en capture un neuf.
|
||||
|
||||
def token_from_storage_state() -> str | None:
|
||||
"""Extrait l'access_token du cookie `apiV2Auth` de .session/storage_state.json.
|
||||
|
||||
Permet de s'authentifier SANS navigateur (utile en headless sur le homelab),
|
||||
tant que la session synchronisée n'a pas expiré. Renvoie None si absent/expiré.
|
||||
"""
|
||||
if not STATE_PATH.exists():
|
||||
return None
|
||||
try:
|
||||
state = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||
for c in state.get("cookies", []):
|
||||
if c.get("name") == "apiV2Auth":
|
||||
data = json.loads(urllib.parse.unquote(c["value"]))
|
||||
tok = data.get("access_token")
|
||||
if tok and _jwt_exp(tok) > time.time() + 60:
|
||||
return tok
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_token(force: bool = False) -> str:
|
||||
"""Renvoie un bearer token valide, par ordre de préférence :
|
||||
|
||||
1. token en cache encore frais ;
|
||||
2. cookie `apiV2Auth` de la session synchronisée (sans navigateur) ;
|
||||
3. capture via navigateur (login si besoin) — impossible en headless si session morte.
|
||||
"""
|
||||
if not force:
|
||||
cached = _read_token_cache()
|
||||
if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S:
|
||||
return cached["token"]
|
||||
tok = token_from_storage_state()
|
||||
if tok:
|
||||
return tok
|
||||
ensure_logged_in()
|
||||
return capture_token(force=force)["token"]
|
||||
|
||||
@@ -205,6 +258,8 @@ def auth_status() -> dict:
|
||||
if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S:
|
||||
age = int(time.time() - cached["captured_at"])
|
||||
return {"logged_in": True, "source": "cache", "token_age_s": age, "gateways": cached.get("gateways", [])}
|
||||
if token_from_storage_state():
|
||||
return {"logged_in": True, "source": "storage_state"}
|
||||
try:
|
||||
ok = ensure_logged_in()
|
||||
return {"logged_in": bool(ok), "source": "browser"}
|
||||
|
||||
@@ -30,6 +30,7 @@ class Recipe:
|
||||
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)
|
||||
@@ -100,6 +101,31 @@ class Recipe:
|
||||
}
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
Reference in New Issue
Block a user