Add capture browser and step-at-a-time browse tool

This commit is contained in:
Ian Gulliver
2026-08-15 13:29:23 -07:00
parent 20c89a61d9
commit 5d6e59122a
4 changed files with 256 additions and 1 deletions
+45
View File
@@ -16,6 +16,51 @@ Flags:
The capture is a full-page screenshot at a 1280×800 viewport. The capture is a full-page screenshot at a 1280×800 viewport.
## Capturing authenticated external sites
Some source material (like the production apps being ported) sits behind a login. The capture browser handles this:
go run ./tools/capturebrowser
launches a headed Chrome with a dedicated profile in `~/.heliosian/capture-profile` and DevTools on `localhost:9222`. Log in to the target site in that window; the session persists in the profile across restarts. The browser stays out of the repo entirely — no cookies or credentials ever land here.
With the capture browser running, add `-remote` to attach to it instead of launching headless Chrome:
go run ./tools/screenshot -remote -url https://example.com/some/page -out screenshots/existing/page.png -wait body
Each capture opens a fresh tab in the authenticated session, navigates, waits for the `-wait` selector, screenshots, and closes the tab. Exploring a site is a series of `-remote` captures over its URLs.
## Interactive exploration
`tools/browse` drives the capture browser one step at a time: each invocation attaches to the current tab, performs at most one action, then captures and reports the resulting URL and title. The tab survives between invocations, so state (login, SPA position) carries across steps.
go run ./tools/browse -nav https://example.com/ -out screenshots/step1.png
go run ./tools/browse -clicksel "a.next" -wait "h1" -out screenshots/step2.png
go run ./tools/browse -click 640,300 -out screenshots/step3.png
go run ./tools/browse -dump
Actions (at most one step's worth per invocation):
- `-nav <url>` — navigate the tab
- `-back` — history back
- `-clicksel <selector>` — click the first match; times out if the selector never appears
- `-click <x,y>` — click at viewport coordinates, which map 1:1 onto the screenshot (1280×800 viewport)
- `-type <text>` — insert text into the focused element
- `-key <name>` — press enter, tab, escape, backspace, or a literal character
- `-scroll <px>` — scroll vertically, negative for up
- `-wait <selector>` — block until this selector is visible before capturing
- `-dump` — print the page HTML (for finding selectors) instead of writing a PNG
The capture is the visible viewport, not the full page, so click coordinates read off a screenshot are directly usable. After an action that triggers cross-page navigation, always pass `-wait` with a selector expected on the destination page — the built-in settle delay is short, and without `-wait` the capture can race the navigation and show the previous page. The reported URL/title always reflect the final state; when a capture looks stale, re-run with no action to capture the current state.
Invocations must not linger: every run exits by itself within its 15-second internal timeout, leaving the browser and tab untouched.
At the end of a capture session, quit the browser:
pkill -f capture-profile
The login session persists in the profile, so the next `tools/capturebrowser` launch is still signed in.
## Agent recipe ## Agent recipe
One self-contained command that starts the server, captures, and shuts down: One self-contained command that starts the server, captures, and shuts down:
+178
View File
@@ -0,0 +1,178 @@
// Command browse drives the capture browser one step at a time: act, capture, report.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/target"
"github.com/chromedp/chromedp"
)
type pageTarget struct {
ID string `json:"id"`
Type string `json:"type"`
URL string `json:"url"`
Title string `json:"title"`
}
func currentTarget() (string, error) {
resp, err := http.Get("http://localhost:9222/json/list")
if err != nil {
return "", fmt.Errorf("capture browser not reachable on localhost:9222, run tools/capturebrowser first: %w", err)
}
defer resp.Body.Close()
targets := []pageTarget{}
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return "", err
}
for _, t := range targets {
if t.Type != "page" {
continue
}
if strings.HasPrefix(t.URL, "devtools://") || strings.HasPrefix(t.URL, "chrome-extension://") {
continue
}
return t.ID, nil
}
return newTab()
}
func newTab() (string, error) {
req, err := http.NewRequest(http.MethodPut, "http://localhost:9222/json/new?url=about:blank", nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
t := pageTarget{}
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
return "", err
}
if t.ID == "" {
return "", fmt.Errorf("capture browser did not create a tab")
}
return t.ID, nil
}
func parseXY(coords string) (float64, float64, error) {
parts := strings.Split(coords, ",")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("click coordinates must be x,y, got %q", coords)
}
x, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
if err != nil {
return 0, 0, err
}
y, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err != nil {
return 0, 0, err
}
return x, y, nil
}
func keyChord(name string) string {
switch strings.ToLower(name) {
case "enter":
return "\r"
case "tab":
return "\t"
case "escape":
return ""
case "backspace":
return "\b"
default:
return name
}
}
func main() {
nav := flag.String("nav", "", "navigate to url")
back := flag.Bool("back", false, "navigate back in history")
scroll := flag.Int("scroll", 0, "scroll vertically by pixels, negative scrolls up")
clickSel := flag.String("clicksel", "", "click the first element matching css selector")
click := flag.String("click", "", "click at viewport coordinates x,y as shown in the screenshot")
typeText := flag.String("type", "", "insert text into the focused element")
key := flag.String("key", "", "press a key: enter, tab, escape, backspace, or a literal character")
wait := flag.String("wait", "", "css selector that must be visible before capturing")
dump := flag.Bool("dump", false, "print page html instead of writing a screenshot")
out := flag.String("out", "screenshots/browse.png", "output png path")
flag.Parse()
id, err := currentTarget()
if err != nil {
log.Fatalf("[ERROR] %v", err)
}
allocCtx, _ := chromedp.NewRemoteAllocator(context.Background(), "http://localhost:9222")
// cancelling the chromedp context closes the attached tab; the tab must outlive this process
ctx, _ := chromedp.NewContext(allocCtx, chromedp.WithTargetID(target.ID(id)))
ctx, cancelTimeout := context.WithTimeout(ctx, 15*time.Second)
defer cancelTimeout()
actions := []chromedp.Action{chromedp.EmulateViewport(1280, 800)}
if *nav != "" {
actions = append(actions, chromedp.Navigate(*nav))
}
if *back {
actions = append(actions, chromedp.NavigateBack())
}
if *scroll != 0 {
actions = append(actions, chromedp.Evaluate(fmt.Sprintf("window.scrollBy(0, %d)", *scroll), nil))
}
if *clickSel != "" {
actions = append(actions, chromedp.Click(*clickSel, chromedp.ByQuery))
}
if *click != "" {
x, y, err := parseXY(*click)
if err != nil {
log.Fatalf("[ERROR] %v", err)
}
actions = append(actions, chromedp.MouseClickXY(x, y))
}
if *typeText != "" {
actions = append(actions, input.InsertText(*typeText))
}
if *key != "" {
actions = append(actions, chromedp.KeyEvent(keyChord(*key)))
}
if *wait != "" {
actions = append(actions, chromedp.WaitVisible(*wait, chromedp.ByQuery))
}
actions = append(actions, chromedp.Sleep(700*time.Millisecond))
var html string
var png []byte
if *dump {
actions = append(actions, chromedp.OuterHTML("html", &html, chromedp.ByQuery))
} else {
actions = append(actions, chromedp.CaptureScreenshot(&png))
}
var location, title string
actions = append(actions, chromedp.Location(&location), chromedp.Title(&title))
if err := chromedp.Run(ctx, actions...); err != nil {
log.Fatalf("[ERROR] browse: %v", err)
}
if *dump {
fmt.Println(html)
} else {
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
log.Fatalf("[ERROR] create output dir: %v", err)
}
if err := os.WriteFile(*out, png, 0o644); err != nil {
log.Fatalf("[ERROR] write %s: %v", *out, err)
}
}
fmt.Printf("url: %s\ntitle: %s\n", location, title)
}
+25
View File
@@ -0,0 +1,25 @@
// Command capturebrowser launches the headed capture browser used to screenshot authenticated sites.
package main
import (
"log"
"os"
"os/exec"
"path/filepath"
)
func main() {
profile := filepath.Join(os.Getenv("HOME"), ".heliosian", "capture-profile")
if err := os.MkdirAll(profile, 0o700); err != nil {
log.Fatalf("[ERROR] create profile dir: %v", err)
}
cmd := exec.Command("open", "-na", "Google Chrome", "--args",
"--user-data-dir="+profile,
"--remote-debugging-port=9222",
"--no-first-run",
"--no-default-browser-check")
if err := cmd.Run(); err != nil {
log.Fatalf("[ERROR] launch chrome: %v", err)
}
log.Printf("capture browser running, devtools on http://localhost:9222, profile in %s", profile)
}
+8 -1
View File
@@ -16,8 +16,15 @@ func main() {
url := flag.String("url", "http://localhost:8080/directory/", "page to capture") url := flag.String("url", "http://localhost:8080/directory/", "page to capture")
out := flag.String("out", "screenshots/capture.png", "output png path") out := flag.String("out", "screenshots/capture.png", "output png path")
wait := flag.String("wait", "body", "css selector that must be visible before capturing") wait := flag.String("wait", "body", "css selector that must be visible before capturing")
remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome")
flag.Parse() flag.Parse()
ctx, cancelBrowser := chromedp.NewContext(context.Background()) ctx := context.Background()
if *remote {
var cancelAllocator context.CancelFunc
ctx, cancelAllocator = chromedp.NewRemoteAllocator(ctx, "http://localhost:9222")
defer cancelAllocator()
}
ctx, cancelBrowser := chromedp.NewContext(ctx)
defer cancelBrowser() defer cancelBrowser()
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second) ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
defer cancelTimeout() defer cancelTimeout()