@vexralabs/zerodom 0.0.1
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.
- package/LICENSE +73 -0
- package/README.md +53 -0
- package/dist/frames.d.ts +55 -0
- package/dist/frames.js +84 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/labelLinker.d.ts +25 -0
- package/dist/labelLinker.js +233 -0
- package/dist/parser.d.ts +168 -0
- package/dist/parser.js +501 -0
- package/dist/playwright.d.ts +30 -0
- package/dist/playwright.js +130 -0
- package/package.json +41 -0
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core DOM-to-Interaction-Graph engine.
|
|
3
|
+
*
|
|
4
|
+
* Built on linkedom rather than jsdom: it implements the same DOM API (querySelector,
|
|
5
|
+
* getElementById, standard traversal) with a much smaller footprint. Like lxml on the
|
|
6
|
+
* Python side, it treats `<template>` as an ordinary element for traversal purposes
|
|
7
|
+
* rather than splitting its children into a separate content fragment — verified
|
|
8
|
+
* empirically, since that's exactly the behavior the shadow-DOM logic below depends on.
|
|
9
|
+
* Direct port of zerodom/parser.py.
|
|
10
|
+
*/
|
|
11
|
+
export { LabelLinker, textOf, getLabel } from "./labelLinker.js";
|
|
12
|
+
/**
|
|
13
|
+
* A declarative shadow root: `<template shadowrootmode="open">`.
|
|
14
|
+
*
|
|
15
|
+
* Inert `<template>` content is pruned, but this one is a live subtree the browser
|
|
16
|
+
* renders — Chromium serializes open shadow roots back into exactly this form.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isShadowRoot(el: Element): boolean;
|
|
19
|
+
/** `#id` when that is legal CSS, otherwise an equivalent attribute selector. */
|
|
20
|
+
export declare function cssId(value: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Escape a value for a single-quoted CSS attribute selector.
|
|
23
|
+
*
|
|
24
|
+
* `name="it's"` must not produce `input[name='it's']`, which is a parse error.
|
|
25
|
+
*/
|
|
26
|
+
export declare function cssString(value: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* A `.class` token when the name is legal CSS, else an exact-word attribute
|
|
29
|
+
* selector. `class="a.b"` must not become `div.a.b` — that reads as two classes and
|
|
30
|
+
* matches a *different* element — and `class="1x"` must not become `.1x`, which is
|
|
31
|
+
* a parse error.
|
|
32
|
+
*/
|
|
33
|
+
export declare function classToken(value: string): string;
|
|
34
|
+
export interface GraphNode {
|
|
35
|
+
id: string;
|
|
36
|
+
type: string;
|
|
37
|
+
role: string;
|
|
38
|
+
label: string;
|
|
39
|
+
selector: string;
|
|
40
|
+
input_type?: string;
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
href?: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
value?: string;
|
|
46
|
+
action: "click" | "fill";
|
|
47
|
+
frame?: string[];
|
|
48
|
+
frame_url?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface GraphMetadata {
|
|
51
|
+
page_title: string;
|
|
52
|
+
url: string;
|
|
53
|
+
total_interactive_nodes: number;
|
|
54
|
+
parsing_latency_ms: number;
|
|
55
|
+
warning?: string;
|
|
56
|
+
frames_read?: number;
|
|
57
|
+
frames_skipped?: number;
|
|
58
|
+
}
|
|
59
|
+
/** One node as the compact DSL renders it. Shared so partial views — a search
|
|
60
|
+
* result, a diff — never drift from the format the full graph prints. */
|
|
61
|
+
export declare function compactLine(node: GraphNode, selectors?: boolean, hrefs?: boolean): string;
|
|
62
|
+
/**
|
|
63
|
+
* Nodes whose type or label contains every whitespace-separated term.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately dumb: substring, case-folded, no stemming or synonyms.
|
|
66
|
+
*/
|
|
67
|
+
export declare function findNodes(nodes: GraphNode[], query: string): GraphNode[];
|
|
68
|
+
export declare class InteractionGraph {
|
|
69
|
+
nodes: GraphNode[];
|
|
70
|
+
metadata: GraphMetadata;
|
|
71
|
+
constructor(nodes: GraphNode[], metadata: GraphMetadata);
|
|
72
|
+
toJson(indent?: number): string;
|
|
73
|
+
/** `node_id -> css selector`, so callers can act on ids alone. */
|
|
74
|
+
selectorMap(): Record<string, string>;
|
|
75
|
+
/**
|
|
76
|
+
* Token-dense line format for model context.
|
|
77
|
+
*
|
|
78
|
+
* JSON spends most of its tokens on repeated keys, quotes and braces. This drops
|
|
79
|
+
* them, and by default drops the CSS selector too: a caller holding
|
|
80
|
+
* `selectorMap()` can resolve `[03]` back to a selector itself, so the model
|
|
81
|
+
* never needs to see it. Enable `selectors`/`hrefs` when the reader has no
|
|
82
|
+
* session to resolve ids against.
|
|
83
|
+
*
|
|
84
|
+
* `*` marks required, `!` marks disabled.
|
|
85
|
+
*/
|
|
86
|
+
toCompactText(options?: {
|
|
87
|
+
selectors?: boolean;
|
|
88
|
+
hrefs?: boolean;
|
|
89
|
+
}): string;
|
|
90
|
+
}
|
|
91
|
+
export declare class ZeroDOMParser {
|
|
92
|
+
private readonly url;
|
|
93
|
+
private readonly rawHtml;
|
|
94
|
+
private readonly document;
|
|
95
|
+
private readonly root;
|
|
96
|
+
private readonly nodes;
|
|
97
|
+
private occ;
|
|
98
|
+
private siblings;
|
|
99
|
+
private paths;
|
|
100
|
+
private shadow;
|
|
101
|
+
constructor(html: string, url?: string);
|
|
102
|
+
/**
|
|
103
|
+
* Hidden by an inline style, an ARIA attribute, or a boolean attribute.
|
|
104
|
+
*
|
|
105
|
+
* `data-zerodom-hidden` is set by the browser-side serializer, the only place a
|
|
106
|
+
* stylesheet rule can actually be resolved — parsing HTML text alone cannot see
|
|
107
|
+
* the cascade.
|
|
108
|
+
*/
|
|
109
|
+
private static isHidden;
|
|
110
|
+
/** Is this something an agent can act on? */
|
|
111
|
+
private static isInteractive;
|
|
112
|
+
private static role;
|
|
113
|
+
/**
|
|
114
|
+
* Occurrences per id / tag+name / tag+class, counted once per document.
|
|
115
|
+
*
|
|
116
|
+
* Uniqueness is only claimed against what the document actually has. A selector
|
|
117
|
+
* like `input[name='color']` looks unique to the author but matches every radio
|
|
118
|
+
* in the group — so `name` and `id` selectors are only emitted when their key
|
|
119
|
+
* occurs exactly once.
|
|
120
|
+
*/
|
|
121
|
+
private counts;
|
|
122
|
+
/** Cheapest CSS selector that uniquely targets this element. */
|
|
123
|
+
private selector;
|
|
124
|
+
private hasShadow;
|
|
125
|
+
/** The ancestor-or-self that is a direct child of a shadow root, if any. */
|
|
126
|
+
private static shadowBoundary;
|
|
127
|
+
/**
|
|
128
|
+
* Resolve light/shadow collisions that plain CSS cannot express.
|
|
129
|
+
*
|
|
130
|
+
* Playwright's CSS engine pierces open shadow roots, so `#host > button` matches
|
|
131
|
+
* a shadow button as well as a light one — the selector looks unique in the HTML
|
|
132
|
+
* and hits two elements at click time. Pages with no shadow root (nearly all of
|
|
133
|
+
* them) return here untouched and pay nothing.
|
|
134
|
+
*/
|
|
135
|
+
private disambiguate;
|
|
136
|
+
/**
|
|
137
|
+
* CSS path anchored at the nearest ancestor `#id`, else at the root.
|
|
138
|
+
*
|
|
139
|
+
* Memoized so shared ancestors are walked once, and anchoring on an id keeps the
|
|
140
|
+
* selector short — a full root path on a deeply nested page costs more tokens
|
|
141
|
+
* than the node it describes.
|
|
142
|
+
*/
|
|
143
|
+
private path;
|
|
144
|
+
/**
|
|
145
|
+
* `:nth-of-type(n)` for a child, or "" when its tag is unique among siblings.
|
|
146
|
+
*
|
|
147
|
+
* Positions are indexed per parent, not rescanned per node — rescanning is O(n^2)
|
|
148
|
+
* on flat pages with thousands of same-tag siblings.
|
|
149
|
+
*/
|
|
150
|
+
private nthOfType;
|
|
151
|
+
/**
|
|
152
|
+
* Single depth-first pass: prune, collect interactive nodes and labels.
|
|
153
|
+
*
|
|
154
|
+
* Skipping a pruned or hidden subtree outright is both the pruning rule and the
|
|
155
|
+
* fast path — nothing below it is ever visited.
|
|
156
|
+
*/
|
|
157
|
+
private walk;
|
|
158
|
+
parse(): InteractionGraph;
|
|
159
|
+
/**
|
|
160
|
+
* Say *why* the graph looks empty, when it does.
|
|
161
|
+
*
|
|
162
|
+
* A bot wall, an open modal and a genuinely bare page all return almost nothing,
|
|
163
|
+
* and the caller cannot tell them apart from the node list.
|
|
164
|
+
*/
|
|
165
|
+
private emptyPageWarning;
|
|
166
|
+
}
|
|
167
|
+
/** Convenience one-shot parse. */
|
|
168
|
+
export declare function parseHtml(html: string, url?: string): InteractionGraph;
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core DOM-to-Interaction-Graph engine.
|
|
3
|
+
*
|
|
4
|
+
* Built on linkedom rather than jsdom: it implements the same DOM API (querySelector,
|
|
5
|
+
* getElementById, standard traversal) with a much smaller footprint. Like lxml on the
|
|
6
|
+
* Python side, it treats `<template>` as an ordinary element for traversal purposes
|
|
7
|
+
* rather than splitting its children into a separate content fragment — verified
|
|
8
|
+
* empirically, since that's exactly the behavior the shadow-DOM logic below depends on.
|
|
9
|
+
* Direct port of zerodom/parser.py.
|
|
10
|
+
*/
|
|
11
|
+
import { parseHTML } from "linkedom";
|
|
12
|
+
import { LabelLinker, textOf } from "./labelLinker.js";
|
|
13
|
+
export { LabelLinker, textOf, getLabel } from "./labelLinker.js";
|
|
14
|
+
// `head` is deliberately absent: everything bloated inside it is pruned by name
|
|
15
|
+
// anyway, and a parser that mis-files stray body content into head would lose those
|
|
16
|
+
// nodes if head were skipped wholesale.
|
|
17
|
+
const PRUNE_TAGS = new Set(["script", "style", "link", "svg", "meta", "noscript", "template"]);
|
|
18
|
+
/**
|
|
19
|
+
* A declarative shadow root: `<template shadowrootmode="open">`.
|
|
20
|
+
*
|
|
21
|
+
* Inert `<template>` content is pruned, but this one is a live subtree the browser
|
|
22
|
+
* renders — Chromium serializes open shadow roots back into exactly this form.
|
|
23
|
+
*/
|
|
24
|
+
export function isShadowRoot(el) {
|
|
25
|
+
return el.localName === "template" && el.hasAttribute("shadowrootmode");
|
|
26
|
+
}
|
|
27
|
+
const INTERACTIVE_TAGS = new Set(["a", "button", "input", "select", "textarea"]);
|
|
28
|
+
const INTERACTIVE_ROLES = new Set([
|
|
29
|
+
"button", "link", "textbox", "checkbox", "radio", "combobox",
|
|
30
|
+
"searchbox", "switch", "menuitem", "tab", "option", "slider",
|
|
31
|
+
]);
|
|
32
|
+
// input[type] -> ARIA role. Anything unlisted (text, email, password, ...) is a textbox.
|
|
33
|
+
const INPUT_ROLES = {
|
|
34
|
+
checkbox: "checkbox",
|
|
35
|
+
radio: "radio",
|
|
36
|
+
submit: "button",
|
|
37
|
+
button: "button",
|
|
38
|
+
reset: "button",
|
|
39
|
+
image: "button",
|
|
40
|
+
file: "button", // ARIA: opens a chooser — clickable, not fillable
|
|
41
|
+
search: "searchbox",
|
|
42
|
+
range: "slider",
|
|
43
|
+
};
|
|
44
|
+
const TAG_ROLES = { a: "link", button: "button", select: "combobox", textarea: "textbox" };
|
|
45
|
+
// Roles driven by typing text rather than clicking.
|
|
46
|
+
const FILLABLE_ROLES = new Set(["textbox", "searchbox", "slider"]);
|
|
47
|
+
// A CSS identifier cannot start with a digit, so `#49151933` is a parse error, not a
|
|
48
|
+
// miss — querySelector throws on it. Hacker News numbers every row that way.
|
|
49
|
+
const CSS_IDENT = /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/;
|
|
50
|
+
const DOCUMENT_START = /^\s*(<!doctype\s+html|<html[\s>])/i;
|
|
51
|
+
/**
|
|
52
|
+
* Unlike lxml.html.document_fromstring (or a real browser), linkedom's parser does
|
|
53
|
+
* not normalize a bare fragment into a full document — it takes the first top-level
|
|
54
|
+
* tag as `documentElement` and silently drops any siblings after it. `page.content()`
|
|
55
|
+
* always returns a full document, so this only matters for hand-fed HTML, but it has
|
|
56
|
+
* to be handled once, here, rather than requiring every caller to know about it.
|
|
57
|
+
*/
|
|
58
|
+
function normalizeDocument(html) {
|
|
59
|
+
const trimmed = html.trim();
|
|
60
|
+
if (!trimmed)
|
|
61
|
+
return "<html><body></body></html>";
|
|
62
|
+
return DOCUMENT_START.test(trimmed) ? trimmed : `<html><body>${trimmed}</body></html>`;
|
|
63
|
+
}
|
|
64
|
+
/** `#id` when that is legal CSS, otherwise an equivalent attribute selector. */
|
|
65
|
+
export function cssId(value) {
|
|
66
|
+
if (CSS_IDENT.test(value))
|
|
67
|
+
return `#${value}`;
|
|
68
|
+
return `[id="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Escape a value for a single-quoted CSS attribute selector.
|
|
72
|
+
*
|
|
73
|
+
* `name="it's"` must not produce `input[name='it's']`, which is a parse error.
|
|
74
|
+
*/
|
|
75
|
+
export function cssString(value) {
|
|
76
|
+
return value
|
|
77
|
+
.replace(/\\/g, "\\\\")
|
|
78
|
+
.replace(/'/g, "\\'")
|
|
79
|
+
.replace(/\n/g, "\\a ")
|
|
80
|
+
.replace(/\r/g, "\\d ");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A `.class` token when the name is legal CSS, else an exact-word attribute
|
|
84
|
+
* selector. `class="a.b"` must not become `div.a.b` — that reads as two classes and
|
|
85
|
+
* matches a *different* element — and `class="1x"` must not become `.1x`, which is
|
|
86
|
+
* a parse error.
|
|
87
|
+
*/
|
|
88
|
+
export function classToken(value) {
|
|
89
|
+
if (CSS_IDENT.test(value))
|
|
90
|
+
return `.${value}`;
|
|
91
|
+
return `[class~='${cssString(value)}']`;
|
|
92
|
+
}
|
|
93
|
+
/** One node as the compact DSL renders it. Shared so partial views — a search
|
|
94
|
+
* result, a diff — never drift from the format the full graph prints. */
|
|
95
|
+
export function compactLine(node, selectors = false, hrefs = false) {
|
|
96
|
+
let flags = node.required ? "*" : "";
|
|
97
|
+
flags += node.disabled ? "!" : "";
|
|
98
|
+
let line = `[${node.id.replace(/^node_/, "")}] ${node.type}${flags} ${JSON.stringify(node.label)}`;
|
|
99
|
+
if (node.placeholder)
|
|
100
|
+
line += ` ph=${JSON.stringify(node.placeholder)}`;
|
|
101
|
+
if (hrefs && node.href)
|
|
102
|
+
line += ` -> ${node.href}`;
|
|
103
|
+
if (selectors)
|
|
104
|
+
line += ` (${node.selector})`;
|
|
105
|
+
return line;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Nodes whose type or label contains every whitespace-separated term.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately dumb: substring, case-folded, no stemming or synonyms.
|
|
111
|
+
*/
|
|
112
|
+
export function findNodes(nodes, query) {
|
|
113
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
114
|
+
return nodes.filter((node) => {
|
|
115
|
+
const haystack = `${node.type} ${node.label}`.toLowerCase();
|
|
116
|
+
return terms.every((term) => haystack.includes(term));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export class InteractionGraph {
|
|
120
|
+
nodes;
|
|
121
|
+
metadata;
|
|
122
|
+
constructor(nodes, metadata) {
|
|
123
|
+
this.nodes = nodes;
|
|
124
|
+
this.metadata = metadata;
|
|
125
|
+
}
|
|
126
|
+
toJson(indent = 2) {
|
|
127
|
+
return JSON.stringify({ nodes: this.nodes, metadata: this.metadata }, null, indent || undefined);
|
|
128
|
+
}
|
|
129
|
+
/** `node_id -> css selector`, so callers can act on ids alone. */
|
|
130
|
+
selectorMap() {
|
|
131
|
+
const map = {};
|
|
132
|
+
for (const node of this.nodes)
|
|
133
|
+
map[node.id] = node.selector;
|
|
134
|
+
return map;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Token-dense line format for model context.
|
|
138
|
+
*
|
|
139
|
+
* JSON spends most of its tokens on repeated keys, quotes and braces. This drops
|
|
140
|
+
* them, and by default drops the CSS selector too: a caller holding
|
|
141
|
+
* `selectorMap()` can resolve `[03]` back to a selector itself, so the model
|
|
142
|
+
* never needs to see it. Enable `selectors`/`hrefs` when the reader has no
|
|
143
|
+
* session to resolve ids against.
|
|
144
|
+
*
|
|
145
|
+
* `*` marks required, `!` marks disabled.
|
|
146
|
+
*/
|
|
147
|
+
toCompactText(options = {}) {
|
|
148
|
+
const lines = [`PAGE: ${this.metadata.page_title} | ${this.metadata.url}`];
|
|
149
|
+
for (const node of this.nodes)
|
|
150
|
+
lines.push(compactLine(node, options.selectors, options.hrefs));
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export class ZeroDOMParser {
|
|
155
|
+
url;
|
|
156
|
+
rawHtml;
|
|
157
|
+
document;
|
|
158
|
+
root;
|
|
159
|
+
nodes = [];
|
|
160
|
+
// (kind:key) -> occurrences, one pass over the tree, keyed so each kind of
|
|
161
|
+
// uniqueness claim — id, tag|name, tag|class — is counted separately.
|
|
162
|
+
occ = null;
|
|
163
|
+
// parent element -> (child -> nth-of-type position, tag -> sibling count)
|
|
164
|
+
siblings = new Map();
|
|
165
|
+
paths = new Map();
|
|
166
|
+
shadow = null;
|
|
167
|
+
constructor(html, url = "about:blank") {
|
|
168
|
+
this.url = url;
|
|
169
|
+
this.rawHtml = html;
|
|
170
|
+
const { document } = parseHTML(normalizeDocument(html));
|
|
171
|
+
this.document = document;
|
|
172
|
+
this.root = document.documentElement;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Hidden by an inline style, an ARIA attribute, or a boolean attribute.
|
|
176
|
+
*
|
|
177
|
+
* `data-zerodom-hidden` is set by the browser-side serializer, the only place a
|
|
178
|
+
* stylesheet rule can actually be resolved — parsing HTML text alone cannot see
|
|
179
|
+
* the cascade.
|
|
180
|
+
*/
|
|
181
|
+
static isHidden(el) {
|
|
182
|
+
if (el.hasAttribute("data-zerodom-hidden"))
|
|
183
|
+
return true;
|
|
184
|
+
const style = (el.getAttribute("style") ?? "").toLowerCase().replace(/\s+/g, "");
|
|
185
|
+
if (style.includes("display:none") || style.includes("visibility:hidden"))
|
|
186
|
+
return true;
|
|
187
|
+
if (el.getAttribute("aria-hidden") === "true" || el.hasAttribute("hidden"))
|
|
188
|
+
return true;
|
|
189
|
+
return el.localName === "input" && el.getAttribute("type") === "hidden";
|
|
190
|
+
}
|
|
191
|
+
/** Is this something an agent can act on? */
|
|
192
|
+
static isInteractive(el) {
|
|
193
|
+
if (INTERACTIVE_TAGS.has(el.localName)) {
|
|
194
|
+
// A bare <a> with no href is a named anchor, not something to click.
|
|
195
|
+
if (el.localName === "a" && !(el.hasAttribute("href") || el.hasAttribute("onclick")))
|
|
196
|
+
return false;
|
|
197
|
+
// Nor is `<a href="#section" id="section"></a>` — the empty fragment anchors
|
|
198
|
+
// GitHub-rendered markdown scatters through a page are link *destinations*.
|
|
199
|
+
// Clicking one does nothing.
|
|
200
|
+
if (el.localName === "a" &&
|
|
201
|
+
(el.getAttribute("href") ?? "").startsWith("#") &&
|
|
202
|
+
el.children.length === 0 &&
|
|
203
|
+
!(el.textContent ?? "").trim()) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
const role = el.getAttribute("role");
|
|
209
|
+
if (role && INTERACTIVE_ROLES.has(role))
|
|
210
|
+
return true;
|
|
211
|
+
return el.hasAttribute("onclick") || el.hasAttribute("tabindex");
|
|
212
|
+
}
|
|
213
|
+
static role(el) {
|
|
214
|
+
const role = el.getAttribute("role");
|
|
215
|
+
if (role)
|
|
216
|
+
return role;
|
|
217
|
+
if (el.localName === "input")
|
|
218
|
+
return INPUT_ROLES[el.getAttribute("type") ?? "text"] ?? "textbox";
|
|
219
|
+
return TAG_ROLES[el.localName] ?? "button";
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Occurrences per id / tag+name / tag+class, counted once per document.
|
|
223
|
+
*
|
|
224
|
+
* Uniqueness is only claimed against what the document actually has. A selector
|
|
225
|
+
* like `input[name='color']` looks unique to the author but matches every radio
|
|
226
|
+
* in the group — so `name` and `id` selectors are only emitted when their key
|
|
227
|
+
* occurs exactly once.
|
|
228
|
+
*/
|
|
229
|
+
counts() {
|
|
230
|
+
if (this.occ === null) {
|
|
231
|
+
const counts = new Map();
|
|
232
|
+
const bump = (key) => counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
233
|
+
for (const el of [this.root, ...Array.from(this.root.querySelectorAll("*"))]) {
|
|
234
|
+
const id = el.getAttribute("id");
|
|
235
|
+
if (id)
|
|
236
|
+
bump(`i:${id}`);
|
|
237
|
+
const name = el.getAttribute("name");
|
|
238
|
+
if (name)
|
|
239
|
+
bump(`n:${el.localName}:${name}`);
|
|
240
|
+
for (const cls of (el.getAttribute("class") ?? "").split(/\s+/).filter(Boolean)) {
|
|
241
|
+
bump(`c:${el.localName}:${cls}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.occ = counts;
|
|
245
|
+
}
|
|
246
|
+
return this.occ;
|
|
247
|
+
}
|
|
248
|
+
/** Cheapest CSS selector that uniquely targets this element. */
|
|
249
|
+
selector(el) {
|
|
250
|
+
const id = el.getAttribute("id");
|
|
251
|
+
if (id && this.counts().get(`i:${id}`) === 1)
|
|
252
|
+
return cssId(id);
|
|
253
|
+
const name = el.getAttribute("name");
|
|
254
|
+
if (name && this.counts().get(`n:${el.localName}:${name}`) === 1) {
|
|
255
|
+
return `${el.localName}[name='${cssString(name)}']`;
|
|
256
|
+
}
|
|
257
|
+
// Only claim uniqueness when a single class already pins the element down; a
|
|
258
|
+
// pair that is jointly unique is possible but not worth an xpath-equivalent per node.
|
|
259
|
+
const classes = (el.getAttribute("class") ?? "").split(/\s+/).filter((c) => c && c.length < 30 && !c.includes(":"));
|
|
260
|
+
for (const cls of classes) {
|
|
261
|
+
if (this.counts().get(`c:${el.localName}:${cls}`) === 1)
|
|
262
|
+
return `${el.localName}${classToken(cls)}`;
|
|
263
|
+
}
|
|
264
|
+
// Structural fallback: a full ancestor path is verbose but always unique, unlike
|
|
265
|
+
// a bare `tag:nth-of-type(n)` which matches under every parent.
|
|
266
|
+
return this.disambiguate(el, this.path(el) || el.localName);
|
|
267
|
+
}
|
|
268
|
+
hasShadow() {
|
|
269
|
+
if (this.shadow === null) {
|
|
270
|
+
this.shadow = Array.from(this.root.querySelectorAll("template")).some(isShadowRoot);
|
|
271
|
+
}
|
|
272
|
+
return this.shadow;
|
|
273
|
+
}
|
|
274
|
+
/** The ancestor-or-self that is a direct child of a shadow root, if any. */
|
|
275
|
+
static shadowBoundary(el) {
|
|
276
|
+
let node = el;
|
|
277
|
+
let parent = el.parentElement;
|
|
278
|
+
while (parent !== null) {
|
|
279
|
+
if (isShadowRoot(parent))
|
|
280
|
+
return node;
|
|
281
|
+
node = parent;
|
|
282
|
+
parent = parent.parentElement;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Resolve light/shadow collisions that plain CSS cannot express.
|
|
288
|
+
*
|
|
289
|
+
* Playwright's CSS engine pierces open shadow roots, so `#host > button` matches
|
|
290
|
+
* a shadow button as well as a light one — the selector looks unique in the HTML
|
|
291
|
+
* and hits two elements at click time. Pages with no shadow root (nearly all of
|
|
292
|
+
* them) return here untouched and pay nothing.
|
|
293
|
+
*/
|
|
294
|
+
disambiguate(el, path) {
|
|
295
|
+
if (!this.hasShadow())
|
|
296
|
+
return path;
|
|
297
|
+
const boundary = ZeroDOMParser.shadowBoundary(el);
|
|
298
|
+
if (boundary === null) {
|
|
299
|
+
// `:light()` is Playwright's non-piercing matcher: scope the whole path,
|
|
300
|
+
// every hop, to the light tree the HTML actually described.
|
|
301
|
+
return `:light(${path})`;
|
|
302
|
+
}
|
|
303
|
+
// A shadow child collides only at the boundary hop; above it, both trees share
|
|
304
|
+
// the same ancestors. Positions are counted per tree, so an identical tag at an
|
|
305
|
+
// identical position on the host is the one ambiguous case.
|
|
306
|
+
const shadowParent = boundary.parentElement; // the <template shadowrootmode> itself
|
|
307
|
+
const host = shadowParent?.parentElement ?? null;
|
|
308
|
+
const segment = this.nthOfType(boundary, shadowParent);
|
|
309
|
+
const twin = host !== null &&
|
|
310
|
+
Array.from(host.children).some((child) => child.localName === boundary.localName && this.nthOfType(child, host) === segment);
|
|
311
|
+
// Light matches come first in Playwright's document order, so the twin is nth=1.
|
|
312
|
+
return twin ? `${path} >> nth=1` : path;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* CSS path anchored at the nearest ancestor `#id`, else at the root.
|
|
316
|
+
*
|
|
317
|
+
* Memoized so shared ancestors are walked once, and anchoring on an id keeps the
|
|
318
|
+
* selector short — a full root path on a deeply nested page costs more tokens
|
|
319
|
+
* than the node it describes.
|
|
320
|
+
*/
|
|
321
|
+
path(el) {
|
|
322
|
+
const chain = [];
|
|
323
|
+
let node = el;
|
|
324
|
+
let path;
|
|
325
|
+
for (;;) {
|
|
326
|
+
const cached = this.paths.get(node);
|
|
327
|
+
if (cached !== undefined) {
|
|
328
|
+
path = cached;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
const parent = node.parentElement;
|
|
332
|
+
if (parent === null) {
|
|
333
|
+
// reached <html>, which we leave implicit
|
|
334
|
+
path = "";
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
const nodeId = node.getAttribute("id");
|
|
338
|
+
if (node !== el && nodeId && this.counts().get(`i:${nodeId}`) === 1) {
|
|
339
|
+
path = cssId(nodeId);
|
|
340
|
+
this.paths.set(node, path);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
chain.push(node);
|
|
344
|
+
node = parent;
|
|
345
|
+
}
|
|
346
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
347
|
+
const n = chain[i];
|
|
348
|
+
// The shadow root itself has no live counterpart — the browser exposes its
|
|
349
|
+
// children as children of the host, and Playwright's `>` reaches them.
|
|
350
|
+
if (isShadowRoot(n)) {
|
|
351
|
+
this.paths.set(n, path);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
// Child combinator, not descendant: `:nth-of-type` is omitted when a tag is
|
|
355
|
+
// unique among its *siblings*, which only pins the element down if every hop
|
|
356
|
+
// is a direct one. Under a descendant combinator `span a` also matches an <a>
|
|
357
|
+
// nested two spans deep — on Hacker News that aimed "new" at the logo.
|
|
358
|
+
const parent = n.parentElement;
|
|
359
|
+
let segment = parent === null ? n.localName : `${n.localName}${this.nthOfType(n, parent)}`;
|
|
360
|
+
// A live DOM already has the <tbody> a <table> implies; this only matters
|
|
361
|
+
// when building the path from source HTML that hasn't been through a browser
|
|
362
|
+
// yet — kept for parity with a raw-source parse.
|
|
363
|
+
//
|
|
364
|
+
// Known limitation: a table that *mixes* authored sections with bare <tr>s
|
|
365
|
+
// splits into two live <tbody> elements, and only a tbody index tells them
|
|
366
|
+
// apart. That renumbering is skipped here, so rows in such tables may share
|
|
367
|
+
// a selector.
|
|
368
|
+
if (n.localName === "tr" && parent?.localName === "table")
|
|
369
|
+
segment = `tbody > ${segment}`;
|
|
370
|
+
path = path ? `${path} > ${segment}` : segment;
|
|
371
|
+
this.paths.set(n, path);
|
|
372
|
+
}
|
|
373
|
+
return path;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* `:nth-of-type(n)` for a child, or "" when its tag is unique among siblings.
|
|
377
|
+
*
|
|
378
|
+
* Positions are indexed per parent, not rescanned per node — rescanning is O(n^2)
|
|
379
|
+
* on flat pages with thousands of same-tag siblings.
|
|
380
|
+
*/
|
|
381
|
+
nthOfType(node, parent) {
|
|
382
|
+
let cache = this.siblings.get(parent);
|
|
383
|
+
if (!cache) {
|
|
384
|
+
const totals = new Map();
|
|
385
|
+
const positions = new Map();
|
|
386
|
+
for (const child of Array.from(parent.children)) {
|
|
387
|
+
const n = (totals.get(child.localName) ?? 0) + 1;
|
|
388
|
+
totals.set(child.localName, n);
|
|
389
|
+
positions.set(child, n);
|
|
390
|
+
}
|
|
391
|
+
cache = [positions, totals];
|
|
392
|
+
this.siblings.set(parent, cache);
|
|
393
|
+
}
|
|
394
|
+
const [positions, totals] = cache;
|
|
395
|
+
const position = positions.get(node);
|
|
396
|
+
if (position === undefined || (totals.get(node.localName) ?? 0) < 2)
|
|
397
|
+
return "";
|
|
398
|
+
return `:nth-of-type(${position})`;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Single depth-first pass: prune, collect interactive nodes and labels.
|
|
402
|
+
*
|
|
403
|
+
* Skipping a pruned or hidden subtree outright is both the pruning rule and the
|
|
404
|
+
* fast path — nothing below it is ever visited.
|
|
405
|
+
*/
|
|
406
|
+
walk() {
|
|
407
|
+
const found = [];
|
|
408
|
+
const labelsByFor = new Map();
|
|
409
|
+
// [element, nearest ancestor <label>], pushed reversed so pops stay in doc order.
|
|
410
|
+
const stack = [[this.root, null]];
|
|
411
|
+
while (stack.length) {
|
|
412
|
+
const [el, parentLabel] = stack.pop();
|
|
413
|
+
if ((PRUNE_TAGS.has(el.localName) && !isShadowRoot(el)) || ZeroDOMParser.isHidden(el))
|
|
414
|
+
continue;
|
|
415
|
+
let ancestorLabel = parentLabel;
|
|
416
|
+
if (el.localName === "label") {
|
|
417
|
+
ancestorLabel = el;
|
|
418
|
+
const forId = el.getAttribute("for");
|
|
419
|
+
if (forId && !labelsByFor.has(forId))
|
|
420
|
+
labelsByFor.set(forId, el);
|
|
421
|
+
}
|
|
422
|
+
if (ZeroDOMParser.isInteractive(el))
|
|
423
|
+
found.push([el, ancestorLabel]);
|
|
424
|
+
const children = Array.from(el.children);
|
|
425
|
+
for (let i = children.length - 1; i >= 0; i--)
|
|
426
|
+
stack.push([children[i], ancestorLabel]);
|
|
427
|
+
}
|
|
428
|
+
return { found, labelsByFor };
|
|
429
|
+
}
|
|
430
|
+
parse() {
|
|
431
|
+
const start = performance.now();
|
|
432
|
+
const titleEl = this.document.querySelector("title");
|
|
433
|
+
const title = titleEl ? textOf(titleEl) : "";
|
|
434
|
+
const { found, labelsByFor } = this.walk();
|
|
435
|
+
const linker = new LabelLinker(this.document, labelsByFor);
|
|
436
|
+
for (const [el, ancestorLabel] of found) {
|
|
437
|
+
const role = ZeroDOMParser.role(el);
|
|
438
|
+
const node = {
|
|
439
|
+
id: `node_${String(this.nodes.length + 1).padStart(2, "0")}`,
|
|
440
|
+
type: el.localName,
|
|
441
|
+
role,
|
|
442
|
+
label: linker.label(el, ancestorLabel),
|
|
443
|
+
selector: this.selector(el),
|
|
444
|
+
action: FILLABLE_ROLES.has(role) ? "fill" : "click",
|
|
445
|
+
};
|
|
446
|
+
if (el.localName === "input")
|
|
447
|
+
node.input_type = el.getAttribute("type") ?? "text";
|
|
448
|
+
const placeholder = el.getAttribute("placeholder");
|
|
449
|
+
if (placeholder)
|
|
450
|
+
node.placeholder = placeholder;
|
|
451
|
+
const href = el.getAttribute("href");
|
|
452
|
+
if (href)
|
|
453
|
+
node.href = href;
|
|
454
|
+
if (el.hasAttribute("required"))
|
|
455
|
+
node.required = true;
|
|
456
|
+
if (el.hasAttribute("disabled"))
|
|
457
|
+
node.disabled = true;
|
|
458
|
+
if (FILLABLE_ROLES.has(role)) {
|
|
459
|
+
node.value = el.localName === "input" ? el.getAttribute("value") ?? "" : directText(el);
|
|
460
|
+
}
|
|
461
|
+
this.nodes.push(node);
|
|
462
|
+
}
|
|
463
|
+
const metadata = {
|
|
464
|
+
page_title: title || "Untitled",
|
|
465
|
+
url: this.url,
|
|
466
|
+
total_interactive_nodes: this.nodes.length,
|
|
467
|
+
parsing_latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
468
|
+
};
|
|
469
|
+
const warning = this.emptyPageWarning(this.rawHtml.length);
|
|
470
|
+
if (warning)
|
|
471
|
+
metadata.warning = warning;
|
|
472
|
+
return new InteractionGraph(this.nodes, metadata);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Say *why* the graph looks empty, when it does.
|
|
476
|
+
*
|
|
477
|
+
* A bot wall, an open modal and a genuinely bare page all return almost nothing,
|
|
478
|
+
* and the caller cannot tell them apart from the node list.
|
|
479
|
+
*/
|
|
480
|
+
emptyPageWarning(htmlBytes) {
|
|
481
|
+
if (this.nodes.length > 2)
|
|
482
|
+
return null;
|
|
483
|
+
if (htmlBytes < 4000) {
|
|
484
|
+
return "Almost no interactive nodes, and the page is tiny — this is usually a bot wall or an error page, not the site you wanted.";
|
|
485
|
+
}
|
|
486
|
+
const inert = this.root.querySelectorAll('[aria-hidden="true"] a, [inert] a');
|
|
487
|
+
if (inert.length) {
|
|
488
|
+
return `Almost no interactive nodes, but ${inert.length} links sit under aria-hidden/inert — a modal or overlay is open. Dismiss it and re-read.`;
|
|
489
|
+
}
|
|
490
|
+
return "Almost no interactive nodes on a large page — the content is probably in an iframe (not traversed), a closed shadow root, or rendered to canvas.";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** Leading text directly inside an element, before its first child tag — not the full subtree text. */
|
|
494
|
+
function directText(el) {
|
|
495
|
+
const first = el.firstChild;
|
|
496
|
+
return first && first.nodeType === 3 ? first.data : "";
|
|
497
|
+
}
|
|
498
|
+
/** Convenience one-shot parse. */
|
|
499
|
+
export function parseHtml(html, url = "about:blank") {
|
|
500
|
+
return new ZeroDOMParser(html, url).parse();
|
|
501
|
+
}
|