mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
- 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>
175 lines
6.5 KiB
Python
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())
|