diff --git a/docs/screenshots.md b/docs/screenshots.md index f119e93..0c1c130 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -16,6 +16,51 @@ Flags: 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 ` — navigate the tab +- `-back` — history back +- `-clicksel ` — click the first match; times out if the selector never appears +- `-click ` — click at viewport coordinates, which map 1:1 onto the screenshot (1280×800 viewport) +- `-type ` — insert text into the focused element +- `-key ` — press enter, tab, escape, backspace, or a literal character +- `-scroll ` — scroll vertically, negative for up +- `-wait ` — 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 One self-contained command that starts the server, captures, and shuts down: diff --git a/tools/browse/main.go b/tools/browse/main.go new file mode 100644 index 0000000..59b6601 --- /dev/null +++ b/tools/browse/main.go @@ -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) +} diff --git a/tools/capturebrowser/main.go b/tools/capturebrowser/main.go new file mode 100644 index 0000000..2d4f4b3 --- /dev/null +++ b/tools/capturebrowser/main.go @@ -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) +} diff --git a/tools/screenshot/main.go b/tools/screenshot/main.go index e98229e..3351ae9 100644 --- a/tools/screenshot/main.go +++ b/tools/screenshot/main.go @@ -16,8 +16,15 @@ func main() { url := flag.String("url", "http://localhost:8080/directory/", "page to capture") out := flag.String("out", "screenshots/capture.png", "output png path") 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() - 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() ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second) defer cancelTimeout()