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é
144 lines
5.6 KiB
Python
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()
|