README.md

# Automator

Chrome DevTools Protocol (CDP) scraper for Elixir. Spawn headless Chromium, navigate pages, evaluate JavaScript, and extract data — all through a clean, idiomatic Elixir API.

## Installation

Add `:automator` to your dependencies:

```elixir
def deps do
  [
    {:automator, "~> 0.1.0"}
  ]
end
```

Requires Chromium installed and available on PATH as `chromium`.

## Quick Start

```elixir
# Start a scraper (spawns Chromium + connects automatically)
{:ok, scraper} = Automator.Scraper.start_link()

# Navigate to a page
Automator.Scraper.navigate(scraper, "https://example.com")

# Evaluate JavaScript
title = Automator.Scraper.eval(scraper, "document.title")
# => "Example Domain"

# Wait for an element to appear
Automator.Scraper.wait_for_selector(scraper, "h1")

# Click an element
Automator.Scraper.click(scraper, "a")

# Take a screenshot (returns base64)
%{"data" => base64} = Automator.Scraper.screenshot(scraper)
File.write!("page.png", Base.decode64!(base64))

# Set cookies
Automator.Scraper.set_cookie(scraper, "session", "abc123", ".example.com")

# Cleanup
Automator.Scraper.stop(scraper)
```

## Architecture

Automator has three layers, from high-level to low-level:

```
┌─────────────────────────────────────────┐
│  Automator.Scraper  (GenServer)         │  ← Primary API
│  Manages browser + page, simple fns     │
├─────────────────────────────────────────┤
│  Automator.Client   (WebSockex)         │  ← Raw CDP commands
│  WebSocket JSON-RPC client              │
├─────────────────────────────────────────┤
│  Automator.Chromium (Process mgmt)      │  ← Browser lifecycle
│  Spawns/kills headless Chromium         │
└─────────────────────────────────────────┘
```

Most users only need `Automator.Scraper`. Use `Client` when you need direct access to CDP domains not exposed by the scraper. Use `Chromium` when you want to manage the browser lifecycle yourself.

## API Reference

### Automator.Scraper

High-level scraping API. A `GenServer` that owns a Chromium instance and a page-level WebSocket connection.

#### `start_link/0`

Spawns headless Chromium and connects to a blank page.

```elixir
{:ok, scraper} = Automator.Scraper.start_link()
```

Returns `{:ok, pid}`.

#### `navigate/2`

Navigates to a URL. Waits ~1 second for the page to load before returning.

```elixir
Automator.Scraper.navigate(scraper, "https://example.com")
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `pid` | `pid` | Scraper process |
| `url` | `String.t()` | URL to navigate to |

#### `eval/2`

Evaluates JavaScript in the page context. Supports async/await — promises are awaited automatically.

```elixir
Automator.Scraper.eval(scraper, "document.title")
# => "Example Domain"

Automator.Scraper.eval(scraper, "document.querySelectorAll('a').length")
# => 1

Automator.Scraper.eval(scraper, "Array.from(document.querySelectorAll('a')).map(a => a.href)")
# => ["https://www.iana.org/domains/example"]

# Async example
Automator.Scraper.eval(scraper, """
  await fetch('/api/data').then(r => r.json())
""")
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `pid` | `pid` | Scraper process |
| `js` | `String.t()` | JavaScript expression |

**Returns:** The JavaScript result value, converted to an Elixir term.

#### `click/2`

Clicks an element matching a CSS selector.

```elixir
Automator.Scraper.click(scraper, "button.submit")
# => true

Automator.Scraper.click(scraper, ".nonexistent")
# => false
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `pid` | `pid` | Scraper process |
| `selector` | `String.t()` | CSS selector |

**Returns:** `true` if element found and clicked, `false` otherwise.

#### `wait_for_selector/3`

Waits for an element to appear in the DOM using a `MutationObserver` (not polling).

```elixir
Automator.Scraper.wait_for_selector(scraper, "h1")
# => :ok

Automator.Scraper.wait_for_selector(scraper, ".dynamic-content", 5000)
# => :ok

Automator.Scraper.wait_for_selector(scraper, ".nonexistent", 1000)
# => {:error, "selector .nonexistent not found within 1000ms"}
```

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `pid` | `pid` | — | Scraper process |
| `selector` | `String.t()` | — | CSS selector |
| `timeout` | `integer()` | `10_000` | Max wait time in ms |

**Returns:** `:ok` or `{:error, reason}`.

#### `screenshot/1`

Captures a screenshot of the current page as a base64-encoded PNG.

```elixir
%{"data" => base64} = Automator.Scraper.screenshot(scraper)
File.write!("screenshot.png", Base.decode64!(base64))
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `pid` | `pid` | Scraper process |

**Returns:** `%{"data" => base64_string}`.

#### `set_cookie/4`

Sets a cookie for the given domain.

