ZeroDOM
Documentation

Ship a browser agent that reads what it can click.

ZeroDOM is a Python library, a CLI and an MCP server over the same deterministic parser. Everything below runs locally against a page you already control.

Python 3.10+Apache 2.096 testslxml · Playwright (optional)

Install

shell
pip install zerodom
# or: uvx zerodom — the CLI runs straight off PyPI

One extra step only if you use the browser-backed features (from_page, --render, --screenshot, --html):

shell
playwright install chromium

Quickstart

python
from zerodom import ZeroDOM

graph = ZeroDOM.from_page(page)      # any Playwright page, sync or async
print(graph.to_compact_text())       # what you send the model
selectors = graph.selector_map()     # {"node_01": "#email-input", ...} — stays your side

Real output, from zerodom demo/demo-page.html on the sample page in the repo:

compact graph
the whole page
PAGE: Orbit — Fleet Console | file:///…/demo/demo-page.html
[01] a 'Fleet'
[02] a 'Routes'
[03] a 'Alerts'
[04] a 'Settings'
[05] input 'Search'
…
[07] input* 'Assigned driver' ph='Search by name'
[15] button 'Dispatch'
[17] button! 'Recall (in transit)'

* marks required, ! marks disabled. The model answers click 15. You resolve node_15 against selector_map() and click it. The CSS selector never enters the context window — which is the whole trick, because on real pages the selectors cost more than the labels do.

MCP setup

The server keeps one Chromium session and holds node_id → selector server-side. That map lives in memory for the life of the process and is never written to disk: a page graph is stale the moment someone clicks something, so there is nothing worth caching between runs.

shell
playwright install chromium

Claude Desktop

claude_desktop_config.json — in ~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows.

json
{
  "mcpServers": {
    "zerodom": {
      "command": "uvx",
      "args": ["--from", "zerodom", "zerodom-mcp"]
    }
  }
}

Cursor

.cursor/mcp.json in the project, or ~/.cursor/mcp.json globally.

json
{
  "mcpServers": {
    "zerodom": {
      "command": "uvx",
      "args": ["--from", "zerodom", "zerodom-mcp"]
    }
  }
}

Tool reference

ToolWhat it does
zerodom_parse_url(url, verbose=False)navigate, return the compact graph
zerodom_read_page(verbose=False)re-read the live DOM without navigating
zerodom_find(query)return only the nodes matching a phrase
zerodom_click_node(node_id)click, then return what changed
zerodom_fill_node(node_id, text)type, then return what changed

An agent loop shouldn't re-read the page it already has. Two tools exist so it doesn't have to. zerodom_find answers “where's the dispatch button?” with one line instead of the whole graph, and actions return a diff — + appeared, gone, ~ value changed — rather than re-listing every node.

measured on the bundled demo page
zerodom_parse_url(...)     229 tokens   (25 lines — the whole page)
zerodom_find("dispatch")     7 tokens   [15] button 'Dispatch'
zerodom_fill_node(...)      14 tokens   no structural change

Actions accept whatever the model saw — 03, [03] and node_03 all resolve. Every action re-reads the page in place, so a click's result comes back in the same turn and node ids never go stale. zerodom_read_page exists because re-navigating would wipe live state:

measured on news.ycombinator.com
typed:            'zerodom'
after read_page : 'zerodom'      ← preserved
after parse_url : ''             ← wiped by goto

CLI

shell
zerodom https://example.com                   # the compact graph + a token report
zerodom https://example.com --find "sign in"  # only the nodes that match
zerodom https://example.com --json            # the full graph, selectors included
zerodom https://example.com --render          # headless Chromium, for JS pages

Seeing the graph

[03] a 'new' tells you node 3 exists. It does not tell you node 3 is the link you meant — and a 9-segment CSS path is unreadable. So look at it:

shell
zerodom https://news.ycombinator.com --screenshot page.png
zerodom https://news.ycombinator.com --html report.html

--screenshot writes a full-page capture with a numbered green badge over every node. --html writes a self-contained report — graph on the left, page on the right. Hover a line to spotlight that element, click to scroll it into view. The image is inlined as a data URI, so the file opens anywhere with no network access.

Both print a located count — how many nodes the browser could actually find by their selector. 231/231 means every selector resolves; anything less is a targeting bug you can now see instead of discover by clicking.

python
from zerodom import report

png, layout = report.screenshot(page, graph, "page.png")   # annotated PNG
open("report.html", "w").write(graph.to_html_report(png, layout))

