> For the complete documentation index, see [llms.txt](https://fleet.hackmap.win/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://fleet.hackmap.win/guides/browser-automations.md).

# Browser-based automations

When a slot needs to drive a real browser — to bypass JS-heavy bot walls, capture tokens, or render a dynamic SPA — declare a `Browser` requirement on the automation. The framework pre-warms a `BrowserPool` at worker boot, injects `ctx.browser` for every slot, and tears it down on shutdown.

You should never `import fleet_browser` yourself. The pool is a first-class declared resource, the same way `TaskPayload`, `Output`, and `Pools` are.

## Install

The browser pool ships in the base wheel — nothing extra to install:

```bash
pip install fleet-framework
```

This pulls in:

* **cloakbrowser** — Playwright + a patched Chromium 146 (downloads \~440 MB on first launch).
* **mitmproxy** — used by SmartRouter for per-flow proxy routing.
* **curl\_cffi** — real Chrome JA3 on paid outbound flows.
* **pyvirtualdisplay** — auto-managed Xvfb so headed-on-virtual-screen "just works."

System packages: `Xvfb`, `libnss3`, `libgbm1`, `fonts-liberation`, and the usual Chromium runtime deps. On Debian/Ubuntu:

```bash
apt-get install -y xvfb libnss3 libgbm1 fonts-liberation
```

You do **not** need to start Xvfb manually. The framework calls `pyvirtualdisplay.Display(...).start()` lazily on the first browser launch — see [auto-managed display](#auto-managed-display) below.

## The declaration

```python
from fleet.core.automation import BatchAutomation, register
from fleet.core.browser import BrowserNeeds

@register("my-scraper")
class MyScraper(BatchAutomation[MyConfig]):
    Config = MyConfig

    Browser = BrowserNeeds(
        size=4,             # pre-warmed Browsers kept hot
        max_uses=1,         # 1 = fresh fingerprint per task; higher = reuse
        engine="cloak",     # "cloak" | "chrome" | "auto"
        headless=False,     # headed-on-Xvfb beats headless for anti-bot
        smart_routing=True, # per-task SmartRouter when proxy is set
    )

    async def run_one(self, task, ctx):
        async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
            await page.goto(task.payload.url, wait_until="domcontentloaded")
            html = await page.content()
            await ctx.emit(MyResult(html=html))
```

That's it. The worker bootstrap reads the `Browser` declaration, provisions one shared `BrowserPool` per process, and `ctx.browser` is ready by the time `run_one` fires.

## `BrowserNeeds` reference

| Field             | Default  | Notes                                                                                                                                                                                                                                                                                                                                                  |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `size`            | `4`      | Number of pre-warmed Browsers. Lease is instant while one is idle.                                                                                                                                                                                                                                                                                     |
| `max_uses`        | `1`      | After this many acquires, dispose + respawn. **`1` = fresh cloak fingerprint per task** (anti-bot). Higher = reuse Browser with cookies cleared between (cheaper).                                                                                                                                                                                     |
| `max_age_seconds` | `3600`   | Hard recycle window. Defends against process memory leaks.                                                                                                                                                                                                                                                                                             |
| `engine`          | `"auto"` | `"auto"` resolves to `"cloak"` when cloakbrowser is installed. `"chrome"` is stock Playwright Chromium (more detectable).                                                                                                                                                                                                                              |
| `headless`        | `False`  | Headed-on-Xvfb is the production recommendation. Stock headless leaks `HeadlessChrome` UA + missing GPU; cloak's new-headless is detectable but better.                                                                                                                                                                                                |
| `smart_routing`   | `True`   | When `True` and `acquire(proxy=...)` is called, a per-task SmartRouter is spun up so paid flows go via curl\_cffi with a real Chrome JA3. Set `False` to skip the router and use the proxy directly.                                                                                                                                                   |
| `human_motion`    | `True`   | Activate `cloakbrowser.human`: bezier mouse curves, realistic typing (lognormal inter-key delays, 2% mistype rate), idle drift, smooth wheel-scroll with overshoot. Patches `page.click`, `page.type`, `page.fill`, `page.mouse.*` to use the humanised versions. Set `False` for legacy plugins that drive Playwright directly with their own timing. |

Two automations declaring **identical** `BrowserNeeds` share one pool — `BrowserRegistry` dedupes by hash.

## `ctx.browser.acquire(proxy=...)`

The acquire method returns an async context manager that yields a Playwright `Page`.

```python
async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
    # page is a freshly-created Playwright Page in its own BrowserContext.
    # Cookies/storage from previous tasks DO NOT leak in.
    await page.goto(url, wait_until="domcontentloaded")
    ...
# On exit: BrowserContext closed (cookies gone), SmartRouter stopped,
# Browser returned to pool (or recycled per max_uses).
```

What happens per acquire:

1. Lease a Browser from the pool (blocks until idle if all are leased — but with size=4 and short tasks this is sub-millisecond).
2. If `proxy` was passed *and* `smart_routing=True`: spin up a `SmartRouter` with `proxy` as upstream, get its local listen URL.
3. Create a fresh `BrowserContext` on the leased Browser, pointed at the SmartRouter (or the raw `proxy=` if smart\_routing is off).
4. Create a new Page in that context, yield it.
5. On exit: close the context, stop the SmartRouter, return the Browser (or dispose + respawn per `max_uses`).

The Browser was launched **once** with no proxy; the per-task proxy lives at the BrowserContext level. This is how the pool stays warm even though every task has its own Evomi sticky-session.

## Choosing `max_uses`

Two settings cover most cases:

**`max_uses=1` — fingerprint refresh per task.** Each task disposes its Browser and the pool spawns a replacement in the background. The cloak fingerprint (set at Browser launch via `--fingerprint=NNN`) is therefore fresh every task. **Recommended against Google-class bot walls** where cross-task fingerprint correlation is the dominant signal.

**`max_uses=100` — Browser reuse.** One Browser handles 100 tasks; cookies + storage are cleared between via the new BrowserContext, but the cloak fingerprint stays the same. Lower wall-time + memory cost. Fine for non-adversarial targets (your own site, public APIs, search engines that don't fingerprint).

The pool's background janitor also recycles entries past `max_age_seconds`, regardless of `max_uses`, as a defense against process leaks.

## Auto-managed display

`pyvirtualdisplay` is wired into `ChromiumWorker.start()` so a managed Xvfb spins up on the first browser launch and stops at interpreter exit. **You don't need to start Xvfb in your container entrypoint.**

If you already set `DISPLAY=:99` (or any non-`:0` value) in the environment, the framework leaves it alone — your manual Xvfb wins. Otherwise:

```python
# fleet_browser.display.ensure_virtual_display() is called automatically.
# Default: 1920x1080@24bit, visible=False.
```

You can call it directly if you want different dimensions before any browser launches:

```python
from fleet_browser import ensure_virtual_display
ensure_virtual_display(width=2560, height=1440, color_depth=24)
```

Idempotent — calling it again returns the same display.

## Per-task proxy + SmartRouter

`ctx.proxy_url` is the per-slot proxy URL (resolved from `proxy_url` config or a configured `proxy_provider`). Pass it to `acquire(proxy=...)`.

With `smart_routing=True`, the addon:

* Forwards `google.com` / `gstatic.com` / etc. through curl\_cffi with Chrome JA3 (CDN-family fingerprint coherence)
* Blocks known tracker / telemetry endpoints (`googletagmanager.com`, `/gen_204`, etc.)
* Caches content-addressable subresources to disk (`/xjs/_/`, `/og/_/`, `/static/`) so the second query of the same hash is free
* Lets `localhost`/`127.0.0.1` flows skip the upstream entirely

Net effect: a Google SERP costs \~180 KB paid wire bytes instead of \~1.3 MB. See [smart\_router.py](https://github.com/sarperavci/fleet/tree/main/src/fleet_browser/smart_router.py) for the addon details.

## Human-shaped motion (`fleet_browser.human`)

With `BrowserNeeds(human_motion=True)` (the default), every `page` yielded by `ctx.browser.acquire()` is patched by `cloakbrowser.human` *before* you touch it:

* `page.mouse.move(x, y)` traces a bezier path through 25–80 points with \~1.5 px end-tremor.
* `page.mouse.wheel(...)` emits multiple small deltas with realistic spacing instead of one teleport.
* `page.click(selector)` does pointer hover → small aim delay → mousedown → 60–140 ms hold → mouseup.
* `page.type(selector, text)` types letter-by-letter with lognormal inter-key delays, occasional 400–1000 ms think-pauses, and a 2 % chance of typo-then-backspace.

For plugins that want explicit motion control (rather than relying on the patched Page methods), use the `fleet_browser.human` helpers:

```python
from fleet_browser import human

async def run_one(self, task, ctx):
    async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
        await page.goto("https://example.com/")

        # Explicit move + click — same patched primitives, callable directly.
        await human.move(page, 400, 200)
        await human.click(page, "button#submit")

        # Letter-by-letter typing into a selector.
        await human.type_text(page, "input[name=q]", "best running shoes")

        # Scroll a target element into view via wheel events.
        await human.scroll_to_element(page, "footer")

        # Idle drift — cursor micro-motion during a dwell.
        await human.idle(page, seconds=2.5)
```

All five helpers raise loudly (`RuntimeError`) if called against a non-humanised page (`human_motion=False` or a non-cloak engine). Use `human.is_humanized(page)` to probe.

Tunable timing knobs live on `cloakbrowser.human.HumanConfig` (typing median, mistype rate, mouse step count, overshoot probability, idle drift radius); pass a custom `HumanConfig` per call via `cfg=...` if a particular page needs slower/faster behaviour.

The validator harness under `tests/browser/human_validator/` is a self-checking page that asserts every motion primitive does what it should (curved mouse path, hover-before-click, lognormal typing rhythm, smooth wheel-scroll, idle drift). Run it with `python tests/browser/human_validator/run.py` against `DISPLAY=:99`.

## Actions and journeys (`fleet_browser.human.actions` + `journey`)

Above the motion primitives, two more layers compose a "reach this target like a real user would" API:

**Action vocabulary** — page-aware verbs that drive cloak-humanised motion against a real Page:

```python
from fleet_browser.human import (
    OpenSite, Search, ClickResult, ReadPage, ScrollAndDwell,
    BackNavigate, ClickLink, LeavePage, HumanContext, GoogleEngine,
)

async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
    hctx = HumanContext(engine=GoogleEngine())
    await OpenSite("https://www.google.com/").execute(page, hctx)
    await Search("best running shoes").execute(page, hctx)
    await ClickResult(rank=1, dwell_after_ms=(6_000, 14_000)).execute(page, hctx)
    await ReadPage(target_dwell_ms=12_000, style="reading").execute(page, hctx)
    await BackNavigate().execute(page, hctx)
```

`GoogleEngine` knows the SERP's selectors (`textarea[name="q"]`, `div.g a:has(h3)`, `a#pnnext`), waits for the *real* SERP HTML (not the shell), and excludes ads + carousel inner links from `nth_organic_result`.

**Journey planner** — declares "reach this target within budget B" and lets the runtime pick the path:

```python
from fleet_browser.human import Journey, Personality

journey = Journey(
    target="https://example.com/product/123",
    personality=Personality.CASUAL,          # FOCUSED | CASUAL | DISTRACTED | RESEARCHER
    budget_seconds=180,
    start_strategy="auto",                   # auto | direct | search
    detour_pool="default",
    require_target_dwell_ms=15_000,
    engine=GoogleEngine(),                   # required when start_strategy can pick "search"
)

async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
    outcome = await journey.execute(page)
    if outcome.target_reached and outcome.target_dwelled:
        ...
```

The planner samples a detour count from the personality (e.g. DISTRACTED = 2–5), picks distractor sites from the configured pool, and produces a different action sequence per seed. If the budget runs low before the target is reached, it pre-empts with a direct nav (the `forced_fallback` flag goes True in the `JourneyOutcome`) — the target is always reached.

For unit tests, pass `dwell_speed_multiplier=0.001` so the 6–20 second per-page dwells become instant. Production keeps the default `1.0`.

### Per-request telemetry

The router writes one JSONL line per completed flow to a side-file next to its stats file. The path surfaces on `RouterStats` as soon as the router is `start()`-ed:

```python
async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
    stats = ctx.browser.router.stats()       # RouterStats
    stats.request_log_path                   # /tmp/.../smart_router_stats.json.requests
```

Each line records what the addon actually saw on the wire:

```json
{"t": 1715450000.12, "host": "www.google.com", "path": "/search?q=...",
 "method": "GET", "action": "paid", "status": 200, "wire": 18234}
```

`action` is one of `paid`, `direct`, `block`, or `cached`. `wire` is the response body length in bytes (the substituted body for paid / cached flows, the actual mitmproxy-recorded length for direct flows). `cached` entries carry an extra `cache: "hit"` field; `truncated` is set when the addon clipped the body for stats purposes.

Two ways to consume it:

```python
# offline read: latest N events
events = ctx.browser.router.requests(limit=200)

# streaming: register a callback before or after start()
def on_batch(batch: list[dict]) -> None:
    for ev in batch:
        ctx.emit_sync(RouterTraffic(**ev))     # feed your stream
ctx.browser.router.set_event_callback(on_batch)
```

The callback is dispatched on a background drain thread (polling the JSONL file every 250 ms) so it does not block the addon. Batches are whatever piled up between polls — usually 1-10 events. If you only want offline reads, leave the callback unset; the file is still written.

Turn it off entirely:

```python
SmartRouter(upstream_proxy=..., request_log_enabled=False)
```

Use cases the framework expects:

* **Master/dashboard feed** — bridge the callback into your fleet `Stream` so a backend has a per-request audit trail (which host, how many bytes paid, which fingerprint).
* **Cost regression tests** — compare wire bytes for the same automation run-over-run; alert if a new Google rollout pushes the per-SERP cost back up.
* **Per-task billing** — sum `wire` over `action == "paid"` to charge a per-task residential-egress cost.

## Self-hosted local-host pages (`acquire_local`)

Some browser flows need `window.location.hostname` to look like a customer's domain — a captcha sitekey whose allowlist binds to `customer.com`, an OAuth redirect that only resolves at the right origin, a script that reads `document.domain`. Pointing Chromium at the real `https://customer.com` doesn't work (you don't own DNS / TLS). Pointing it at `127.0.0.1` doesn't work either (the page's origin is wrong).

`ctx.browser.acquire_local(...)` solves this by spoofing the hostname locally:

```python
class RecaptchaAutomation(BatchAutomation):
    Browser = BrowserNeeds(
        size=1, max_uses=1,
        engine="cloak",
        headless=False,
        extra_args=("--load-extension=/path/to/ext",),
        smart_router_preset="recaptcha",
    )

    async def run_one(self, task, ctx):
        async with ctx.browser.acquire_local(
            hostname=task.hostname,
            page_path=PAGE_HTML,
            params={"sitekey": task.sitekey, "variant": "v2"},
            proxy=ctx.proxy_url,
        ) as session:
            ev = await session.wait_for("solved", timeout=60)
            await ctx.emit(Result(token=ev.payload["token"]))
```

What `acquire_local` does for you, in one shot:

1. Generates a self-signed cert with CN + SAN for `hostname`.
2. Starts a threaded TLS HTTP server on `127.0.0.1:<auto_port>`, serving the plugin's page at `GET /` and accepting events at `POST /api/event`.
3. Launches a **fresh** `ChromiumWorker` (the pre-warmed pool is bypassed — the launch flags are per-task) with:
   * `--host-resolver-rules=MAP <hostname>:<port> 127.0.0.1:<port>` — DNS spoof.
   * `--ignore-certificate-errors` — accept the self-signed cert.
   * `proxy_bypass=(hostname,)` — Chromium goes direct to the local server, not via the upstream proxy.
   * Everything from `BrowserNeeds`: `engine`, `headless`, `extra_args`, `smart_router_preset` — so loaded extensions, smart-routing presets, fingerprinting all carry over.
4. Navigates to `https://<hostname>:<port>/?<params>`.
5. Yields a `LocalSession` so you can read events from the page.
6. On exit: stops the browser → stops the server → deletes the cert tempdir.

### The page contract

Provide either `page_path: Path` or `page_bytes: bytes`. The plugin's page emits events back to your `run_one` via a tiny JS helper that fleet auto-injects into `<head>`:

```javascript
window.fleet.emit("solved", { token: theToken });
window.fleet.emit("progress", { pct: 42 });
window.fleet.emit("error", { message: "no anchor iframe" });
```

The helper POSTs to `/api/event` (with `sendBeacon`, then `fetch` as fallback). If your page already defines `window.fleet = { ... }` we won't double-inject.

CORS is open on `/api/event`, so child iframes from third-party origins (Google reCAPTCHA's bframe, an OAuth provider's redirect page) can also emit if you want them to.

### Reading events from the session

```python
async with ctx.browser.acquire_local(...) as session:
    # Wait for a single named event with a timeout.
    ev = await session.wait_for("solved", timeout=60)
    token = ev.payload["token"]

    # Wait for any of several names.
    ev = await session.wait_for_any(["solved", "error"], timeout=30)
    if ev.name == "error":
        raise PluginError(ev.payload["message"])

    # Stream every event (useful for progress UIs).
    async for ev in session.events():
        if ev.name == "progress":
            log.info("page progress: %s%%", ev.payload["pct"])
        if ev.name == "done":
            break
```

`session.history` is a snapshot list of every event seen so far (capped at 256 by default). `wait_for` checks history first so events that fire *before* the call is reached are not lost.

`session.page` is the underlying Playwright `Page`. Use it freely — drive iframes, click elements, take screenshots — alongside the event channel.

### Why a fresh browser per call

The pre-warmed `BrowserPool` exists so the same Browser instance can serve many tasks with only a cheap BrowserContext reset between them. That works because every task's *launch flags* are identical.

`acquire_local`'s launch flags include `--host-resolver-rules=MAP <hostname>:<port> ...` — per-task by definition. The cert is per-hostname. So `acquire_local` launches a fresh `ChromiumWorker` each call and discards it on exit. With `size=1` the unused pool slot is a tiny resource leak; with `size=N>1` it's `N` pre-warmed Browsers sitting idle. If your automation **only** uses `acquire_local`, set `size=1` and treat the pool slot as a placeholder for the BrowserNeeds config — fleet's registry still needs a `BrowserNeeds` entry to give you `ctx.browser`.

### Inspecting from the plugin side

If you need to ping the local server from Python (a health check, fetching a stat) — `session.host` is the `LocalHost` instance. Use `host.loopback_url` (`https://127.0.0.1:<port>`) for direct access; `host.url` (`https://<hostname>:<port>`) only resolves from inside the spoofed Chromium.

### When *not* to use `acquire_local`

* Your plugin can drive `https://customer.com` directly — DNS resolves, the cert chain validates. Use the regular `ctx.browser.acquire()` instead.
* You don't care about the origin at all (most scraping). Same.
* The page you serve doesn't talk back. Then there are no events to read, and the LocalSession layer is overhead — use a raw `ChromiumWorker` with `host_resolver_rules` set on `BrowserConfig`.

The use cases where `acquire_local` actually earns its keep: captcha solvers (Turnstile, reCAPTCHA), origin-bound OAuth flows, and any browser-side script you ship that delivers a structured result back to your plugin.

## The lower-level API (when you don't want a pool)

If you have one-off browser work that doesn't justify a process-wide pool — a CLI tool, a CI script, a one-shot debugging session — use `slot()` directly:

```python
from fleet_browser import slot

async with slot(
    ctx,
    page_url="https://example.com",
    headless=False,
    engine="cloak",
) as page:
    await page.goto("https://example.com")
    print(await page.content())
```

`slot()` spawns a fresh `ChromiumWorker` per call (\~1.5-2 s cloak launch overhead). It's the right tool for ad-hoc work; for production task loops, declare `Browser` on the automation.

## Migrating from manual `slot()` / `BrowserPool`

Older plugins instantiated `BrowserPool` per task or called `slot()` directly. Both work but neither amortizes Browser launch across tasks. To migrate:

**Before:**

```python
async def run_one(self, task, ctx):
    from fleet_browser import slot
    async with slot(ctx, page_url=url, headless=False, engine="cloak") as page:
        ...
```

**After:**

```python
class MyAutomation(BatchAutomation):
    Browser = BrowserNeeds(size=4, max_uses=1, engine="cloak", headless=False)

    async def run_one(self, task, ctx):
        async with ctx.browser.acquire(proxy=ctx.proxy_url) as page:
            ...
```

The savings: Browser launches happen once at worker boot (in parallel), not once per task. For a worker that processes 100 tasks, that's \~100 × 1.8 s = 3 minutes saved.

## Container init: still required

Even with the cloak Browser process tree, you should run the worker container with an init process (`tini` or Docker Compose `init: true`). Without one, Python becomes PID 1 and doesn't reap zombies; orphan Chromium subprocesses pile up in the pids cgroup and new browser launches eventually fail with `RuntimeError: can't start new thread`.

```dockerfile
RUN apt-get install -y tini xvfb libnss3 libgbm1 fonts-liberation
ENTRYPOINT ["/usr/bin/tini", "--", "your-entrypoint.sh"]
```

## Examples

`fleet-serp-google` and `fleet-serp-bing` both use `Browser = BrowserNeeds(...)`. They're the canonical reference for production-shaped browser-based automations — see [`fleet_serp_google/automation.py`](https://github.com/sarperavci/fleet-plugins-private/blob/main/packages/fleet-serp-google/fleet_serp_google/automation.py).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://fleet.hackmap.win/guides/browser-automations.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
