@glittering0717/ai-browser 0.0.0-stage → 1.2.0
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 +21 -0
- package/README.md +784 -2
- package/bin/ai-browser.cjs +78 -0
- package/examples/AGENT_INSTRUCTIONS.md +166 -0
- package/examples/mcp-config.json +12 -0
- package/llms-install.md +158 -0
- package/package.json +78 -4
- package/scripts/electron_resolve.cjs +241 -0
- package/scripts/start.cjs +60 -0
- package/src/main/axExtractor.js +436 -0
- package/src/main/canvas_hook_source.js +654 -0
- package/src/main/canvas_monitor.js +269 -0
- package/src/main/capability_probe.js +415 -0
- package/src/main/download_monitor.js +137 -0
- package/src/main/index.js +359 -0
- package/src/main/mcp_server.js +408 -0
- package/src/main/mcp_tools.js +289 -0
- package/src/main/mcp_ws.js +178 -0
- package/src/main/network_monitor.js +805 -0
- package/src/main/page_manager.js +2743 -0
- package/src/main/snapshot_diff.js +371 -0
- package/src/main/ws_server.js +440 -0
- package/src/preload/actions.cjs +190 -0
- package/src/preload/bridge.cjs +75 -0
- package/src/preload/extractor.cjs +936 -0
- package/src/preload/watcher.cjs +241 -0
- package/src/renderer/tab_bar.html +115 -0
- package/src/shared/config.js +32 -0
- package/src/shared/deadline.js +89 -0
- package/src/shared/guards.js +17 -0
- package/src/shared/protocol.js +116 -0
- package/src/shared/value_contract.cjs +132 -0
- package/src/shared/version.js +5 -0
- package/src/shared/watchdog.js +57 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// main/axExtractor.js — AX-tree read layer (P0 probe foundation)
|
|
2
|
+
//
|
|
3
|
+
// P0 goal (see .trae/documents/replan-ax-tree-bottom-up.md): replace reading the
|
|
4
|
+
// page from hand-written DOM heuristics (preload/extractor.cjs) with the browser's
|
|
5
|
+
// own semantic layer (CDP Accessibility.getFullAXTree). The OPERATION layer stays
|
|
6
|
+
// CDP and is unchanged; AX and CDP are fused by `data-ai-id`.
|
|
7
|
+
//
|
|
8
|
+
// - normalizeAxTree(rawNodes): PURE. Raw CDP AXNode[] -> contract TreeNode.
|
|
9
|
+
// No Electron/DOM dependency, so it is unit-testable under plain Node. No
|
|
10
|
+
// frame/class/script heuristics.
|
|
11
|
+
// - extractFromDebugger(dbg): RUNTIME. Wires Electron's webContents.debugger:
|
|
12
|
+
// Accessibility.enable + getFullAXTree -> normalize -> stamp data-ai-id onto
|
|
13
|
+
// the real DOM via DOM.describeNode(backendNodeId) then DOM.setAttributeValue.
|
|
14
|
+
//
|
|
15
|
+
// This module is ADDITIVE: nothing here is called by the default getTree path yet.
|
|
16
|
+
|
|
17
|
+
// node.value 的截断契约(legacy 读层 src/preload/extractor.cjs 用同一个 helper,
|
|
18
|
+
// 两条读层不得各自实现 slice)。
|
|
19
|
+
import valueContract from '../shared/value_contract.cjs';
|
|
20
|
+
const { truncateNodeValue } = valueContract;
|
|
21
|
+
|
|
22
|
+
// --- Pure: raw AXNode[] -> contract TreeNode -----------------------------
|
|
23
|
+
// Raw AXNode (CDP Accessibility.AXNode):
|
|
24
|
+
// { nodeId, ignored, role:{value}, name:{value}, value:{value},
|
|
25
|
+
// properties:[{name, value:{type, value}}], backendDOMNodeId,
|
|
26
|
+
// bounds:{x,y,width,height}, childIds:[] }
|
|
27
|
+
|
|
28
|
+
const VERSION_TAG = 'ax-0.1';
|
|
29
|
+
|
|
30
|
+
function prop(nodes, nodeId, name) {
|
|
31
|
+
const n = nodes.get(nodeId);
|
|
32
|
+
if (!n) return null;
|
|
33
|
+
const p = (n.properties || []).find((q) => q.name === name);
|
|
34
|
+
return p ? p.value : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function valueOf(pv) {
|
|
38
|
+
return pv && typeof pv.value !== 'undefined' ? pv.value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// AX gives div[contenteditable] the role `generic` plus `editable=richtext`.
|
|
42
|
+
// The only "rule" we keep (plan §5): fold that into a semantic `textbox`.
|
|
43
|
+
function editorTypeFrom(role, editable) {
|
|
44
|
+
if (editable === 'richtext' || editable === 'contenteditable') return 'richtext';
|
|
45
|
+
if (role === 'textbox') {
|
|
46
|
+
if (editable === 'multiline') return 'textarea';
|
|
47
|
+
if (editable === 'plaintext') return 'textbox';
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function actionsFor(role) {
|
|
53
|
+
switch (role) {
|
|
54
|
+
case 'button': case 'link': return ['click', 'focus', 'hover'];
|
|
55
|
+
case 'textbox': return ['type', 'setContent', 'clear', 'focus'];
|
|
56
|
+
case 'select': return ['select', 'focus'];
|
|
57
|
+
case 'slider': return ['scroll_to', 'focus'];
|
|
58
|
+
case 'checkbox': case 'radio': return ['click', 'focus'];
|
|
59
|
+
// dialog/alertdialog 曾经也广告 ['open','close'],与 extractor.cjs 是同一个错:
|
|
60
|
+
// page_manager.performAction 与 preload actions.cjs 都没有这两个 case,调用只会
|
|
61
|
+
// 拿到 "Unknown action"。语义树(AX 路径)自述了做不到的动作,agent 照着做必然
|
|
62
|
+
// 失败。弹窗里真正能点的按钮本身就带 ['click','focus'],给空数组不损失能力。
|
|
63
|
+
case 'dialog': case 'alertdialog': return [];
|
|
64
|
+
default: return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function statesFor(node) {
|
|
69
|
+
const states = [];
|
|
70
|
+
for (const p of node.properties || []) {
|
|
71
|
+
const v = p.value;
|
|
72
|
+
if (!v || v.type !== 'boolean' || v.value !== true) continue;
|
|
73
|
+
if (p.name === 'read-only') { states.push('readonly'); continue; }
|
|
74
|
+
if (p.name === 'hidden') continue;
|
|
75
|
+
states.push(p.name); // focusable/disabled/focused/checked/selected/settable/multiline/visible...
|
|
76
|
+
}
|
|
77
|
+
// raw `editable` stays available to callers that need the primitive signal
|
|
78
|
+
const editable = nodeStore ? valueOf(prop(nodeStore, node.nodeId, 'editable')) : null;
|
|
79
|
+
if (editable) states.push('editable=' + editable);
|
|
80
|
+
return states;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let nodeStore = null; // current normalize pass's node map (see normalizeAxTree)
|
|
84
|
+
|
|
85
|
+
function build(node, idFor) {
|
|
86
|
+
const rawRole = (node.role && node.role.value) || 'generic';
|
|
87
|
+
const editable = valueOf(prop(nodeStore, node.nodeId, 'editable'));
|
|
88
|
+
const editorType = editorTypeFrom(rawRole, editable);
|
|
89
|
+
const role = (rawRole === 'generic' && editorType === 'richtext') ? 'textbox' : rawRole;
|
|
90
|
+
|
|
91
|
+
const tn = {
|
|
92
|
+
id: idFor(node),
|
|
93
|
+
role,
|
|
94
|
+
label: (node.name && node.name.value) || '',
|
|
95
|
+
states: statesFor(node),
|
|
96
|
+
actions: actionsFor(role),
|
|
97
|
+
bounds: {
|
|
98
|
+
x: Math.round((node.bounds && node.bounds.x) || 0),
|
|
99
|
+
y: Math.round((node.bounds && node.bounds.y) || 0),
|
|
100
|
+
width: Math.round((node.bounds && node.bounds.width) || 0),
|
|
101
|
+
height: Math.round((node.bounds && node.bounds.height) || 0),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
if (node.backendDOMNodeId != null) tn.backendDOMNodeId = node.backendDOMNodeId;
|
|
105
|
+
if (editorType) tn.editor_type = editorType;
|
|
106
|
+
|
|
107
|
+
if (role === 'textbox' || role === 'combobox' || role === 'listbox' || role === 'searchbox') {
|
|
108
|
+
let v = valueOf(node.value);
|
|
109
|
+
if ((v == null || v === '') && readValue) v = readValue(node);
|
|
110
|
+
// 密码框:AX 用 protected 属性标记。不返回值、长度或取全量提示。
|
|
111
|
+
const isProtected = valueOf(prop(nodeStore, node.nodeId, 'protected')) === true;
|
|
112
|
+
if (isProtected) {
|
|
113
|
+
tn.value_sensitive = true;
|
|
114
|
+
tn.value_sensitive_reason = 'password';
|
|
115
|
+
} else if (v != null && v !== '') {
|
|
116
|
+
// 与 legacy 读层共用同一截断契约(src/shared/value_contract.cjs):
|
|
117
|
+
// code point 计数,截断时带 value_truncated / value_full_length /
|
|
118
|
+
// value_fetch_ref,未截断只给 value。
|
|
119
|
+
const cut = truncateNodeValue(String(v), tn.id, 200);
|
|
120
|
+
tn.value = cut.value;
|
|
121
|
+
if (cut.value_truncated) {
|
|
122
|
+
tn.value_truncated = true;
|
|
123
|
+
tn.value_full_length = cut.value_full_length;
|
|
124
|
+
tn.value_length_unit = cut.value_length_unit;
|
|
125
|
+
tn.value_fetch_ref = cut.value_fetch_ref;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const kids = [];
|
|
131
|
+
for (const cid of node.childIds || []) {
|
|
132
|
+
const c = nodeStore.get(cid);
|
|
133
|
+
if (c && !c.ignored) kids.push(build(c, idFor));
|
|
134
|
+
}
|
|
135
|
+
if (kids.length) tn.children = kids;
|
|
136
|
+
return tn;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let readValue = null; // opts.readValue — per-node DOM read hook for textbox values
|
|
140
|
+
|
|
141
|
+
// Public pure API.
|
|
142
|
+
// opts: { idFor: (node)=>string, readValue: (node)=>string }
|
|
143
|
+
export function normalizeAxTree(rawNodes, opts = {}) {
|
|
144
|
+
const live = (rawNodes || []).filter((n) => n && !n.ignored);
|
|
145
|
+
nodeStore = new Map(live.map((n) => [n.nodeId, n]));
|
|
146
|
+
readValue = opts.readValue || null;
|
|
147
|
+
const idFor = opts.idFor || ((n) => 'ax-' + n.nodeId);
|
|
148
|
+
|
|
149
|
+
const childIds = new Set();
|
|
150
|
+
for (const n of live) for (const c of n.childIds || []) if (c != null) childIds.add(c);
|
|
151
|
+
const roots = live.filter((n) => !childIds.has(n.nodeId));
|
|
152
|
+
|
|
153
|
+
const built = roots.map((n) => build(n, idFor));
|
|
154
|
+
const tree = built.length === 1
|
|
155
|
+
? built[0]
|
|
156
|
+
: { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
|
|
157
|
+
tree._debug = { layer: 'ax', version: VERSION_TAG };
|
|
158
|
+
nodeStore = null;
|
|
159
|
+
readValue = null;
|
|
160
|
+
return tree;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// C: PURE MERGE. Splices out-of-process sub-frame trees into the main AX tree.
|
|
164
|
+
// Input `mainTree` is the top-frame normalized tree; each `frame` is
|
|
165
|
+
// { key, label, tree } where `tree` is that child frame's normalized tree
|
|
166
|
+
// (ids already prefixed by the caller's idFor so they cannot collide with the
|
|
167
|
+
// main frame's `ax-{backendDOMNodeId}`). Sub-frame editable/interactive nodes
|
|
168
|
+
// are lifted under a single role=Iframe gate so the caller's tree walker sees
|
|
169
|
+
// them without needing to know about frame topology. Pure — no predicate on
|
|
170
|
+
// mainTree.shape; a single-root main tree is wrapped into a container.
|
|
171
|
+
export function mergeFrameTrees(mainTree, frames = []) {
|
|
172
|
+
if (!mainTree) return mainTree;
|
|
173
|
+
const usable = (frames || []).filter((f) => f && f.tree);
|
|
174
|
+
if (!usable.length) return mainTree;
|
|
175
|
+
|
|
176
|
+
// Lift interactive/editable subtrees out of each frame tree (frame roots are
|
|
177
|
+
// usually generic wrappers; keep their actionable leaves, drop pure layout).
|
|
178
|
+
const lift = (tv) => {
|
|
179
|
+
const out = [];
|
|
180
|
+
(function w(t, depth) {
|
|
181
|
+
if (!t) return;
|
|
182
|
+
// Lift any actionable/editable node (skip generic layout wrappers),
|
|
183
|
+
// regardless of depth — so a single-leaf interactive root is kept.
|
|
184
|
+
const actionable = t.role !== 'generic' && (t.actions || []).length > 0;
|
|
185
|
+
if (actionable) { out.push(t); return; }
|
|
186
|
+
for (const c of t.children || []) w(c, depth + 1);
|
|
187
|
+
})(tv, 0);
|
|
188
|
+
return out;
|
|
189
|
+
};
|
|
190
|
+
const gates = usable.map((f) => ({
|
|
191
|
+
id: f.key,
|
|
192
|
+
role: 'Iframe',
|
|
193
|
+
label: f.label || '子框架',
|
|
194
|
+
states: ['visible'],
|
|
195
|
+
actions: [],
|
|
196
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
197
|
+
children: lift(f.tree),
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
if (mainTree.children && mainTree.children.length) {
|
|
201
|
+
return { ...mainTree, children: [...mainTree.children, ...gates] };
|
|
202
|
+
}
|
|
203
|
+
// mainTree is a leaf root → promote to a container so gates can attach.
|
|
204
|
+
return {
|
|
205
|
+
id: 'root',
|
|
206
|
+
role: 'generic',
|
|
207
|
+
label: '',
|
|
208
|
+
states: ['visible'],
|
|
209
|
+
actions: [],
|
|
210
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
211
|
+
children: [mainTree, ...gates],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// AX role=dialog|alertdialog that is visible, plus its descendant buttons — the
|
|
216
|
+
// AX-native replacement for the class-keyword modal table (extractor's modalSelectors).
|
|
217
|
+
export function modalsFromAx(rawNodes) {
|
|
218
|
+
const live = (rawNodes || []).filter((n) => n && !n.ignored);
|
|
219
|
+
nodeStore = new Map(live.map((n) => [n.nodeId, n]));
|
|
220
|
+
const dialogs = live.filter((n) => {
|
|
221
|
+
const r = n.role && n.role.value;
|
|
222
|
+
if (r !== 'dialog' && r !== 'alertdialog') return false;
|
|
223
|
+
const b = n.bounds || {};
|
|
224
|
+
return (b.width && b.height) || valueOf(prop(nodeStore, n.nodeId, 'visible'));
|
|
225
|
+
});
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const d of dialogs) {
|
|
228
|
+
const buttons = [];
|
|
229
|
+
const walk = (id) => {
|
|
230
|
+
const n = nodeStore.get(id);
|
|
231
|
+
if (!n || n.ignored) return;
|
|
232
|
+
const r = n.role && n.role.value;
|
|
233
|
+
if (r === 'button') {
|
|
234
|
+
const btn = {
|
|
235
|
+
text: ((n.name && n.name.value) || '').slice(0, 30),
|
|
236
|
+
disabled: valueOf(prop(nodeStore, n.nodeId, 'disabled')) === true,
|
|
237
|
+
};
|
|
238
|
+
if (btn.text) buttons.push(btn);
|
|
239
|
+
}
|
|
240
|
+
for (const cid of n.childIds || []) walk(cid);
|
|
241
|
+
};
|
|
242
|
+
walk(d.nodeId);
|
|
243
|
+
if (buttons.length) out.push({ buttons });
|
|
244
|
+
}
|
|
245
|
+
nodeStore = null;
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Runtime: drive Electron webContents.debugger ------------------------
|
|
250
|
+
// dbg = view.webContents.debugger (already attached by page_manager._ensureCdp).
|
|
251
|
+
//
|
|
252
|
+
// C (plan §9.5): READING and STAMPING are now separate. `axRead` builds the
|
|
253
|
+
// semantic tree from AX alone and NEVER touches the DOM (no side effects, no
|
|
254
|
+
// per-read data-ai-id rewrite). `ensureHandles` materializes data-ai-id onto
|
|
255
|
+
// the real interactive elements, but only at the act/peek boundary and
|
|
256
|
+
// idempotently — existing data-ai-id (e.g. the default extractor's `e:...`)
|
|
257
|
+
// is preserved. ids are derived from backendDOMNodeId so they are stable
|
|
258
|
+
// across AX passes (vs AX nodeId which can resequence).
|
|
259
|
+
|
|
260
|
+
const interactiveRoles = { button: 1, link: 1, textbox: 1, combobox: 1, checkbox: 1, radio: 1, select: 1, option: 1, slider: 1, dialog: 1, alertdialog: 1, menuitem: 1, tab: 1 };
|
|
261
|
+
|
|
262
|
+
// Stable public id: backendDOMNodeId stays with its DOM element for the element's
|
|
263
|
+
// lifetime; fall back to AX nodeId for role-only nodes with no backing element.
|
|
264
|
+
function axId(n) { return n.backendDOMNodeId != null ? 'ax-' + n.backendDOMNodeId : 'ax-' + n.nodeId; }
|
|
265
|
+
|
|
266
|
+
function isInteractiveNode(n, byId) {
|
|
267
|
+
const r = n.role && n.role.value;
|
|
268
|
+
const editable = valueOf(prop(byId, n.nodeId, 'editable'));
|
|
269
|
+
return !!interactiveRoles[r] || !!editable || valueOf(prop(byId, n.nodeId, 'focusable')) === true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Enabled AX + returns raw AXNode[] (also loads DOM doc so backendNodeId is usable).
|
|
273
|
+
async function axRaw(dbg) {
|
|
274
|
+
await dbg.sendCommand('Accessibility.enable');
|
|
275
|
+
const res = await dbg.sendCommand('Accessibility.getFullAXTree', {});
|
|
276
|
+
const raw = (res && res.nodes) || [];
|
|
277
|
+
if (!raw.length) return raw;
|
|
278
|
+
await dbg.sendCommand('DOM.getDocument', { depth: 0, pierce: true });
|
|
279
|
+
return raw;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function interactiveIdSet(raw) {
|
|
283
|
+
const byId = new Map(raw.filter((n) => !n.ignored).map((n) => [n.nodeId, n]));
|
|
284
|
+
const s = new Set();
|
|
285
|
+
for (const n of raw) if (!n.ignored && isInteractiveNode(n, byId)) s.add(n.nodeId);
|
|
286
|
+
return s;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// C: PURE READ. Same {tree, context} shape as the default getTree, but derived
|
|
290
|
+
// from AX alone. No DOM mutation, ids = ax-{backendDOMNodeId} (stable).
|
|
291
|
+
export async function axRead(dbg) {
|
|
292
|
+
const raw = await axRaw(dbg);
|
|
293
|
+
if (!raw.length) return { tree: null, context: { modals: [] } };
|
|
294
|
+
const tree = normalizeAxTree(raw, { idFor: axId, readValue: () => '' });
|
|
295
|
+
const context = { modals: modalsFromAx(raw), session: {}, stats: { layer: 'ax' } };
|
|
296
|
+
return { tree, context };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Backward-compat alias: axRead is the current read path (previously stamping).
|
|
300
|
+
export async function extractFromDebugger(dbg) { return axRead(dbg); }
|
|
301
|
+
|
|
302
|
+
// C: IDEMPOTENT materialize. Adds data-ai-id to interactive backing elements.
|
|
303
|
+
// Never overwrites an existing handle (keeps default extractor's `e:...` ids
|
|
304
|
+
// intact). Net effect: read stays side-effect-free; act/peek get handles on demand.
|
|
305
|
+
export async function ensureHandles(dbg) {
|
|
306
|
+
const raw = await axRaw(dbg);
|
|
307
|
+
const byId = new Map(raw.filter((n) => !n.ignored).map((n) => [n.nodeId, n]));
|
|
308
|
+
const stampOne = (objId, ai) => dbg.sendCommand('Runtime.callFunctionOn', {
|
|
309
|
+
objectId: objId,
|
|
310
|
+
functionDeclaration: `function(v){ var e=this,t=e&&e.nodeType||0; if(t!==1) return 'nonel'; var c=e.getAttribute('data-ai-id'); if(c) return c===v?'same':'keep'; e.setAttribute('data-ai-id',v); return 'set'; }`,
|
|
311
|
+
arguments: [{ value: ai }],
|
|
312
|
+
returnByValue: true,
|
|
313
|
+
}).then((r) => r && r.result && r.result.value);
|
|
314
|
+
const out = { set: 0, same: 0, kept: 0, skipped: 0 };
|
|
315
|
+
for (const n of raw) {
|
|
316
|
+
if (n.ignored || n.backendDOMNodeId == null) continue;
|
|
317
|
+
if (!isInteractiveNode(n, byId)) continue;
|
|
318
|
+
const ai = axId(n);
|
|
319
|
+
let rd;
|
|
320
|
+
try { rd = await dbg.sendCommand('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId }); }
|
|
321
|
+
catch (e) { out.skipped++; continue; } // cross-origin/OOPIF node — not reachable here
|
|
322
|
+
const objId = rd && rd.object && rd.object.objectId;
|
|
323
|
+
if (!objId) { out.skipped++; continue; }
|
|
324
|
+
const s = await stampOne(objId, ai);
|
|
325
|
+
if (s === 'set') out.set++;
|
|
326
|
+
else if (s === 'same') out.same++;
|
|
327
|
+
else out.kept++; // 'keep' or 'nonel'
|
|
328
|
+
}
|
|
329
|
+
out.handled = out.set + out.same;
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// A: PURE diff of two AX snapshots (pre/post a reveal) by nodeId. Returns only
|
|
334
|
+
// interactive nodes that newly appeared (revealed) or disappeared (hidden).
|
|
335
|
+
export function diffAx(beforeRaw, afterRaw) {
|
|
336
|
+
const bm = new Map((beforeRaw || []).map((n) => [n.nodeId, n]));
|
|
337
|
+
const am = new Map((afterRaw || []).map((n) => [n.nodeId, n]));
|
|
338
|
+
const bi = interactiveIdSet(beforeRaw || []);
|
|
339
|
+
const ai = interactiveIdSet(afterRaw || []);
|
|
340
|
+
const toLite = (n) => ({
|
|
341
|
+
id: axId(n),
|
|
342
|
+
role: (n.role && n.role.value) || 'generic',
|
|
343
|
+
label: (n.name && n.name.value) || '',
|
|
344
|
+
backendDOMNodeId: n.backendDOMNodeId != null ? n.backendDOMNodeId : undefined,
|
|
345
|
+
});
|
|
346
|
+
const revealed = [], hidden = [];
|
|
347
|
+
for (const id of ai) if (!bi.has(id)) { const n = am.get(id); if (n && !n.ignored) revealed.push(toLite(n)); }
|
|
348
|
+
for (const id of bi) if (!ai.has(id)) { const n = bm.get(id); if (n && !n.ignored) hidden.push(toLite(n)); }
|
|
349
|
+
return { revealed, hidden };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// C: read-only href resolution for actionable nodes. The AX read layer only knows
|
|
353
|
+
// "this is clickable", not "where it goes" (AX has no href). We lazily decorate
|
|
354
|
+
// link/button nodes with their absolute target `url` via DOM.resolveNode +
|
|
355
|
+
// Runtime.callFunctionOn (a read, no DOM mutation). Generic across sites — gives
|
|
356
|
+
// agents "where does clicking this take me" instead of guessing from the label.
|
|
357
|
+
const HREF_FN = `function(){var e=this,h=null;if(e.href&&typeof e.href==='string')h=e.href;if(!h&&e.closest){var a=e.closest('a[href]');if(a)h=a.href;}if(!h&&typeof e.getAttribute==='function'){var r=e.getAttribute('href');if(r)try{h=new URL(r, location.href).href;}catch(_){}}return h||null;}`;
|
|
358
|
+
const MAX_HREFS = 500;
|
|
359
|
+
export async function attachHrefs(dbg, tree) {
|
|
360
|
+
if (!tree || !dbg) return tree;
|
|
361
|
+
const targets = [];
|
|
362
|
+
(function w(t) {
|
|
363
|
+
if (!t) return;
|
|
364
|
+
if ((t.role === 'link' || t.role === 'button') && t.backendDOMNodeId != null && !t.url) {
|
|
365
|
+
if (targets.length < MAX_HREFS) targets.push(t);
|
|
366
|
+
}
|
|
367
|
+
for (const c of t.children || []) w(c);
|
|
368
|
+
})(tree);
|
|
369
|
+
if (!targets.length) return tree;
|
|
370
|
+
const resolved = await Promise.all(targets.map(async (n) => {
|
|
371
|
+
try {
|
|
372
|
+
const rd = await dbg.sendCommand('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId });
|
|
373
|
+
const objId = rd && rd.object && rd.object.objectId;
|
|
374
|
+
if (!objId) return null;
|
|
375
|
+
const c = await dbg.sendCommand('Runtime.callFunctionOn', { objectId: objId, functionDeclaration: HREF_FN, returnByValue: true });
|
|
376
|
+
const v = c && c.result && c.result.value;
|
|
377
|
+
return typeof v === 'string' && v ? v : null;
|
|
378
|
+
} catch (e) { return null; } // cross-origin/OOPIF node — leave url unset
|
|
379
|
+
}));
|
|
380
|
+
for (let i = 0; i < targets.length; i++) if (resolved[i]) targets[i].url = resolved[i];
|
|
381
|
+
return tree;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Prune to the interactive/editable subtree. Keeps the root container as a
|
|
385
|
+
// wrapper plus every node that is actionable/editable (or has actionable
|
|
386
|
+
// descendants); drops layout-only branches. Pure and generic (role/actions
|
|
387
|
+
// based, no site heuristics) — the read-lowering mode for long feeds.
|
|
388
|
+
function isInteractive(tree) { return !!tree.editor_type || (tree.actions && tree.actions.length > 0) || !!tree.url; }
|
|
389
|
+
export function filterInteractive(tree) {
|
|
390
|
+
if (!tree) return tree;
|
|
391
|
+
const keep = (t) => {
|
|
392
|
+
if (!t) return null;
|
|
393
|
+
const kids = (t.children || []).map(keep).filter(Boolean);
|
|
394
|
+
if (kids.length || isInteractive(t)) {
|
|
395
|
+
if (!kids.length) return t;
|
|
396
|
+
return { ...t, children: kids };
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
};
|
|
400
|
+
const out = { ...tree, children: (tree.children || []).map(keep).filter(Boolean) };
|
|
401
|
+
if (!out.children.length && !isInteractive(out)) return null;
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Small memory-safe snapshot of interactive nodes (backendDOMNodeId -> lite),
|
|
406
|
+
// for incremental get_tree {ax:true, mode:'diff'} across reads without caching
|
|
407
|
+
// the full raw AX payload per tab. Pure.
|
|
408
|
+
export function liteSnapshot(rawNodes) {
|
|
409
|
+
const valid = (rawNodes || []).filter((n) => n && !n.ignored);
|
|
410
|
+
const byNodeId = new Map(valid.map((n) => [n.nodeId, n]));
|
|
411
|
+
const out = new Map();
|
|
412
|
+
for (const n of valid) {
|
|
413
|
+
if (n.backendDOMNodeId == null) continue;
|
|
414
|
+
if (!isInteractiveNode(n, byNodeId)) continue;
|
|
415
|
+
out.set(n.backendDOMNodeId, {
|
|
416
|
+
id: axId(n),
|
|
417
|
+
role: (n.role && n.role.value) || 'generic',
|
|
418
|
+
label: (n.name && n.name.value) || '',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Diff two lite snaccapshots by backendDOMNodeId -> { revealed, hidden }. Pure.
|
|
425
|
+
export function diffLite(prevLite, newLite) {
|
|
426
|
+
const revealed = [], hidden = [];
|
|
427
|
+
for (const [bid, n] of (newLite || new Map()).entries()) {
|
|
428
|
+
if (!prevLite || !prevLite.has(bid)) revealed.push(n);
|
|
429
|
+
}
|
|
430
|
+
for (const [bid, n] of (prevLite || new Map()).entries()) {
|
|
431
|
+
if (!newLite || !newLite.has(bid)) hidden.push(n);
|
|
432
|
+
}
|
|
433
|
+
return { revealed, hidden };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export default { normalizeAxTree, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles, diffAx, interactiveIdSet, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };
|