```elixir
Automator.Scraper.set_cookie(scraper, "session", "abc123", ".example.com")
# => %{"success" => true}
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `pid` | `pid` | Scraper process |
| `name` | `String.t()` | Cookie name |
| `value` | `String.t()` | Cookie value |
| `domain` | `String.t()` | Cookie domain (e.g., `".example.com"`) |

#### `stop/1`

Stops the scraper and kills the Chromium process.

```elixir
Automator.Scraper.stop(scraper)
# => :ok
```

---

### Automator.Chromium

Low-level browser process management. Use this when you want to manage the Chromium lifecycle yourself and connect multiple clients.

#### `spawn/0`

Launches headless Chromium on an available port.

```elixir
browser = Automator.Chromium.spawn()
# => %{
#   chromium: #Port<0.5>,
#   os_pid: 12345,
#   port: 9222,
#   ws_url: "ws://localhost:9222/devtools/browser/..."
# }
```

**Flags used:**

| Flag | Value |
|------|-------|
| `--headless` | `new` |
| `--no-sandbox` | — |
| `--disable-gpu` | — |
| `--window-size` | `1920,1080` |
| `--remote-debugging-port` | auto-detected |

**Returns:** A map with `:chromium` (port ref), `:os_pid`, `:port`, and `:ws_url`.

#### `kill/1`

Kills the Chromium process by OS PID.

```elixir
browser = Automator.Chromium.spawn()
Automator.Chromium.kill(browser)
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `browser` | `map()` | Map returned by `spawn/0` |

---

### Automator.Client

Low-level WebSocket client for sending raw CDP commands. Use this when you need access to CDP domains not exposed by `Scraper`.

#### `start_link/1`

Connects to a Chromium WebSocket debugger URL.

```elixir
{:ok, client} = Automator.Client.start_link("ws://localhost:9222/devtools/browser/...")
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `ws_url` | `String.t()` | WebSocket URL from `Chromium.spawn().ws_url` or `/json` endpoint |

#### `send_command/3`

Sends a CDP command and blocks until the response arrives.

```elixir
# Browser-level command
{:ok, result} = Automator.Client.send_command(client, "Browser.getVersion")
IO.inspect(result["product"])
# => "Chrome/145.0.7632.159"

# Page-level command
{:ok, page_client} = Automator.Client.start_link(page_ws_url)
{:ok, _} = Automator.Client.send_command(page_client, "Page.navigate", %{url: "https://example.com"})

# With parameters
{:ok, result} = Automator.Client.send_command(page_client, "Runtime.evaluate", %{
  expression: "document.title",
  returnByValue: true
})
```

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `pid` | `pid` | — | Client process |
| `method` | `String.t()` | — | CDP method name |
| `params` | `map()` | `%{}` | Command parameters |

**Returns:** `{:ok, result}` or `{:error, error}`.

See the [CDP protocol documentation](https://chromedevtools.github.io/devtools-protocol/) for all available domains and methods.

## Common Patterns

### Scraping a list of items

```elixir
{:ok, scraper} = Automator.Scraper.start_link()
Automator.Scraper.navigate(scraper, "https://example.com/products")

items = Automator.Scraper.eval(scraper, """
  Array.from(document.querySelectorAll('.product')).map(el => ({
    name: el.querySelector('.name').textContent,
    price: el.querySelector('.price').textContent,
    url: el.querySelector('a').href
  }))
""")

Automator.Scraper.stop(scraper)
```

### Waiting for dynamic content

```elixir
{:ok, scraper} = Automator.Scraper.start_link()
Automator.Scraper.navigate(scraper, "https://example.com")

# Wait for SPA to render
Automator.Scraper.wait_for_selector(scraper, ".app-root", 15_000)

# Interact
Automator.Scraper.click(scraper, "button.load-more")
Automator.Scraper.wait_for_selector(scraper, ".item:nth-child(20)", 10_000)

# Extract
data = Automator.Scraper.eval(scraper, "window.__INITIAL_STATE__")

Automator.Scraper.stop(scraper)
```

### Using cookies for authenticated sessions

```elixir
{:ok, scraper} = Automator.Scraper.start_link()

# Set auth cookie
Automator.Scraper.set_cookie(scraper, "auth_token", "secret", ".example.com")

# Navigate — already authenticated
Automator.Scraper.navigate(scraper, "https://example.com/dashboard")
profile = Automator.Scraper.eval(scraper, "document.querySelector('.profile').textContent")

Automator.Scraper.stop(scraper)
```

### Raw CDP access for advanced use cases

```elixir
# Start scraper for browser management
{:ok, scraper} = Automator.Scraper.start_link()
Automator.Scraper.navigate(scraper, "https://example.com")

# Access performance metrics via CDP
Automator.Scraper.eval(scraper, "performance.getEntriesByType('navigation')[0]")

# Or use Client directly for any CDP domain
# (e.g., Network, DOM, CSS, Accessibility, etc.)
Automator.Scraper.stop(scraper)
```

## CDP Domains

Through `Automator.Client.send_command/3`, you have access to the full Chrome DevTools Protocol. Commonly useful domains:

| Domain | Use case |
|--------|----------|
| `Page` | Navigation, screenshots, lifecycle events |
| `Runtime` | JavaScript evaluation, object inspection |
| `DOM` | DOM tree traversal, node manipulation |
| `Network` | Request/response interception, cookies |
| `CSS` | Stylesheet inspection, computed styles |
| `Input` | Mouse/keyboard simulation |
| `Emulation` | Device emulation, viewport, geolocation |
| `Browser` | Browser info, window management |
| `Target` | Tab/page management |

See the [full CDP reference](https://chromedevtools.github.io/devtools-protocol/) for every method.

## License

MIT