- 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
151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
"""Client httpx vers l'API interne HelloFresh.
|
|
|
|
Les URLs réelles ne sont pas codées en dur : elles viennent de `config/endpoints.json`,
|
|
généré/validé via `tools/discover_api.py`. Tant que ce fichier n'est pas rempli, les
|
|
appels lèvent une erreur explicite.
|
|
|
|
Le token bearer est fourni par `auth.get_token()`. Les réponses (forme variable) sont
|
|
mappées vers `models.Recipe` / `models.Week` de façon tolérante.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from . import auth
|
|
from .models import Recipe, Week, _first
|
|
|
|
ENDPOINTS_PATH = auth.ROOT / "config" / "endpoints.json"
|
|
|
|
DEFAULT_HEADERS = {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Origin": auth.BASE_URL,
|
|
"Referer": auth.BASE_URL + "/",
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|
),
|
|
}
|
|
|
|
|
|
class EndpointsNotConfigured(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _load_endpoints() -> dict:
|
|
if not ENDPOINTS_PATH.exists():
|
|
raise EndpointsNotConfigured(
|
|
f"{ENDPOINTS_PATH} absent. Lancer `python tools/discover_api.py` pour le générer."
|
|
)
|
|
data = json.loads(ENDPOINTS_PATH.read_text(encoding="utf-8"))
|
|
missing = [k for k in ("weeks", "menu", "set_selection") if not data.get(k)]
|
|
if missing:
|
|
raise EndpointsNotConfigured(
|
|
f"Endpoints manquants dans {ENDPOINTS_PATH}: {missing}. "
|
|
"Compléter via tools/discover_api.py."
|
|
)
|
|
return data
|
|
|
|
|
|
class HelloFreshClient:
|
|
def __init__(self, token: str | None = None):
|
|
self._endpoints = _load_endpoints()
|
|
self._token = token or auth.get_token()
|
|
self._client = httpx.Client(
|
|
headers={**DEFAULT_HEADERS, "Authorization": f"Bearer {self._token}"},
|
|
timeout=30.0,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self._client.close()
|
|
|
|
def __enter__(self) -> "HelloFreshClient":
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
self.close()
|
|
|
|
# --- requêtes bas niveau avec re-auth transparent sur 401 ---------------
|
|
def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
resp = self._client.request(method, url, **kwargs)
|
|
if resp.status_code == 401:
|
|
# Token expiré → on en capture un neuf et on rejoue une fois.
|
|
self._token = auth.get_token(force=True)
|
|
self._client.headers["Authorization"] = f"Bearer {self._token}"
|
|
resp = self._client.request(method, url, **kwargs)
|
|
resp.raise_for_status()
|
|
return resp
|
|
|
|
# --- API métier ---------------------------------------------------------
|
|
def get_editable_weeks(self) -> list[Week]:
|
|
"""Liste les semaines de l'abonnement encore modifiables."""
|
|
resp = self._request("GET", self._endpoints["weeks"])
|
|
data = resp.json()
|
|
raw_weeks = _extract_list(data, "weeks", "deliveries", "items", "data")
|
|
weeks = [Week.from_api(w) for w in raw_weeks]
|
|
editable = [w for w in weeks if w.editable] or weeks
|
|
return editable
|
|
|
|
def get_menu(self, week: str) -> list[Recipe]:
|
|
"""Recettes proposées pour une semaine donnée."""
|
|
url = self._endpoints["menu"].replace("{week}", str(week))
|
|
resp = self._request("GET", url, params=None if "{week}" in self._endpoints["menu"] else {"week": week})
|
|
data = resp.json()
|
|
raw_recipes = _extract_recipes(data)
|
|
return [Recipe.from_api(r) for r in raw_recipes]
|
|
|
|
def set_selection(self, week: str, recipe_ids: list[str]) -> dict[str, Any]:
|
|
"""Enregistre la sélection de recettes pour une semaine (écriture).
|
|
|
|
N'est appelé qu'après confirmation côté serveur MCP. La forme du payload
|
|
dépend de l'endpoint découvert ; on envoie une structure courante, à ajuster
|
|
selon discover_api.py si nécessaire.
|
|
"""
|
|
url = self._endpoints["set_selection"].replace("{week}", str(week))
|
|
method = self._endpoints.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)
|
|
try:
|
|
return resp.json()
|
|
except Exception:
|
|
return {"status": resp.status_code, "ok": True}
|
|
|
|
|
|
def _extract_list(data: Any, *keys: str) -> list[dict]:
|
|
if isinstance(data, list):
|
|
return data
|
|
if isinstance(data, dict):
|
|
found = _first(data, *keys, default=None)
|
|
if isinstance(found, list):
|
|
return found
|
|
# Parfois imbriqué sous "data"/"items"
|
|
for v in data.values():
|
|
if isinstance(v, list):
|
|
return v
|
|
return []
|
|
|
|
|
|
def _extract_recipes(data: Any) -> list[dict]:
|
|
"""Extrait la liste de recettes, qui peut être imbriquée dans des 'courses'."""
|
|
if isinstance(data, dict):
|
|
courses = _first(data, "courses", "modules", "items", default=None)
|
|
if isinstance(courses, list):
|
|
recipes = []
|
|
for c in courses:
|
|
if isinstance(c, dict) and "recipe" in c and isinstance(c["recipe"], dict):
|
|
recipes.append(c["recipe"])
|
|
elif isinstance(c, dict):
|
|
recipes.append(c)
|
|
if recipes:
|
|
return recipes
|
|
return _extract_list(data, "recipes", "items", "data")
|