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.
Install
pip install zerodom
# or: uvx zerodom — the CLI runs straight off PyPIOne extra step only if you use the browser-backed features (from_page, --render, --screenshot, --html):
playwright install chromiumQuickstart
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 sideReal output, from zerodom demo/demo-page.html on the sample page in the repo:
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.
playwright install chromiumClaude Desktop
claude_desktop_config.json — in ~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows.
{
"mcpServers": {
"zerodom": {
"command": "uvx",
"args": ["--from", "zerodom", "zerodom-mcp"]
}
}
}Cursor
.cursor/mcp.json in the project, or ~/.cursor/mcp.json globally.
{
"mcpServers": {
"zerodom": {
"command": "uvx",
"args": ["--from", "zerodom", "zerodom-mcp"]
}
}
}Tool reference
| Tool | What 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.
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 changeActions 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:
typed: 'zerodom'
after read_page : 'zerodom' ← preserved
after parse_url : '' ← wiped by gotoCLI
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 pagesSeeing 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:
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.
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:
- 01<label for>
- 02wrapping <label>
- 03aria-labelledby
- 04aria-label / placeholder / alt / title
- 05a submit input's value
- 06adjacent caption text (Search: <input name="q"> → Search)
- 07the element's own text
- 08name / value
- 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
{
"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