@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Playwright (Node) middleware adapter. Port of zerodom/playwright_wrapper.py. */
|
|
2
|
+
import type { FrameLike } from "./frames.js";
|
|
3
|
+
import { InteractionGraph } from "./parser.js";
|
|
4
|
+
export declare const SERIALIZE = "() => {\n const roots = [];\n const marked = [];\n const visit = (root) => {\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) { roots.push(el.shadowRoot); visit(el.shadowRoot); }\n // A stylesheet rule is invisible to a parser reading HTML text, so\n // input.hidden-class looks clickable and an agent burns a 30s timeout\n // on it. Only the browser knows; record what it knows.\n //\n // NOT contentVisibilityAuto: content-visibility:auto is a rendering\n // optimisation for offscreen content, not a way to hide it. Counting it\n // as hidden deletes everything below the fold \u2014 on vercel.com that was\n // 129 of 177 controls, an agent blinded to most of the page.\n if (!el.checkVisibility({ visibilityProperty: true })) {\n el.setAttribute('data-zerodom-hidden', '');\n marked.push(el);\n }\n }\n };\n visit(document);\n const body = document.body;\n if (!body || !body.getHTML) { for (const el of marked) el.removeAttribute('data-zerodom-hidden'); return null; }\n try {\n // Only <body>, never <head>. Scripts routinely append a <div> into head;\n // Chromium serializes it faithfully, but re-parsing HTML text treats flow\n // content in head as the implicit start of body \u2014 which relocated 40+ head\n // elements into europa.eu's body and shifted every nth-of-type index after\n // them. The title is the one thing worth carrying across.\n const attrs = (el) => [...el.attributes].map(a => ` ${a.name}=\"${a.value.replace(/\"/g, '"')}\"`).join('');\n const title = document.title.replace(/&/g, '&').replace(/</g, '<');\n return '<html' + attrs(document.documentElement) + '><head><title>' + title\n + '</title></head><body' + attrs(body) + '>'\n + body.getHTML({ serializableShadowRoots: true, shadowRoots: roots })\n + '</body></html>';\n } finally {\n // The page belongs to the caller; leave it exactly as we found it. This\n // whole function is synchronous, so nothing else can observe the marks.\n for (const el of marked) el.removeAttribute('data-zerodom-hidden');\n }\n}";
|
|
5
|
+
/** Minimal surface this module needs from a Playwright Page. */
|
|
6
|
+
export interface PageLike extends FrameLike {
|
|
7
|
+
content(): Promise<string>;
|
|
8
|
+
evaluate<T>(fn: string): Promise<T>;
|
|
9
|
+
frames(): FrameLike[];
|
|
10
|
+
mainFrame(): FrameLike;
|
|
11
|
+
frameLocator(selector: string): unknown;
|
|
12
|
+
locator(selector: string): unknown;
|
|
13
|
+
}
|
|
14
|
+
/** Page HTML including open shadow roots. */
|
|
15
|
+
export declare function serialize(page: PageLike): Promise<string>;
|
|
16
|
+
/** 1-line Playwright integration: `ZeroDOM.fromPage(page)`. */
|
|
17
|
+
export declare const ZeroDOM: {
|
|
18
|
+
fromHtml(html: string, url?: string): InteractionGraph;
|
|
19
|
+
/**
|
|
20
|
+
* Parse a Playwright page.
|
|
21
|
+
*
|
|
22
|
+
* `frames: true` also walks same- and cross-origin iframes, which is the only
|
|
23
|
+
* way to see inside embedded editors, payment fields and consent gates. Off by
|
|
24
|
+
* default: it costs a serialize per frame, and on an ad-heavy page most of
|
|
25
|
+
* those frames are advertising.
|
|
26
|
+
*/
|
|
27
|
+
fromPage(page: PageLike, options?: {
|
|
28
|
+
frames?: boolean;
|
|
29
|
+
}): Promise<InteractionGraph>;
|
|
30
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Playwright (Node) middleware adapter. Port of zerodom/playwright_wrapper.py. */
|
|
2
|
+
import { frameChain, isWorthReading, renumber } from "./frames.js";
|
|
3
|
+
import { ZeroDOMParser } from "./parser.js";
|
|
4
|
+
// `page.content()` serializes light DOM only, so controls inside a shadow root are
|
|
5
|
+
// invisible — and worse, Playwright's CSS engine *pierces* open shadow roots when it
|
|
6
|
+
// clicks, so a light-DOM path like `#host > button` silently matches shadow elements
|
|
7
|
+
// we never knew were there. Chromium's getHTML() emits open roots as
|
|
8
|
+
// `<template shadowrootmode>`, which gives the parser the whole picture.
|
|
9
|
+
//
|
|
10
|
+
// Closed roots stay unreachable by design; no API exposes them. Identical to the
|
|
11
|
+
// Python SERIALIZE string — it's JavaScript either way, evaluated in the page.
|
|
12
|
+
export const SERIALIZE = `() => {
|
|
13
|
+
const roots = [];
|
|
14
|
+
const marked = [];
|
|
15
|
+
const visit = (root) => {
|
|
16
|
+
for (const el of root.querySelectorAll('*')) {
|
|
17
|
+
if (el.shadowRoot) { roots.push(el.shadowRoot); visit(el.shadowRoot); }
|
|
18
|
+
// A stylesheet rule is invisible to a parser reading HTML text, so
|
|
19
|
+
// input.hidden-class looks clickable and an agent burns a 30s timeout
|
|
20
|
+
// on it. Only the browser knows; record what it knows.
|
|
21
|
+
//
|
|
22
|
+
// NOT contentVisibilityAuto: content-visibility:auto is a rendering
|
|
23
|
+
// optimisation for offscreen content, not a way to hide it. Counting it
|
|
24
|
+
// as hidden deletes everything below the fold — on vercel.com that was
|
|
25
|
+
// 129 of 177 controls, an agent blinded to most of the page.
|
|
26
|
+
if (!el.checkVisibility({ visibilityProperty: true })) {
|
|
27
|
+
el.setAttribute('data-zerodom-hidden', '');
|
|
28
|
+
marked.push(el);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
visit(document);
|
|
33
|
+
const body = document.body;
|
|
34
|
+
if (!body || !body.getHTML) { for (const el of marked) el.removeAttribute('data-zerodom-hidden'); return null; }
|
|
35
|
+
try {
|
|
36
|
+
// Only <body>, never <head>. Scripts routinely append a <div> into head;
|
|
37
|
+
// Chromium serializes it faithfully, but re-parsing HTML text treats flow
|
|
38
|
+
// content in head as the implicit start of body — which relocated 40+ head
|
|
39
|
+
// elements into europa.eu's body and shifted every nth-of-type index after
|
|
40
|
+
// them. The title is the one thing worth carrying across.
|
|
41
|
+
const attrs = (el) => [...el.attributes].map(a => \` \${a.name}="\${a.value.replace(/"/g, '"')}"\`).join('');
|
|
42
|
+
const title = document.title.replace(/&/g, '&').replace(/</g, '<');
|
|
43
|
+
return '<html' + attrs(document.documentElement) + '><head><title>' + title
|
|
44
|
+
+ '</title></head><body' + attrs(body) + '>'
|
|
45
|
+
+ body.getHTML({ serializableShadowRoots: true, shadowRoots: roots })
|
|
46
|
+
+ '</body></html>';
|
|
47
|
+
} finally {
|
|
48
|
+
// The page belongs to the caller; leave it exactly as we found it. This
|
|
49
|
+
// whole function is synchronous, so nothing else can observe the marks.
|
|
50
|
+
for (const el of marked) el.removeAttribute('data-zerodom-hidden');
|
|
51
|
+
}
|
|
52
|
+
}`;
|
|
53
|
+
/** Page HTML including open shadow roots. */
|
|
54
|
+
export async function serialize(page) {
|
|
55
|
+
return (await page.evaluate(SERIALIZE)) ?? (await page.content());
|
|
56
|
+
}
|
|
57
|
+
/** 1-line Playwright integration: `ZeroDOM.fromPage(page)`. */
|
|
58
|
+
export const ZeroDOM = {
|
|
59
|
+
fromHtml(html, url = "about:blank") {
|
|
60
|
+
return new ZeroDOMParser(html, url).parse();
|
|
61
|
+
},
|
|
62
|
+
/**
|
|
63
|
+
* Parse a Playwright page.
|
|
64
|
+
*
|
|
65
|
+
* `frames: true` also walks same- and cross-origin iframes, which is the only
|
|
66
|
+
* way to see inside embedded editors, payment fields and consent gates. Off by
|
|
67
|
+
* default: it costs a serialize per frame, and on an ad-heavy page most of
|
|
68
|
+
* those frames are advertising.
|
|
69
|
+
*/
|
|
70
|
+
async fromPage(page, options = {}) {
|
|
71
|
+
const html = await serialize(page);
|
|
72
|
+
const graph = new ZeroDOMParser(html, page.url()).parse();
|
|
73
|
+
return options.frames ? mergeFrames(page, graph) : graph;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* One frame's nodes, `[]` if not worth reading, `null` if it refused.
|
|
78
|
+
*
|
|
79
|
+
* Every failure is swallowed here so `Promise.all` never has to care: one
|
|
80
|
+
* hostile advertisement must not cost the caller the rest of the page.
|
|
81
|
+
*/
|
|
82
|
+
async function readFrame(frame) {
|
|
83
|
+
if (!(await isWorthReading(frame)))
|
|
84
|
+
return [];
|
|
85
|
+
const chain = await frameChain(frame);
|
|
86
|
+
if (chain === null)
|
|
87
|
+
return null;
|
|
88
|
+
try {
|
|
89
|
+
const html = await serialize(frame);
|
|
90
|
+
const sub = new ZeroDOMParser(html, frame.url()).parse();
|
|
91
|
+
for (const node of sub.nodes) {
|
|
92
|
+
node.frame = chain;
|
|
93
|
+
node.frame_url = frame.url();
|
|
94
|
+
}
|
|
95
|
+
return sub.nodes;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Append every readable iframe's nodes to the top document's, in frame order.
|
|
103
|
+
*
|
|
104
|
+
* Frames are independent documents, so reading them serially just adds up round
|
|
105
|
+
* trips. `Promise.all` preserves order, which matters: node ids are assigned in
|
|
106
|
+
* document order and must stay stable between reads.
|
|
107
|
+
*/
|
|
108
|
+
async function mergeFrames(page, graph) {
|
|
109
|
+
const children = page.frames().filter((f) => f !== page.mainFrame());
|
|
110
|
+
const results = await Promise.all(children.map((f) => readFrame(f).catch(() => null)));
|
|
111
|
+
const added = [];
|
|
112
|
+
let skipped = 0;
|
|
113
|
+
for (const result of results) {
|
|
114
|
+
if (result === null)
|
|
115
|
+
skipped += 1;
|
|
116
|
+
else
|
|
117
|
+
added.push(...result);
|
|
118
|
+
}
|
|
119
|
+
if (added.length) {
|
|
120
|
+
graph.nodes = renumber([...graph.nodes, ...added]);
|
|
121
|
+
graph.metadata.total_interactive_nodes = graph.nodes.length;
|
|
122
|
+
// A graph that reached into frames is no longer "almost empty because the
|
|
123
|
+
// content is in an iframe" — that warning would now be misleading.
|
|
124
|
+
delete graph.metadata.warning;
|
|
125
|
+
}
|
|
126
|
+
graph.metadata.frames_read = new Set(added.map((n) => JSON.stringify(n.frame))).size;
|
|
127
|
+
if (skipped)
|
|
128
|
+
graph.metadata.frames_skipped = skipped;
|
|
129
|
+
return graph;
|
|
130
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vexralabs/zerodom",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "DOM-to-Interaction-Graph middleware for AI web agents (TypeScript port)",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"keywords": ["dom", "llm", "agent", "playwright", "browser-automation", "tokens", "mcp"],
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Syed Husnain Khalid",
|
|
19
|
+
"email": "contact@vexralabs.com"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/DevHusnainAi/zerodom.git",
|
|
24
|
+
"directory": "js"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/DevHusnainAi/zerodom/tree/main/js#readme",
|
|
27
|
+
"bugs": "https://github.com/DevHusnainAi/zerodom/issues",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"prepare": "tsc",
|
|
31
|
+
"pretest": "tsc",
|
|
32
|
+
"test": "node --test --experimental-strip-types test/*.test.ts"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"linkedom": "^0.18.13"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.20.2",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|