Model/scripts/hyde/elmhurst_session.py
Jun-te Kim 2f0eb49eee Checkpoint: UPRN 10093116543 Elmhurst build + devcontainer VNC/Playwright + perms
- Add SAP-accuracy sample for uprn_10093116543 (epc.json, elmhurst_inputs.md,
  summary/worksheet PDFs)
- Persist hyde viewer stack (xvfb/fluxbox/x11vnc/novnc/websockify) and Playwright
  chromium in the backend devcontainer; forward noVNC 6080
- Broaden .claude/settings.local.json allowlist (display/python/grep/tail)
- In-progress campaign mapper/cert_to_inputs work carried from prior cert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:21:56 +00:00

175 lines
6.5 KiB
Python

"""Elmhurst RdSAP entry-tool browser session (POC, step 1: login + navigate).
The slow part of the `validate-cert-sap-accuracy` loop is the manual step:
a human keys ~50 fields from `elmhurst_inputs.md` into Elmhurst's web RdSAP
entry tool, then exports the Input Summary + SAP Worksheets PDFs. This module
is the foundation for automating that with Playwright.
Design (agreed with the user):
* **Persistent browser session.** We launch a *headed* Chromium against a
persistent profile dir (`.elmhurst-session/`, gitignored). You log in to
Elmhurst once by hand — MFA/captcha included — and every later run reuses
that session, so no credentials live in the repo.
* **Iterative.** This first cut only proves login + session reuse +
navigation. Form-filling and PDF export land on top of `launch_session()`
in later steps (selectors to come from a recorded `playwright codegen`
session, since the Elmhurst DOM is login-gated).
Usage:
# First run — a real browser opens; log in by hand, then press Enter here.
python scripts/hyde/elmhurst_session.py login --url https://<elmhurst-tool-url>
# Later runs — session is reused; this just proves you're still logged in.
python scripts/hyde/elmhurst_session.py open --url https://<elmhurst-tool-url>
python scripts/hyde/elmhurst_session.py status
The tool URL can also come from the ELMHURST_URL env var instead of --url.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Optional
from playwright.sync_api import BrowserContext, Page, sync_playwright
HERE = Path(__file__).parent
SESSION_DIR = HERE / ".elmhurst-session"
DOWNLOADS_DIR = HERE / "elmhurst_downloads"
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s"
)
logger = logging.getLogger("elmhurst")
def _resolve_url(cli_url: Optional[str]) -> str:
"""Tool URL from --url, else ELMHURST_URL env. We never hardcode it: the
Elmhurst entry-tool URL isn't in the repo and may differ per account."""
url = cli_url or os.environ.get("ELMHURST_URL")
if not url:
logger.error(
"No Elmhurst URL given. Pass --url <url> or set ELMHURST_URL. "
"(Open the entry tool in your browser and copy the address.)"
)
sys.exit(2)
return url
@contextmanager
def launch_session(headless: bool = False) -> Generator[BrowserContext, None, None]:
"""Open the persistent Elmhurst profile as a Playwright context.
Headed by default — Elmhurst login needs a human the first time. The
persistent ``user_data_dir`` is what makes the session survive across runs:
cookies and local storage are written into ``SESSION_DIR`` on disk, so a
later launch is already authenticated. ``accept_downloads`` is on so the
later PDF-export step can capture Input Summary / SAP Worksheets.
"""
SESSION_DIR.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=str(SESSION_DIR),
headless=headless,
accept_downloads=True,
viewport={"width": 1400, "height": 1000},
)
try:
yield context
finally:
context.close()
def _active_page(context: BrowserContext, url: Optional[str]) -> Page:
"""Reuse the profile's existing tab (persistent contexts open one) instead
of stacking new blank tabs on every run; navigate it if a URL is given."""
page = context.pages[0] if context.pages else context.new_page()
if url:
logger.info("Navigating to %s", url)
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
return page
def _wait_for_operator(prompt: str) -> None:
"""Block until the human is done in the browser. Headed/interactive only."""
try:
input(f"\n>>> {prompt}\n>>> Press Enter here when done... ")
except EOFError:
# Non-interactive (piped) run: nothing to wait on.
logger.warning("No interactive stdin; not waiting.")
def cmd_login(args: argparse.Namespace) -> int:
"""First-run login: open the tool, let the human authenticate, persist it."""
url = _resolve_url(args.url)
with launch_session(headless=False) as context:
page = _active_page(context, url)
_wait_for_operator(
"Log in to Elmhurst in the opened browser (MFA included). "
"Leave the browser on a logged-in page."
)
logger.info("Final URL: %s", page.url)
logger.info("Session saved to %s", SESSION_DIR)
return 0
def cmd_open(args: argparse.Namespace) -> int:
"""Reuse the saved session and navigate — proves login survived a restart."""
if not _session_exists():
logger.error("No saved session. Run `login` first.")
return 1
url = _resolve_url(args.url)
with launch_session(headless=False) as context:
page = _active_page(context, url)
logger.info("Landed on: %s (title: %r)", page.url, page.title())
_wait_for_operator("Inspect the page. This confirms the session reused.")
return 0
def _session_exists() -> bool:
return SESSION_DIR.exists() and any(SESSION_DIR.iterdir())
def cmd_status(_args: argparse.Namespace) -> int:
if _session_exists():
logger.info("Session present at %s", SESSION_DIR)
else:
logger.info("No session yet. Run `login`.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="elmhurst_session",
description="Elmhurst RdSAP entry-tool session (login + navigate POC).",
)
sub = parser.add_subparsers(dest="command", required=True)
p_login = sub.add_parser("login", help="open browser, log in by hand, save session")
p_login.add_argument("--url", help="Elmhurst tool URL (or set ELMHURST_URL)")
p_login.set_defaults(func=cmd_login)
p_open = sub.add_parser("open", help="reuse session and navigate to a URL")
p_open.add_argument("--url", help="Elmhurst tool URL (or set ELMHURST_URL)")
p_open.set_defaults(func=cmd_open)
p_status = sub.add_parser("status", help="report whether a session is saved")
p_status.set_defaults(func=cmd_status)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
func = getattr(args, "func")
return int(func(args))
if __name__ == "__main__":
raise SystemExit(main())