É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:
132
tools/attach_capture.py
Normal file
132
tools/attach_capture.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Attache Playwright à TON Chrome (via CDP) au lieu de lancer un navigateur piloté.
|
||||
|
||||
Pourquoi : un navigateur lancé par Playwright porte des drapeaux d'automatisation que
|
||||
HelloFresh peut rejeter au login. Ici, c'est TOI qui lances Chrome (session normale, le
|
||||
login marche), et on s'y attache en lecture pour :
|
||||
- capturer le trafic gateway (dont l'appel d'écriture de sélection de recettes) ;
|
||||
- exporter ta session (cookies) -> .session/storage_state.json, réutilisable en headless.
|
||||
|
||||
Prérequis — lance Chrome avec le port de debug et un PROFIL DÉDIÉ (Chrome 149 interdit le
|
||||
debug sur le profil par défaut). Ta fenêtre Chrome habituelle peut rester ouverte :
|
||||
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\
|
||||
--remote-debugging-port=9222 --user-data-dir="$HOME/.hf-chrome-debug" \\
|
||||
https://www.hellofresh.fr/my-account/deliveries/menu
|
||||
|
||||
Puis, dans cette fenêtre : connecte-toi (email + mot de passe) et change une recette.
|
||||
|
||||
Usage :
|
||||
python tools/attach_capture.py # attend ~5 min puis sauvegarde
|
||||
ANTICOCO_DISCOVER_WAIT=600 python tools/attach_capture.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from playwright.sync_api import sync_playwright # noqa: E402
|
||||
from hellofresh import auth # noqa: E402
|
||||
|
||||
CDP_URL = os.environ.get("ANTICOCO_CDP_URL", "http://localhost:9222")
|
||||
LOG_PATH = auth.SESSION_DIR / "discovery_log_cdp.json"
|
||||
STATE_PATH = auth.SESSION_DIR / "storage_state.json"
|
||||
DISCOVERED = ROOT / "config" / "endpoints_discovered.json"
|
||||
|
||||
|
||||
def _connect(pw, timeout_s: int = 60):
|
||||
"""Connexion CDP avec attente que Chrome (port debug) soit disponible."""
|
||||
deadline = time.time() + timeout_s
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
return pw.chromium.connect_over_cdp(CDP_URL)
|
||||
except Exception as e: # Chrome pas encore prêt
|
||||
last = e
|
||||
print(f" ...en attente de Chrome sur {CDP_URL}")
|
||||
time.sleep(3)
|
||||
raise RuntimeError(f"Impossible de se connecter à {CDP_URL} : {last}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
auth.SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
requests_seen: list[dict] = []
|
||||
|
||||
def on_request(req):
|
||||
if not auth._is_gateway_request(req.url):
|
||||
return
|
||||
entry = {"method": req.method, "url": req.url,
|
||||
"has_auth": bool(req.headers.get("authorization"))}
|
||||
if req.method in ("POST", "PUT", "PATCH"):
|
||||
try:
|
||||
entry["post_data"] = req.post_data
|
||||
except Exception:
|
||||
entry["post_data"] = None
|
||||
requests_seen.append(entry)
|
||||
print(f" [{req.method}] {req.url}")
|
||||
|
||||
with sync_playwright() as pw:
|
||||
print(f"Connexion à Chrome via {CDP_URL} ...")
|
||||
browser = _connect(pw)
|
||||
contexts = browser.contexts
|
||||
print(f"Connecté. {len(contexts)} contexte(s).")
|
||||
|
||||
def wire_page(page):
|
||||
try:
|
||||
page.on("request", on_request)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for ctx in contexts:
|
||||
for p in ctx.pages:
|
||||
wire_page(p)
|
||||
ctx.on("page", wire_page)
|
||||
|
||||
wait_s = int(os.environ.get("ANTICOCO_DISCOVER_WAIT", "300"))
|
||||
print(f">>> Connecte-toi et change une recette. Capture {wait_s}s max "
|
||||
"(Ctrl-C pour finir plus tôt).")
|
||||
try:
|
||||
waited = 0
|
||||
while waited < wait_s and browser.is_connected():
|
||||
time.sleep(2)
|
||||
waited += 2
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
# Export de la session (cookies) tant que la connexion tient.
|
||||
try:
|
||||
if browser.contexts:
|
||||
browser.contexts[0].storage_state(path=str(STATE_PATH))
|
||||
print(f"Session exportée -> {STATE_PATH}")
|
||||
except Exception as e:
|
||||
print("Export storage_state impossible:", e)
|
||||
|
||||
_save_report(requests_seen)
|
||||
|
||||
|
||||
def _save_report(requests_seen: list[dict]) -> None:
|
||||
LOG_PATH.write_text(json.dumps(requests_seen, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"\n{len(requests_seen)} requêtes gateway -> {LOG_PATH}")
|
||||
|
||||
noise = ("otlp/traces", "/login", "auth/email", "translations", "/bot/")
|
||||
writes = [r for r in requests_seen
|
||||
if r["method"] in ("POST", "PUT", "PATCH")
|
||||
and not any(n in r["url"] for n in noise)]
|
||||
skeleton = {
|
||||
"_comment": "Brouillon issu de attach_capture (ne remplace pas config/endpoints.json).",
|
||||
"set_selection_candidates": [f"[{r['method']}] {r['url']}" for r in writes],
|
||||
}
|
||||
DISCOVERED.write_text(json.dumps(skeleton, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Candidats d'écriture ({len(writes)}) -> {DISCOVERED}")
|
||||
for r in writes:
|
||||
print(f" [{r['method']}] {r['url']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -43,12 +43,17 @@ def main() -> None:
|
||||
requests_seen: list[dict] = []
|
||||
|
||||
with sync_playwright() as pw:
|
||||
ctx = pw.chromium.launch_persistent_context(
|
||||
kwargs = dict(
|
||||
user_data_dir=str(auth.PROFILE_DIR),
|
||||
headless=False, # toujours visible : c'est une étape interactive
|
||||
locale="fr-FR",
|
||||
viewport={"width": 1280, "height": 900},
|
||||
)
|
||||
channel = auth._channel()
|
||||
if channel:
|
||||
kwargs["channel"] = channel
|
||||
print(f"Navigateur : {channel}")
|
||||
ctx = pw.chromium.launch_persistent_context(**kwargs)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
|
||||
def on_request(req):
|
||||
@@ -67,49 +72,71 @@ def main() -> None:
|
||||
requests_seen.append(entry)
|
||||
print(f" [{req.method}] {req.url}")
|
||||
|
||||
closed = {"v": False}
|
||||
ctx.on("close", lambda *_: closed.update(v=True))
|
||||
page.on("close", lambda *_: closed.update(v=True))
|
||||
page.on("request", on_request)
|
||||
|
||||
print("Ouvre le menu de la semaine, change une recette si tu veux capturer l'écriture.")
|
||||
page.goto(auth.BASE_URL + "/my-account", wait_until="domcontentloaded", timeout=30000)
|
||||
try:
|
||||
page.goto(auth.BASE_URL + "/my-account", wait_until="domcontentloaded", timeout=30000)
|
||||
except Exception as e:
|
||||
print("goto initial échoué (on continue quand même):", e)
|
||||
|
||||
# Si on a un vrai terminal : on attend Entrée. Sinon (lancé en arrière-plan,
|
||||
# sans TTY) : on attend une durée fixe pour laisser le temps de se connecter
|
||||
# et de naviguer, tout en continuant à logger les requêtes.
|
||||
if sys.stdin and sys.stdin.isatty():
|
||||
# On capture jusqu'à : Entrée (si TTY), fermeture de la fenêtre, ou fin du délai.
|
||||
# Le rapport est TOUJOURS écrit (finally) même si la fenêtre est fermée en cours.
|
||||
try:
|
||||
if sys.stdin and sys.stdin.isatty():
|
||||
try:
|
||||
input("\n>>> Quand tu as fini, appuie sur Entrée (ou ferme la fenêtre)...\n")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
pass
|
||||
else:
|
||||
wait_s = int(os.environ.get("ANTICOCO_DISCOVER_WAIT", "240"))
|
||||
print(f"\n>>> Capture pendant {wait_s}s max. Connecte-toi (email + mot de passe, "
|
||||
"PAS Google), ouvre le menu, change une recette. Ferme la fenêtre quand fini.")
|
||||
waited = 0
|
||||
while waited < wait_s and not closed["v"]:
|
||||
try:
|
||||
page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
break # page/contexte fermé
|
||||
waited += 2
|
||||
finally:
|
||||
_save_report(requests_seen)
|
||||
try:
|
||||
input("\n>>> Quand tu as fini de naviguer, appuie sur Entrée pour générer le rapport...\n")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
if not closed["v"]:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
wait_s = int(os.environ.get("ANTICOCO_DISCOVER_WAIT", "240"))
|
||||
print(f"\n>>> Pas de terminal interactif : capture pendant {wait_s}s. "
|
||||
"Connecte-toi et navigue dans la fenêtre ouverte...")
|
||||
page.wait_for_timeout(wait_s * 1000)
|
||||
|
||||
ctx.close()
|
||||
|
||||
def _save_report(requests_seen: list[dict]) -> None:
|
||||
"""Écrit le log complet + un squelette d'endpoints découverts (sans écraser le vrai)."""
|
||||
LOG_PATH.write_text(json.dumps(requests_seen, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"\n{len(requests_seen)} requêtes gateway enregistrées dans {LOG_PATH}")
|
||||
|
||||
# Heuristiques pour pré-remplir le squelette d'endpoints.
|
||||
def find(method: str, *needles: str) -> str:
|
||||
for r in requests_seen:
|
||||
if r["method"] != method:
|
||||
continue
|
||||
if all(n in r["url"].lower() for n in needles):
|
||||
if r["method"] == method and all(n in r["url"].lower() for n in needles):
|
||||
return r["url"]
|
||||
return ""
|
||||
|
||||
noise = ("otlp/traces", "/login", "auth/email", "translations")
|
||||
writes = [r for r in requests_seen
|
||||
if r["method"] in ("POST", "PUT", "PATCH")
|
||||
and not any(n in r["url"] for n in noise)]
|
||||
|
||||
discovered = ENDPOINTS_PATH.parent / "endpoints_discovered.json"
|
||||
skeleton = {
|
||||
"_comment": "Endpoints HelloFresh confirmés via discovery. Compléter/valider à la main. Utiliser {week} comme placeholder pour le handle de semaine.",
|
||||
"base": "",
|
||||
"weeks": find("GET", "subscription") or find("GET", "deliveries") or "",
|
||||
"menu": find("GET", "menu") or find("GET", "courses") or "",
|
||||
"set_selection": find("PUT", "menu") or find("POST", "menu") or "",
|
||||
"_comment": "Brouillon issu de la dernière discovery (ne remplace pas config/endpoints.json).",
|
||||
"menu": find("GET", "menus-service") or find("GET", "menu"),
|
||||
"weeks": find("GET", "deliveries") or find("GET", "subscriptions"),
|
||||
"set_selection_candidates": [f"[{r['method']}] {r['url']}" for r in writes],
|
||||
}
|
||||
ENDPOINTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
ENDPOINTS_PATH.write_text(json.dumps(skeleton, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Squelette d'endpoints écrit dans {ENDPOINTS_PATH} — à vérifier avant usage.")
|
||||
discovered.parent.mkdir(parents=True, exist_ok=True)
|
||||
discovered.write_text(json.dumps(skeleton, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Candidats d'écriture ({len(writes)}) -> {discovered}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user