@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.
@@ -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, '&quot;')}\"`).join('');\n const title = document.title.replace(/&/g, '&amp;').replace(/</g, '&lt;');\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, '&quot;')}"\`).join('');
42
+ const title = document.title.replace(/&/g, '&amp;').replace(/</g, '&lt;');
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
+ }