É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:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user