Files
AntiCoco/tools/discover_api.py
jerem 051ecb50d8 É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é
2026-06-15 22:57:36 +02:00

144 lines
5.6 KiB
Python

"""ÉTAPE 0 — Découverte de l'API interne HelloFresh.
Lance un navigateur visible, te laisse te connecter et naviguer (menu de la semaine,
sélection de recettes), puis enregistre TOUTES les requêtes vers le gateway pour en
déduire les 3 endpoints utiles :
1. abonnement + semaines éditables
2. menu d'une semaine (recettes / ingrédients / allergènes)
3. enregistrement de la sélection de recettes
Usage :
ANTICOCO_HEADLESS=0 python tools/discover_api.py
Pendant que la fenêtre est ouverte :
- connecte-toi,
- ouvre le menu de la semaine,
- (optionnel) change une recette pour capturer l'appel d'écriture,
puis reviens dans le terminal et appuie sur Entrée pour écrire le rapport.
Sortie :
- .session/discovery_log.json : toutes les requêtes gateway observées (debug complet)
- config/endpoints.json : squelette pré-rempli à compléter/valider à la main
"""
from __future__ import annotations
import json
import os
import sys
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
LOG_PATH = auth.SESSION_DIR / "discovery_log.json"
ENDPOINTS_PATH = ROOT / "config" / "endpoints.json"
def main() -> None:
auth.SESSION_DIR.mkdir(parents=True, exist_ok=True)
requests_seen: list[dict] = []
with sync_playwright() as pw:
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):
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}")
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.")
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)
# 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:
if not closed["v"]:
ctx.close()
except Exception:
pass
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}")
def find(method: str, *needles: str) -> str:
for r in requests_seen:
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": "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],
}
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__":
main()