# browse97

Chrome browser automation for [pi coding agent](https://pi.dev). Control Chrome via Chrome DevTools Protocol (CDP) — open tabs, snapshot pages, click elements, fill forms, upload files, evaluate JavaScript.

No external dependencies. Uses Node.js built-in WebSocket.

## Setup

Launch Chrome with remote debugging:

```bash
google-chrome-stable --remote-debugging-port=9222 --no-first-run --user-data-dir=data/chrome-profile &
```

Custom port via environment variable:

```bash
BROWSE97_PORT=9223 google-chrome-stable --remote-debugging-port=9223 ...
```

## Install

```bash
pi install git:github.com/nerkn/browse97
```

## Tools

| Tool | Description |
|---|---|
| `browse97_start` | Open a new browser tab and connect to it. Call this first. |
| `browse97_alltabs` | List all open tabs (id, url, title) |
| `browse97_switchtab` | Connect agent to an existing tab by id |
| `browse97_navigate` | Navigate the owned tab to a URL |
| `browse97_snapshot` | List interactive elements. Scope with `container`, filter with `filter` |
| `browse97_click` | Click by CSS selector or visible text |
| `browse97_fill` | Fill multiple form fields by fuzzy matching name/id/placeholder/label |
| `browse97_upload` | Upload file to a file input (blob injection for HTTPS) |
| `browse97_eval` | Evaluate JavaScript in the browser page context |
| `browse97_wait` | Wait for an element or text to appear |

## Ownership Model

- `browse97_start` creates a **new tab** → your pi session owns it
- All other tools operate only on your owned tab
- Tab stays open in Chrome after session ends
- Each pi session gets its own tab — safe for multi-project use

## Usage Examples

```
Open google.com and search:
  → browse97_start({url: "https://google.com"})
  → browse97_snapshot({filter: "input,button"})
  → browse97_fill({fields: {"q": "pi coding agent"}})
  → browse97_click({target: "Google Search"})

Fill a job application form:
  → browse97_start({url: "https://apply.example.com"})
  → browse97_snapshot({container: ".application-form"})
  → browse97_fill({fields: {"first_name": "Erkin", "last_name": "Tek", "email": "me@example.com"}})
  → browse97_upload({selector: "#cv", file: "assets/CV.pdf"})
  → browse97_click({target: "Submit"})

Extract data from a page:
  → browse97_eval({expression: "[...document.querySelectorAll('.job-card')].map(c => c.textContent.trim()).join('\\n')"})

Connect to an already-open tab:
  → browse97_alltabs({})
  → browse97_switchtab({id: "ABC123"})
  → browse97_snapshot({})

Wait for dynamic content:
  → browse97_wait({target: ".results-loaded", timeout: 5000})
```

## snapshot Parameters

| Parameter | Example | Description |
|---|---|---|
| (none) | `{}` | All interactive elements on page |
| `container` | `{container: ".main"}` | Scope to elements inside `.main` |
| `filter` | `{filter: "a,button"}` | Only show links and buttons |
| both | `{container: ".form", filter: "input,select"}` | Inputs and selects inside `.form` |

## click Auto-Detection

- Starts with `.` `#` `[` `(` → CSS selector
- Contains `>` `+` `~` `*` → CSS selector
- Otherwise → matches visible text of buttons, links, submit inputs

## fill Fuzzy Matching

Field keys are matched case-insensitively against: `name`, `id`, `placeholder`, `aria-label`, associated `<label>` text.

```
browse97_fill({fields: {"email": "x@y.com"}})
  → matches: name="email", id="email", placeholder="Email address", label "Email"
```

## File Upload on HTTPS

`DOM.setFileInputFiles` only works on `file://` pages. browse97 works around this by injecting files as blobs via `DataTransfer`.

**Limitation:** Files set via blob don't survive native form submission (browser security).

**Workaround:** Use `browse97_eval` with `FormData` + `fetch()` for form submission that includes files:

```javascript
const form = document.querySelector('form');
const formData = new FormData(form);
// blob already injected by browse97_upload
const response = await fetch(form.action, { method: 'POST', body: formData });
```

## Your Context is Safe

`browse97_snapshot` and `browse97_eval` truncate results at **5000 characters** by default. When truncated, a message suggests scoping with `container`/`filter` or passing `allowWhole: true`.

```snapshot({container: ".main", filter: "a"})       // scoped → likely fits
snapshot({allowWhole: true})                          // no truncation
browse97_eval({expression: "...", allowWhole: true})  // no truncation
```

## License

MIT
