zerodom 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zerodom/__init__.py +8 -0
- zerodom/cli.py +163 -0
- zerodom/label_linker.py +119 -0
- zerodom/mcp_server.py +180 -0
- zerodom/parser.py +440 -0
- zerodom/playwright_wrapper.py +72 -0
- zerodom/report.py +375 -0
- zerodom-0.0.1.dist-info/METADATA +168 -0
- zerodom-0.0.1.dist-info/RECORD +12 -0
- zerodom-0.0.1.dist-info/WHEEL +4 -0
- zerodom-0.0.1.dist-info/entry_points.txt +3 -0
- zerodom-0.0.1.dist-info/licenses/LICENSE +95 -0
zerodom/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""ZeroDOM: DOM-to-Interaction-Graph middleware for AI web agents."""
|
|
2
|
+
|
|
3
|
+
from .label_linker import get_label
|
|
4
|
+
from .parser import InteractionGraph, ZeroDOMParser, parse_html
|
|
5
|
+
from .playwright_wrapper import ZeroDOM
|
|
6
|
+
|
|
7
|
+
__version__ = "0.0.1"
|
|
8
|
+
__all__ = ["ZeroDOM", "ZeroDOMParser", "InteractionGraph", "parse_html", "get_label"]
|
zerodom/cli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Turn a web page into the list of things an agent can click, and count the
|
|
2
|
+
savings. `zerodom <url>` for the compact graph, --find to search it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import tiktoken
|
|
12
|
+
|
|
13
|
+
from .parser import ZeroDOMParser, compact_line, find_nodes
|
|
14
|
+
from .playwright_wrapper import serialize
|
|
15
|
+
|
|
16
|
+
USER_AGENT = "Mozilla/5.0 (compatible; zerodom)"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def count_tokens(text: str) -> int:
|
|
20
|
+
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fetch(url: str, render: bool) -> tuple[str, str]:
|
|
24
|
+
"""Return (html, final_url). `render` runs a real browser for JS-heavy pages."""
|
|
25
|
+
if render:
|
|
26
|
+
from playwright.sync_api import sync_playwright
|
|
27
|
+
|
|
28
|
+
with sync_playwright() as pw:
|
|
29
|
+
browser = pw.chromium.launch()
|
|
30
|
+
page = browser.new_page()
|
|
31
|
+
page.goto(url, wait_until="networkidle")
|
|
32
|
+
html, final_url = serialize(page), page.url
|
|
33
|
+
browser.close()
|
|
34
|
+
return html, final_url
|
|
35
|
+
|
|
36
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
37
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
38
|
+
return resp.read().decode(resp.headers.get_content_charset() or "utf-8", "replace"), resp.url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def capture(url: str, screenshot: str | None, html_path: str | None) -> tuple[str, str, str]:
|
|
42
|
+
"""Load a URL once and write whichever visual artifacts were asked for.
|
|
43
|
+
|
|
44
|
+
Measuring, annotating and screenshotting all need the same live page, so they
|
|
45
|
+
share one browser session rather than one launch per artifact.
|
|
46
|
+
"""
|
|
47
|
+
from playwright.sync_api import sync_playwright
|
|
48
|
+
|
|
49
|
+
from . import report
|
|
50
|
+
|
|
51
|
+
with sync_playwright() as pw:
|
|
52
|
+
browser = pw.chromium.launch()
|
|
53
|
+
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
|
54
|
+
page.goto(url, wait_until="networkidle")
|
|
55
|
+
source, final_url = serialize(page), page.url
|
|
56
|
+
graph = ZeroDOMParser(source, final_url).parse()
|
|
57
|
+
|
|
58
|
+
png, layout = report.screenshot(page, graph, screenshot)
|
|
59
|
+
browser.close()
|
|
60
|
+
|
|
61
|
+
located = len(layout["boxes"])
|
|
62
|
+
notes = []
|
|
63
|
+
if screenshot:
|
|
64
|
+
notes.append(f" Screenshot {screenshot} ({located}/{len(graph['nodes'])} located)")
|
|
65
|
+
if html_path:
|
|
66
|
+
with open(html_path, "w", encoding="utf-8") as fh:
|
|
67
|
+
fh.write(graph.to_html_report(png, layout))
|
|
68
|
+
notes.append(f" HTML report {html_path}")
|
|
69
|
+
return source, final_url, "\n".join(notes)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def inspect(
|
|
73
|
+
url: str,
|
|
74
|
+
render: bool = False,
|
|
75
|
+
as_json: bool = False,
|
|
76
|
+
screenshot: str | None = None,
|
|
77
|
+
html_path: str | None = None,
|
|
78
|
+
find: str | None = None,
|
|
79
|
+
) -> int:
|
|
80
|
+
# A local path is a perfectly good thing to inspect; both fetchers need file://.
|
|
81
|
+
if "://" not in url and Path(url).exists():
|
|
82
|
+
url = Path(url).resolve().as_uri()
|
|
83
|
+
|
|
84
|
+
extra = ""
|
|
85
|
+
if screenshot or html_path:
|
|
86
|
+
html, final_url, extra = capture(url, screenshot, html_path)
|
|
87
|
+
else:
|
|
88
|
+
html, final_url = fetch(url, render)
|
|
89
|
+
graph = ZeroDOMParser(html, final_url).parse()
|
|
90
|
+
|
|
91
|
+
raw_tokens = count_tokens(html)
|
|
92
|
+
# Measure what a model would actually be sent: compact JSON, or the text DSL.
|
|
93
|
+
payload = graph.to_json(indent=None) if as_json else graph.to_compact_text()
|
|
94
|
+
graph_tokens = count_tokens(payload)
|
|
95
|
+
savings = (1 - graph_tokens / raw_tokens) * 100 if raw_tokens else 0.0
|
|
96
|
+
|
|
97
|
+
if find:
|
|
98
|
+
hits = find_nodes(graph["nodes"], find)
|
|
99
|
+
print("\n".join(compact_line(node) for node in hits) if hits
|
|
100
|
+
else f"No node matches {find!r} among {len(graph['nodes'])} nodes.")
|
|
101
|
+
else:
|
|
102
|
+
print(graph.to_json() if as_json else graph.to_compact_text())
|
|
103
|
+
print(f"\n{'─' * 52}")
|
|
104
|
+
print(f" URL {final_url}")
|
|
105
|
+
print(f" Interactive nodes {graph['metadata']['total_interactive_nodes']}")
|
|
106
|
+
print(f" Parsing latency {graph['metadata']['parsing_latency_ms']} ms")
|
|
107
|
+
print(f" Raw DOM tokens {raw_tokens:,}")
|
|
108
|
+
print(f" ZeroDOM tokens {graph_tokens:,} ({'json' if as_json else 'compact text'})")
|
|
109
|
+
print(f" Token savings {savings:.1f}%")
|
|
110
|
+
if extra:
|
|
111
|
+
print(extra)
|
|
112
|
+
print("─" * 52)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
parser = argparse.ArgumentParser(prog="zerodom", description=__doc__)
|
|
118
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
119
|
+
|
|
120
|
+
insp = sub.add_parser("inspect", help="parse a URL and report token savings")
|
|
121
|
+
insp.add_argument("url")
|
|
122
|
+
insp.add_argument(
|
|
123
|
+
"--render",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="fetch via headless Chromium instead of plain HTTP (for JS-rendered pages)",
|
|
126
|
+
)
|
|
127
|
+
insp.add_argument(
|
|
128
|
+
"--json",
|
|
129
|
+
dest="as_json",
|
|
130
|
+
action="store_true",
|
|
131
|
+
help="emit the full JSON graph instead of the token-dense text DSL",
|
|
132
|
+
)
|
|
133
|
+
insp.add_argument(
|
|
134
|
+
"--find",
|
|
135
|
+
metavar="QUERY",
|
|
136
|
+
help="print only the nodes whose type or label matches QUERY",
|
|
137
|
+
)
|
|
138
|
+
insp.add_argument(
|
|
139
|
+
"--screenshot",
|
|
140
|
+
metavar="OUT.png",
|
|
141
|
+
help="write a full-page screenshot with a numbered badge over every node",
|
|
142
|
+
)
|
|
143
|
+
insp.add_argument(
|
|
144
|
+
"--html",
|
|
145
|
+
dest="html_path",
|
|
146
|
+
metavar="OUT.html",
|
|
147
|
+
help="write a self-contained HTML report: graph beside the annotated page",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# `inspect` is the only verb, so requiring it is pure ceremony: `zerodom <url>`
|
|
151
|
+
# works, and the explicit form keeps working for anyone who learned it.
|
|
152
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
153
|
+
if argv and argv[0] not in {"inspect", "-h", "--help"} and not argv[0].startswith("-"):
|
|
154
|
+
argv = ["inspect", *argv]
|
|
155
|
+
|
|
156
|
+
args = parser.parse_args(argv)
|
|
157
|
+
return inspect(
|
|
158
|
+
args.url, args.render, args.as_json, args.screenshot, args.html_path, args.find
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
zerodom/label_linker.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Work out what a control is called, the way a person reading the page would."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lxml.html import HtmlElement
|
|
6
|
+
|
|
7
|
+
# Attribute fallbacks, most explicit first. Split around the adjacent-text
|
|
8
|
+
# scan: authored attributes beat scraped text, but scraped text beats `name`/`value`,
|
|
9
|
+
# which are developer identifiers ("q") rather than anything a user reads.
|
|
10
|
+
AUTHORED_ATTRS = ("aria-label", "placeholder", "alt", "title")
|
|
11
|
+
IDENTIFIER_ATTRS = ("value", "name")
|
|
12
|
+
|
|
13
|
+
# Controls that sit next to their caption. Buttons and links carry their own text.
|
|
14
|
+
CAPTIONED_TAGS = {"input", "select", "textarea"}
|
|
15
|
+
|
|
16
|
+
# Inputs whose `value` is a button caption rather than user data.
|
|
17
|
+
BUTTON_INPUT_TYPES = {"submit", "button", "reset", "image"}
|
|
18
|
+
|
|
19
|
+
MAX_LABEL_LEN = 80
|
|
20
|
+
|
|
21
|
+
# Trailing punctuation on a caption: "Search:" -> "Search".
|
|
22
|
+
CAPTION_STRIP = " \t\n\xa0:*>-–—"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def text_of(el: HtmlElement) -> str:
|
|
26
|
+
"""Visible text of an element, whitespace-collapsed and length-capped."""
|
|
27
|
+
return " ".join(el.text_content().split())[:MAX_LABEL_LEN]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def adjacent_text(el: HtmlElement) -> str:
|
|
31
|
+
"""Caption sitting immediately before an input, as in `Search: <input>`.
|
|
32
|
+
|
|
33
|
+
Reads the previous sibling's tail, or the parent's leading text when the input
|
|
34
|
+
is the first child. Only the last few words are kept — the text run before an
|
|
35
|
+
input is often a whole sentence of surrounding copy, not a caption.
|
|
36
|
+
"""
|
|
37
|
+
parent = el.getparent()
|
|
38
|
+
if parent is None:
|
|
39
|
+
return ""
|
|
40
|
+
previous = el.getprevious()
|
|
41
|
+
source = previous.tail if previous is not None else parent.text
|
|
42
|
+
caption = " ".join((source or "").split()).rstrip(CAPTION_STRIP)
|
|
43
|
+
if not caption:
|
|
44
|
+
return ""
|
|
45
|
+
return " ".join(caption.split()[-6:])[:MAX_LABEL_LEN]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LabelLinker:
|
|
49
|
+
"""Resolves labels against a pre-indexed set of <label> tags.
|
|
50
|
+
|
|
51
|
+
The parser walks the DOM once and hands over what it saw, so label lookup
|
|
52
|
+
stays O(1) per node instead of re-searching the tree for every input.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, root: HtmlElement, labels_by_for: dict[str, HtmlElement] | None = None
|
|
57
|
+
) -> None:
|
|
58
|
+
self.root = root
|
|
59
|
+
if labels_by_for is None:
|
|
60
|
+
labels_by_for = {}
|
|
61
|
+
for tag in root.iter("label"):
|
|
62
|
+
if (for_id := tag.get("for")) and for_id not in labels_by_for:
|
|
63
|
+
labels_by_for[for_id] = tag
|
|
64
|
+
self.labels_by_for = labels_by_for
|
|
65
|
+
|
|
66
|
+
def label(self, element: HtmlElement, ancestor_label: HtmlElement | None = None) -> str:
|
|
67
|
+
"""Resolve a human-readable label for an interactive element."""
|
|
68
|
+
# explicit <label for="...">
|
|
69
|
+
elem_id = element.get("id")
|
|
70
|
+
if elem_id and (tag := self.labels_by_for.get(elem_id)) is not None:
|
|
71
|
+
if text := text_of(tag):
|
|
72
|
+
return text
|
|
73
|
+
|
|
74
|
+
# wrapping <label>
|
|
75
|
+
if ancestor_label is None:
|
|
76
|
+
ancestor_label = next(element.iterancestors("label"), None)
|
|
77
|
+
if ancestor_label is not None and (text := text_of(ancestor_label)):
|
|
78
|
+
return text
|
|
79
|
+
|
|
80
|
+
# aria-labelledby -> referenced element's text
|
|
81
|
+
for ref_id in element.get("aria-labelledby", "").split():
|
|
82
|
+
refs = self.root.xpath("//*[@id=$ref]", ref=ref_id)
|
|
83
|
+
if refs and (text := text_of(refs[0])):
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
# authored attribute fallbacks
|
|
87
|
+
for attr in AUTHORED_ATTRS:
|
|
88
|
+
if (val := element.get(attr)) and val.strip():
|
|
89
|
+
return val.strip()[:MAX_LABEL_LEN]
|
|
90
|
+
|
|
91
|
+
# On a submit/button input, `value` is the visible caption, not an identifier.
|
|
92
|
+
if element.tag == "input" and element.get("type") in BUTTON_INPUT_TYPES:
|
|
93
|
+
if (val := element.get("value")) and val.strip():
|
|
94
|
+
return val.strip()[:MAX_LABEL_LEN]
|
|
95
|
+
|
|
96
|
+
# Caption text next to the control, before falling back to identifiers
|
|
97
|
+
if element.tag in CAPTIONED_TAGS and (caption := adjacent_text(element)):
|
|
98
|
+
return caption
|
|
99
|
+
|
|
100
|
+
# Inner text (buttons, links). Skipped for captioned controls, whose contents
|
|
101
|
+
# are their value or options — a <select>'s first <option> is not its label.
|
|
102
|
+
if element.tag not in CAPTIONED_TAGS and (text := text_of(element)):
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
for attr in IDENTIFIER_ATTRS:
|
|
106
|
+
if (val := element.get(attr)) and val.strip():
|
|
107
|
+
return val.strip()[:MAX_LABEL_LEN]
|
|
108
|
+
|
|
109
|
+
# Image-only links and buttons carry their label on the <img>.
|
|
110
|
+
for img in element.iterdescendants("img"):
|
|
111
|
+
if alt := (img.get("alt") or img.get("title") or "").strip():
|
|
112
|
+
return alt[:MAX_LABEL_LEN]
|
|
113
|
+
|
|
114
|
+
return "Unlabelled Element"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_label(root: HtmlElement, element: HtmlElement) -> str:
|
|
118
|
+
"""Standalone label lookup for a single element."""
|
|
119
|
+
return LabelLinker(root).label(element)
|
zerodom/mcp_server.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Model Context Protocol server: drive a browser through the interaction graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp.server import MCPServer
|
|
8
|
+
|
|
9
|
+
from .parser import ZeroDOMParser, compact_line, find_nodes
|
|
10
|
+
from .playwright_wrapper import serialize_async
|
|
11
|
+
|
|
12
|
+
mcp = MCPServer("zerodom", version="0.0.1")
|
|
13
|
+
|
|
14
|
+
# ponytail: one global browser session — the MCP server drives a single agent.
|
|
15
|
+
# Add a session_id parameter only if concurrent pages are ever needed.
|
|
16
|
+
_session: dict[str, Any] = {
|
|
17
|
+
"pw": None, "browser": None, "page": None,
|
|
18
|
+
"selectors": {}, "nodes": [], "url": None,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _page() -> Any:
|
|
23
|
+
if _session["page"] is None:
|
|
24
|
+
from playwright.async_api import async_playwright
|
|
25
|
+
|
|
26
|
+
pw = await async_playwright().start()
|
|
27
|
+
browser = await pw.chromium.launch()
|
|
28
|
+
_session.update(pw=pw, browser=browser, page=await browser.new_page())
|
|
29
|
+
return _session["page"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _selector(node_id: str) -> str:
|
|
33
|
+
"""Resolve a node id to its CSS selector — the lookup map the model never sees.
|
|
34
|
+
|
|
35
|
+
Accepts both the bare index the compact graph prints (`[03]` -> "3") and the
|
|
36
|
+
full "node_03" form used in the JSON graph.
|
|
37
|
+
"""
|
|
38
|
+
key = node_id if node_id.startswith("node_") else f"node_{node_id.strip('[]').zfill(2)}"
|
|
39
|
+
selector = _session["selectors"].get(key)
|
|
40
|
+
if not selector:
|
|
41
|
+
raise ValueError(f"Unknown node '{node_id}'. Call zerodom_parse_url first.")
|
|
42
|
+
return selector
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _identity(node: dict[str, Any]) -> tuple[str, str, str]:
|
|
46
|
+
"""What makes a node "the same node" across two reads. Node ids renumber on
|
|
47
|
+
every parse, so they cannot be it."""
|
|
48
|
+
return (node["type"], node["label"], node["selector"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _diff(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> str | None:
|
|
52
|
+
"""Only what changed, or None when a full graph would be more honest.
|
|
53
|
+
|
|
54
|
+
An agent clicking through a page re-reads the same few hundred nodes over and
|
|
55
|
+
over; on Hacker News that is ~2,350 tokens per action to say "one menu opened".
|
|
56
|
+
A wholesale change (a navigation) has no useful diff, so it falls back.
|
|
57
|
+
"""
|
|
58
|
+
old, new = {_identity(n): n for n in before}, {_identity(n): n for n in after}
|
|
59
|
+
added = [n for key, n in new.items() if key not in old]
|
|
60
|
+
removed = [n for key, n in old.items() if key not in new]
|
|
61
|
+
changed = [
|
|
62
|
+
(old[key], n)
|
|
63
|
+
for key, n in new.items()
|
|
64
|
+
if key in old and n.get("value") != old[key].get("value")
|
|
65
|
+
]
|
|
66
|
+
if not (added or removed or changed):
|
|
67
|
+
# Typing sets the DOM *property*, not the `value` attribute, so text a user
|
|
68
|
+
# entered is invisible to any serializer. The fill tool echoes it instead.
|
|
69
|
+
return "no structural change"
|
|
70
|
+
# More than half the page moved: it is a different page, so show all of it.
|
|
71
|
+
if len(added) + len(removed) > len(new) / 2:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
lines = [f"+{compact_line(n)}" for n in added]
|
|
75
|
+
lines += [f"-{compact_line(n)}" for n in removed]
|
|
76
|
+
lines += [
|
|
77
|
+
f"~{compact_line(n)} {was.get('value', '')!r} -> {n.get('value', '')!r}"
|
|
78
|
+
for was, n in changed
|
|
79
|
+
]
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _read(verbose: bool = False, diff: bool = False) -> str:
|
|
84
|
+
"""Parse the live DOM in place and refresh the selector map.
|
|
85
|
+
|
|
86
|
+
Never navigates, so form state, scroll position and cookies survive. Every
|
|
87
|
+
action re-reads through here, which is what keeps node ids pointing at the
|
|
88
|
+
page the model is actually looking at.
|
|
89
|
+
"""
|
|
90
|
+
page = await _page()
|
|
91
|
+
# A click may still be navigating; content() during that raises.
|
|
92
|
+
await page.wait_for_load_state("domcontentloaded")
|
|
93
|
+
graph = ZeroDOMParser(await serialize_async(page), page.url).parse()
|
|
94
|
+
previous, url = _session["nodes"], _session["url"]
|
|
95
|
+
_session.update(
|
|
96
|
+
selectors=graph.selector_map(), nodes=graph["nodes"], url=page.url
|
|
97
|
+
)
|
|
98
|
+
if verbose:
|
|
99
|
+
return graph.to_json()
|
|
100
|
+
# A navigation invalidates every id, so a diff against the old page is noise.
|
|
101
|
+
if diff and previous and url == page.url and (delta := _diff(previous, graph["nodes"])):
|
|
102
|
+
return delta
|
|
103
|
+
return graph.to_compact_text()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
async def zerodom_parse_url(url: str, verbose: bool = False) -> str:
|
|
108
|
+
"""Navigate to a URL and return its interaction graph.
|
|
109
|
+
|
|
110
|
+
Returns the compact text graph: `[03] button 'Sign In'`. CSS selectors are
|
|
111
|
+
kept server-side and resolved by node id, so they never cost context — pass
|
|
112
|
+
verbose=True for the full JSON including selectors.
|
|
113
|
+
"""
|
|
114
|
+
page = await _page()
|
|
115
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
116
|
+
return await _read(verbose)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@mcp.tool()
|
|
120
|
+
async def zerodom_read_page(verbose: bool = False) -> str:
|
|
121
|
+
"""Re-read the current page without navigating.
|
|
122
|
+
|
|
123
|
+
Use after an action changed the page, or when node ids look stale. Unlike
|
|
124
|
+
zerodom_parse_url this does not reload, so anything typed into the page stays.
|
|
125
|
+
"""
|
|
126
|
+
return await _read(verbose)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@mcp.tool()
|
|
130
|
+
async def zerodom_find(query: str) -> str:
|
|
131
|
+
"""Search the current page's graph for nodes matching `query`.
|
|
132
|
+
|
|
133
|
+
Case-insensitive substring match over each node's label and type. Prefer this
|
|
134
|
+
over re-reading the whole page when you already know what you are looking for:
|
|
135
|
+
"checkout" costs three lines, the full graph costs every node on the page.
|
|
136
|
+
"""
|
|
137
|
+
hits = find_nodes(_session["nodes"], query)
|
|
138
|
+
if not _session["nodes"]:
|
|
139
|
+
return "No page loaded. Call zerodom_parse_url first."
|
|
140
|
+
if not hits:
|
|
141
|
+
return f"No node matches {query!r} among {len(_session['nodes'])} nodes."
|
|
142
|
+
return "\n".join(compact_line(node) for node in hits)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@mcp.tool()
|
|
146
|
+
async def zerodom_click_node(node_id: str) -> str:
|
|
147
|
+
"""Click a node and return what changed on the page.
|
|
148
|
+
|
|
149
|
+
Returns a diff — `+` appeared, `-` gone, `~` value changed — because most
|
|
150
|
+
clicks alter a handful of nodes and re-listing the page would cost hundreds.
|
|
151
|
+
A navigation renumbers everything, so that returns the full graph instead.
|
|
152
|
+
"""
|
|
153
|
+
page = await _page()
|
|
154
|
+
selector = _selector(node_id)
|
|
155
|
+
before = page.url
|
|
156
|
+
await page.click(selector)
|
|
157
|
+
graph = await _read(diff=True)
|
|
158
|
+
moved = f" -> {page.url}" if page.url != before else ""
|
|
159
|
+
return f"clicked [{node_id}]{moved}\n\n{graph}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@mcp.tool()
|
|
163
|
+
async def zerodom_fill_node(node_id: str, text: str) -> str:
|
|
164
|
+
"""Type text into a node and return what changed on the page.
|
|
165
|
+
|
|
166
|
+
The text is echoed back in the first line; the diff below it reports *structural*
|
|
167
|
+
change — a validation error appearing, an autocomplete list opening.
|
|
168
|
+
"""
|
|
169
|
+
page = await _page()
|
|
170
|
+
selector = _selector(node_id)
|
|
171
|
+
await page.fill(selector, text)
|
|
172
|
+
return f"filled [{node_id}] with {text!r}\n\n{await _read(diff=True)}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main() -> None:
|
|
176
|
+
mcp.run()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|