How labels are resolved

In order, first hit wins:

  1. 01<label for>
  2. 02wrapping <label>
  3. 03aria-labelledby
  4. 04aria-label / placeholder / alt / title
  5. 05a submit input's value
  6. 06adjacent caption text (Search: <input name="q"> → Search)
  7. 07the element's own text
  8. 08name / value
  9. 09an image-only control's <img alt>

Adjacent text is what turns Hacker News' search box from 'q' into 'Search'. Identifiers like name come last on purpose: q is for developers, not models.

Output schema

json
{
  "nodes": [
    {"id": "node_01", "type": "input", "role": "textbox", "label": "Email Address",
     "selector": "#email-input", "placeholder": "[email protected]",
     "required": true, "value": "", "action": "fill"}
  ],
  "metadata": {"page_title": "Login", "url": "...",
               "total_interactive_nodes": 1, "parsing_latency_ms": 4.2}
}

Limitations

The full list, with the reasoning behind each boundary, is on the landing page. The short version:

  • Closed shadow roots are unreachable — open roots are parsed.
  • Iframes aren't traversed; ZeroDOM parses the top document.
  • Canvas and WebGL surfaces have no elements to emit.
  • Nothing waits for the page to finish thinking — wait for your own condition, then parse.
  • Static parsing sees only server HTML; client-rendered pages need --render, from_page, or the MCP server.
  • Visibility is read from markup, not from layout reflows.
  • Anti-bot systems are out of scope, by design — 0% bot-detection footprint.

Labels are untrusted input. A hostile page can name a button so it reads as an instruction to whatever model you send the graph to. Every tool that shows a model a web page has this problem. Keep the decision to click on your side.

Troubleshooting

zerodom: command not found after pip install

pip puts the script in a user bin dir that may not be on your PATH — ~/.local/bin on Linux, ~/Library/Python/3.x/bin on macOS. Add it to your shell profile, or run python -m zerodom.cli. uv tool install zerodom avoids this entirely.

Executable doesn't exist at …/chromium…

Playwright ships the driver, not the browser. Run playwright install chromium once. Only --render, --screenshot, --html and from_page need it; plain parsing does not.

The graph is nearly empty, or it's a cookie banner

You fetched the server HTML of a client-rendered page, or hit a bot wall. Add --render to load it in real Chromium. If it's still a wall, drive an authenticated Page yourself and use from_page — ZeroDOM has no bot-detection bypass and doesn't claim one.

A node I can see on the page isn't in the graph

In order of likelihood: it's inside an <iframe> (not traversed), inside a closed shadow root (unreachable by any API), drawn on a <canvas> (no element to find), or the page hadn't finished rendering when you parsed.

Unknown node '07'. Call zerodom_parse_url first.

The MCP session has no graph yet, or the page navigated and ids renumbered. Call zerodom_read_page. Ids are only valid for the read that produced them.

A click hits the wrong element

That's a selector bug and it's the one thing this project treats as unacceptable — please report it with the URL. Run zerodom <url> --html report.html first: the report shows exactly which element each node resolved to.

Design notes

lxml, not BeautifulSoup

bs4's tree construction alone costs ~60ms on a 5,000-node page, spending the entire latency budget before any work happens. lxml does the same job in ~5ms.

Selectors prefer #id, then [name], then a unique class, then a structural path

Structural paths use the child combinator, because :nth-of-type is only omitted when a tag is unique among its siblings — a guarantee that only holds hop-to-hop. Under a descendant combinator, span a would also match an <a> nested two spans deep; on Hacker News that aimed "new" at the logo.

Open shadow roots are parsed, and light-DOM selectors are scoped against them

page.content() omits shadow roots entirely. from_page serializes with Chromium's getHTML({serializableShadowRoots: true}); light-DOM paths are then wrapped in Playwright's non-piercing :light(…), and a shadow child that collides with a slotted light sibling gets >> nth=1.

Ids are escaped when they are not legal CSS

Hacker News numbers its rows (id="49151933"), and #49151933 is a CSS parse error — querySelector throws rather than returning nothing. Those ids become [id="49151933"].

Pruning skips whole subtrees

<script>, <style>, <link>, <svg>, <meta>, <noscript> and anything hidden by display:none, visibility:hidden, aria-hidden or [hidden] is never descended into, so pruning is also the fast path.

Running agents in production?

The private B2B beta covers hosted MCP, priority selector fixes and a benchmark run against your own pages.

Access Private B2B Beta