pi-lean-portal 0.1.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 +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM Extractor — reads visible page content and correlates it with
|
|
3
|
+
* the ARIA element cache for @e ref annotations.
|
|
4
|
+
*
|
|
5
|
+
* Contains:
|
|
6
|
+
* - The inline EXTRACTOR_SCRIPT (runs in browser page context via evaluate)
|
|
7
|
+
* - runExtractor() — calls the script and validates the result
|
|
8
|
+
* - correlateElements() — matches extracted content against the element cache
|
|
9
|
+
* - queryElementCache() — synchronous cache filtering
|
|
10
|
+
* - formatCorrelatedOutput(), formatElementList(), formatRoleCountSummary()
|
|
11
|
+
* - Boilerplate filtering constants
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BrowserPlugin } from "../plugin-api.js";
|
|
15
|
+
import type { AriaCachedNode } from "./accessibility-tree.js";
|
|
16
|
+
import { roleIcon } from "./accessibility-tree.js";
|
|
17
|
+
|
|
18
|
+
// ─── Text length filter ────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Minimum trimmed text length for an extracted element to be included.
|
|
22
|
+
*
|
|
23
|
+
* Set to 3 to capture short-but-meaningful labels like "FAQ", "Top", "Map"
|
|
24
|
+
* while still filtering out true noise (single chars, whitespace-only text).
|
|
25
|
+
* Visibility filters (offsetParent, getClientRects, visibility) handle most
|
|
26
|
+
* garbage — lowering from 5 to 3 adds short categories/links that are clearly
|
|
27
|
+
* intentional content.
|
|
28
|
+
*/
|
|
29
|
+
const MIN_TEXT_LENGTH = 3;
|
|
30
|
+
|
|
31
|
+
// ─── Types ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Structured result from the DOM walker script. */
|
|
34
|
+
export interface ExtractResult {
|
|
35
|
+
title: string;
|
|
36
|
+
headings: Array<{ level: number; text: string }>;
|
|
37
|
+
paragraphs: Array<{ text: string; region?: string }>;
|
|
38
|
+
links: Array<{ text: string; href: string }>;
|
|
39
|
+
images: Array<{ alt: string; src: string }>;
|
|
40
|
+
/** Interactive elements (buttons, inputs, selects, textareas) */
|
|
41
|
+
interactive: Array<{
|
|
42
|
+
text: string;
|
|
43
|
+
role: string;
|
|
44
|
+
type?: string;
|
|
45
|
+
disabled: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
/** Error string if the extractor script caught an exception */
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Output of the correlation step — text with @e annotations. */
|
|
52
|
+
export interface CorrelatedResult {
|
|
53
|
+
/** Formatted text output with @e annotations */
|
|
54
|
+
text: string;
|
|
55
|
+
/** Number of matched refs */
|
|
56
|
+
matchedRefs: number;
|
|
57
|
+
/** Whether a staleness notice was appended */
|
|
58
|
+
staleCache: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parameters for queryElementCache. */
|
|
62
|
+
export interface ElementCacheQuery {
|
|
63
|
+
role?: string;
|
|
64
|
+
name?: string;
|
|
65
|
+
ref?: string;
|
|
66
|
+
subtree?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── EXTRACTOR_SCRIPT — runs in browser page context ──────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Inline DOM walker script that runs in the browser page context
|
|
73
|
+
* via page.evaluate(). Returns a JSON string matching ExtractResult.
|
|
74
|
+
*
|
|
75
|
+
* Playwright injects evaluate() at the DevTools protocol level,
|
|
76
|
+
* bypassing page CSP. Sandboxed iframes without allow-scripts
|
|
77
|
+
* are the only case that fails — the caller handles that.
|
|
78
|
+
*/
|
|
79
|
+
const EXTRACTOR_SCRIPT = `(() => {
|
|
80
|
+
'use strict';
|
|
81
|
+
try {
|
|
82
|
+
const result = {
|
|
83
|
+
title: document.title || '',
|
|
84
|
+
headings: [],
|
|
85
|
+
paragraphs: [],
|
|
86
|
+
links: [],
|
|
87
|
+
images: [],
|
|
88
|
+
interactive: [],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ── Headings ──
|
|
92
|
+
const headings = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
93
|
+
for (const h of headings) {
|
|
94
|
+
const text = h.innerText.trim();
|
|
95
|
+
if (text.length >= ${MIN_TEXT_LENGTH}) {
|
|
96
|
+
result.headings.push({ level: +h.tagName[1], text: text });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Paragraphs ──
|
|
101
|
+
const paras = document.querySelectorAll('p, li, td, blockquote, figcaption');
|
|
102
|
+
for (const p of paras) {
|
|
103
|
+
if (p.offsetParent === null) continue;
|
|
104
|
+
if (p.getClientRects().length === 0) continue;
|
|
105
|
+
if (window.getComputedStyle(p).visibility === 'hidden') continue;
|
|
106
|
+
const text = p.innerText.trim();
|
|
107
|
+
if (text.length < ${MIN_TEXT_LENGTH}) continue;
|
|
108
|
+
|
|
109
|
+
const region = p.closest('[role="region"], [aria-labelledby], [aria-label]');
|
|
110
|
+
const regionLabel = region
|
|
111
|
+
? (region.getAttribute('aria-label') || region.getAttribute('aria-labelledby') || '')
|
|
112
|
+
: '';
|
|
113
|
+
result.paragraphs.push({ text: text, region: regionLabel || undefined });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Links ──
|
|
117
|
+
const links = document.querySelectorAll('a[href]');
|
|
118
|
+
for (const a of links) {
|
|
119
|
+
if (a.offsetParent === null) continue;
|
|
120
|
+
if (a.getClientRects().length === 0) continue;
|
|
121
|
+
if (window.getComputedStyle(a).visibility === 'hidden') continue;
|
|
122
|
+
const text = a.innerText.trim();
|
|
123
|
+
if (text.length < ${MIN_TEXT_LENGTH}) continue;
|
|
124
|
+
|
|
125
|
+
result.links.push({ text: text, href: a.href });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Images ──
|
|
129
|
+
const imgs = document.querySelectorAll('img[alt]');
|
|
130
|
+
for (const img of imgs) {
|
|
131
|
+
if (img.offsetParent === null) continue;
|
|
132
|
+
if (img.getClientRects().length === 0) continue;
|
|
133
|
+
if (window.getComputedStyle(img).visibility === 'hidden') continue;
|
|
134
|
+
const alt = (img.alt || '').trim();
|
|
135
|
+
if (alt.length < ${MIN_TEXT_LENGTH}) continue;
|
|
136
|
+
result.images.push({ alt: alt, src: img.src });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Interactive elements ──
|
|
140
|
+
const interactive = document.querySelectorAll(
|
|
141
|
+
'button, [role="button"], input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), select, textarea'
|
|
142
|
+
);
|
|
143
|
+
for (const el of interactive) {
|
|
144
|
+
if (el.offsetParent === null) continue;
|
|
145
|
+
if (el.getClientRects().length === 0) continue;
|
|
146
|
+
if (window.getComputedStyle(el).visibility === 'hidden') continue;
|
|
147
|
+
const tagName = el.tagName.toLowerCase();
|
|
148
|
+
const role = el.getAttribute('role') || tagName;
|
|
149
|
+
var text = '';
|
|
150
|
+
var disabled = false;
|
|
151
|
+
if (el.disabled !== undefined) disabled = el.disabled;
|
|
152
|
+
if (tagName === 'input') {
|
|
153
|
+
const inputType = el.getAttribute('type') || 'text';
|
|
154
|
+
if (el.labels && el.labels.length > 0) {
|
|
155
|
+
text = el.labels[0].innerText.trim();
|
|
156
|
+
} else if (el.placeholder) {
|
|
157
|
+
text = el.placeholder;
|
|
158
|
+
}
|
|
159
|
+
result.interactive.push({ text: text, role: role, type: inputType, disabled: disabled });
|
|
160
|
+
} else if (tagName === 'select' || tagName === 'textarea') {
|
|
161
|
+
if (el.labels && el.labels.length > 0) {
|
|
162
|
+
text = el.labels[0].innerText.trim();
|
|
163
|
+
} else if (el.placeholder) {
|
|
164
|
+
text = el.placeholder;
|
|
165
|
+
}
|
|
166
|
+
result.interactive.push({ text: text, role: role, disabled: disabled });
|
|
167
|
+
} else {
|
|
168
|
+
text = (el.innerText || el.value || '').trim();
|
|
169
|
+
result.interactive.push({ text: text, role: role, disabled: disabled });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return JSON.stringify(result);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return JSON.stringify({ error: err.message || String(err) });
|
|
176
|
+
}
|
|
177
|
+
})();`;
|
|
178
|
+
|
|
179
|
+
// ─── runExtractor() — call the script via evaluate ────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Run the DOM extractor in the page context via plugin.evaluate().
|
|
183
|
+
*
|
|
184
|
+
* The extractor runs purely on DOM properties — no fetch, no XHR, no eval.
|
|
185
|
+
* Returns a parsed ExtractResult, or null on failure (including error
|
|
186
|
+
* caught by the script itself, evaluate rejection, or invalid JSON).
|
|
187
|
+
*/
|
|
188
|
+
export async function runExtractor(
|
|
189
|
+
taskId: string,
|
|
190
|
+
plugin: BrowserPlugin,
|
|
191
|
+
): Promise<ExtractResult | null> {
|
|
192
|
+
try {
|
|
193
|
+
const evalResult = await plugin.evaluate(taskId, EXTRACTOR_SCRIPT);
|
|
194
|
+
|
|
195
|
+
if (!evalResult.success) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The script returns a JSON string inside result.result
|
|
200
|
+
const rawJson =
|
|
201
|
+
typeof evalResult.result === "string"
|
|
202
|
+
? evalResult.result
|
|
203
|
+
: JSON.stringify(evalResult.result);
|
|
204
|
+
|
|
205
|
+
const parsed = JSON.parse(rawJson) as ExtractResult;
|
|
206
|
+
|
|
207
|
+
// Check for script-level error
|
|
208
|
+
if (parsed.error) {
|
|
209
|
+
console.warn("[pi-lean-portal] DOM extractor script error:", parsed.error);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate shape (basic structural check)
|
|
214
|
+
if (
|
|
215
|
+
typeof parsed.title !== "string" ||
|
|
216
|
+
!Array.isArray(parsed.headings) ||
|
|
217
|
+
!Array.isArray(parsed.paragraphs) ||
|
|
218
|
+
!Array.isArray(parsed.links) ||
|
|
219
|
+
!Array.isArray(parsed.images) ||
|
|
220
|
+
!Array.isArray(parsed.interactive)
|
|
221
|
+
) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return parsed;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── correlateElements() — @e ref annotation ──────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Build a reverse index from the element cache: "role||name" → ref[].
|
|
235
|
+
* Handles many-to-many duplicates (multiple elements with same role+name).
|
|
236
|
+
*/
|
|
237
|
+
function buildReverseIndex(
|
|
238
|
+
cache: Map<string, AriaCachedNode>,
|
|
239
|
+
): Map<string, string[]> {
|
|
240
|
+
const index = new Map<string, string[]>();
|
|
241
|
+
for (const [, node] of cache) {
|
|
242
|
+
const key = `${node.role}||${node.name}`;
|
|
243
|
+
const existing = index.get(key);
|
|
244
|
+
if (existing) {
|
|
245
|
+
existing.push(node.ref);
|
|
246
|
+
} else {
|
|
247
|
+
index.set(key, [node.ref]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return index;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Annotate extracted text with @e refs from the element cache.
|
|
255
|
+
*
|
|
256
|
+
* For each element in the extract result, looks up matching AriaCachedNode
|
|
257
|
+
* by role + name. Annotates with @e refs where matches are found.
|
|
258
|
+
* Handles duplicate role+name by annotating ALL matching @e refs.
|
|
259
|
+
*
|
|
260
|
+
* @param extracted - The structured result from the DOM walker
|
|
261
|
+
* @param elementCache - The plugin's element cache (ref → AriaCachedNode)
|
|
262
|
+
* @param cacheFresh - Whether the cache is still fresh (true = no staleness notice)
|
|
263
|
+
*/
|
|
264
|
+
export function correlateElements(
|
|
265
|
+
extracted: ExtractResult,
|
|
266
|
+
elementCache: Map<string, AriaCachedNode>,
|
|
267
|
+
cacheFresh: boolean,
|
|
268
|
+
): CorrelatedResult {
|
|
269
|
+
const reverseIndex = buildReverseIndex(elementCache);
|
|
270
|
+
const matchedRefs = new Set<string>();
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
|
|
273
|
+
// ── Title ──
|
|
274
|
+
if (extracted.title) {
|
|
275
|
+
lines.push(`Title: ${extracted.title}`);
|
|
276
|
+
lines.push("");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Headings ──
|
|
280
|
+
if (extracted.headings.length > 0) {
|
|
281
|
+
lines.push("Headings:");
|
|
282
|
+
for (const h of extracted.headings) {
|
|
283
|
+
const refs = reverseIndex.get(`heading||${h.text}`);
|
|
284
|
+
const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
|
|
285
|
+
lines.push(` ${refStr}📌 "${h.text}" [${h.level}]`);
|
|
286
|
+
}
|
|
287
|
+
lines.push("");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Content (paragraphs, links, images) ──
|
|
291
|
+
if (
|
|
292
|
+
extracted.paragraphs.length > 0 ||
|
|
293
|
+
extracted.links.length > 0 ||
|
|
294
|
+
extracted.images.length > 0
|
|
295
|
+
) {
|
|
296
|
+
lines.push("Content:");
|
|
297
|
+
|
|
298
|
+
// Paragraphs
|
|
299
|
+
for (const p of extracted.paragraphs) {
|
|
300
|
+
const refs = reverseIndex.get(`paragraph||${p.text}`);
|
|
301
|
+
const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
|
|
302
|
+
const region = p.region ? ` [${p.region}]` : "";
|
|
303
|
+
// Cap individual paragraph text at 300 chars
|
|
304
|
+
const displayText =
|
|
305
|
+
p.text.length > 300 ? p.text.slice(0, 297) + "…" : p.text;
|
|
306
|
+
lines.push(` ${refStr}${displayText}${region}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Links
|
|
310
|
+
for (const link of extracted.links) {
|
|
311
|
+
const refs = reverseIndex.get(`link||${link.text}`);
|
|
312
|
+
const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
|
|
313
|
+
lines.push(` ${refStr}🔗 "${link.text}" → ${link.href}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Images
|
|
317
|
+
for (const img of extracted.images) {
|
|
318
|
+
const refs = reverseIndex.get(`img||${img.alt}`);
|
|
319
|
+
const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
|
|
320
|
+
lines.push(` ${refStr}🖼 "${img.alt}"`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
lines.push("");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Interactive elements ──
|
|
327
|
+
if (extracted.interactive.length > 0) {
|
|
328
|
+
lines.push("Interactive:");
|
|
329
|
+
for (const el of extracted.interactive) {
|
|
330
|
+
const refs = reverseIndex.get(`${el.role}||${el.text}`);
|
|
331
|
+
const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
|
|
332
|
+
|
|
333
|
+
const icon = roleIcon(el.role) || "• ";
|
|
334
|
+
const disabledStr = el.disabled ? " [disabled]" : "";
|
|
335
|
+
const typeStr = el.type ? ` type="${el.type}"` : "";
|
|
336
|
+
const displayText =
|
|
337
|
+
el.text && el.text.length > 0
|
|
338
|
+
? ` "${el.text.length > 80 ? el.text.slice(0, 77) + "…" : el.text}"`
|
|
339
|
+
: "";
|
|
340
|
+
lines.push(
|
|
341
|
+
` ${refStr}${icon}${el.role}${displayText}${typeStr}${disabledStr}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
lines.push("");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Staleness notice ──
|
|
348
|
+
let staleCache = false;
|
|
349
|
+
if (!cacheFresh && matchedRefs.size > 0) {
|
|
350
|
+
staleCache = true;
|
|
351
|
+
lines.push(
|
|
352
|
+
"⚠ Element refs may be stale — consider browser-snapshot before clicking.",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
text: lines.join("\n").trim(),
|
|
358
|
+
matchedRefs: matchedRefs.size,
|
|
359
|
+
staleCache,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Annotate @e refs into a leading string.
|
|
365
|
+
* Multiple refs are comma-separated.
|
|
366
|
+
*/
|
|
367
|
+
function annotateRefs(refs: string[], matchedRefs: Set<string>): string {
|
|
368
|
+
for (const ref of refs) {
|
|
369
|
+
matchedRefs.add(ref);
|
|
370
|
+
}
|
|
371
|
+
if (refs.length === 1) {
|
|
372
|
+
return `@${refs[0]} `;
|
|
373
|
+
}
|
|
374
|
+
return `@${refs.join(", @")} `;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── queryElementCache() — synchronous cache filter ───────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Query the element cache synchronously.
|
|
381
|
+
*
|
|
382
|
+
* - ref: look up a specific @e ref (with or without @ prefix)
|
|
383
|
+
* - role: comma-separated roles, match any
|
|
384
|
+
* - name: case-insensitive substring match
|
|
385
|
+
* - subtree: find elements whose parentRef chain leads to a container
|
|
386
|
+
* matching the given role
|
|
387
|
+
*
|
|
388
|
+
* All filters are AND-ed.
|
|
389
|
+
*/
|
|
390
|
+
export function queryElementCache(
|
|
391
|
+
cache: Map<string, AriaCachedNode>,
|
|
392
|
+
filters: ElementCacheQuery,
|
|
393
|
+
): AriaCachedNode[] {
|
|
394
|
+
const results: AriaCachedNode[] = [];
|
|
395
|
+
|
|
396
|
+
// ref lookup: direct map access (cache keys are "e5" not "@e5")
|
|
397
|
+
if (filters.ref) {
|
|
398
|
+
const key = filters.ref.startsWith("@")
|
|
399
|
+
? filters.ref.slice(1)
|
|
400
|
+
: filters.ref;
|
|
401
|
+
const node = cache.get(key);
|
|
402
|
+
if (node) {
|
|
403
|
+
// Apply additional filters if any
|
|
404
|
+
if (filters.role && !matchesRole(node, filters.role)) return [];
|
|
405
|
+
if (filters.name && !matchesName(node, filters.name)) return [];
|
|
406
|
+
if (filters.subtree && !matchesSubtree(node, cache, filters.subtree))
|
|
407
|
+
return [];
|
|
408
|
+
return [node];
|
|
409
|
+
}
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Pre-compute subtree ancestry map for subtree filter
|
|
414
|
+
const subtreeAncestors = filters.subtree
|
|
415
|
+
? computeSubtreeAncestors(cache, filters.subtree)
|
|
416
|
+
: null;
|
|
417
|
+
|
|
418
|
+
for (const [, node] of cache) {
|
|
419
|
+
if (filters.role && !matchesRole(node, filters.role)) continue;
|
|
420
|
+
if (filters.name && !matchesName(node, filters.name)) continue;
|
|
421
|
+
if (filters.subtree && subtreeAncestors) {
|
|
422
|
+
if (!subtreeAncestors.has(node.ref)) continue;
|
|
423
|
+
}
|
|
424
|
+
results.push(node);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return results;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function matchesRole(node: AriaCachedNode, roleFilter: string): boolean {
|
|
431
|
+
const roles = roleFilter.split(",").map((r) => r.trim().toLowerCase());
|
|
432
|
+
return roles.includes(node.role);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function matchesName(node: AriaCachedNode, nameFilter: string): boolean {
|
|
436
|
+
return node.name.toLowerCase().includes(nameFilter.toLowerCase());
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Compute the set of element refs that are inside a container matching
|
|
441
|
+
* the given role. Follows parentRef chains.
|
|
442
|
+
*/
|
|
443
|
+
function computeSubtreeAncestors(
|
|
444
|
+
cache: Map<string, AriaCachedNode>,
|
|
445
|
+
containerRole: string,
|
|
446
|
+
): Set<string> {
|
|
447
|
+
const containerRefs = new Set<string>();
|
|
448
|
+
const insideRefs = new Set<string>();
|
|
449
|
+
|
|
450
|
+
// Find all container elements matching the role
|
|
451
|
+
for (const [, node] of cache) {
|
|
452
|
+
if (node.role === containerRole) {
|
|
453
|
+
containerRefs.add(node.ref);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (containerRefs.size === 0) return insideRefs;
|
|
458
|
+
|
|
459
|
+
// For each node, walk parentRef chain to see if any ancestor is a container
|
|
460
|
+
for (const [, node] of cache) {
|
|
461
|
+
// Skip the containers themselves
|
|
462
|
+
if (containerRefs.has(node.ref)) continue;
|
|
463
|
+
|
|
464
|
+
let current: AriaCachedNode | undefined = node;
|
|
465
|
+
let depth = 0;
|
|
466
|
+
while (current && depth < 100) {
|
|
467
|
+
// Safety limit on chain depth
|
|
468
|
+
if (containerRefs.has(current.ref)) {
|
|
469
|
+
insideRefs.add(node.ref);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
if (!current.parentRef) break;
|
|
473
|
+
current = cache.get(current.parentRef);
|
|
474
|
+
depth++;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return insideRefs;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function matchesSubtree(
|
|
482
|
+
node: AriaCachedNode,
|
|
483
|
+
cache: Map<string, AriaCachedNode>,
|
|
484
|
+
containerRole: string,
|
|
485
|
+
): boolean {
|
|
486
|
+
const ancestors = computeSubtreeAncestors(cache, containerRole);
|
|
487
|
+
return ancestors.has(node.ref);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ─── Output formatting ────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Format a list of AriaCachedNode elements for agent consumption.
|
|
494
|
+
*/
|
|
495
|
+
export function formatElementList(
|
|
496
|
+
elements: AriaCachedNode[],
|
|
497
|
+
filters?: ElementCacheQuery,
|
|
498
|
+
): string {
|
|
499
|
+
if (elements.length === 0) {
|
|
500
|
+
if (filters?.ref) {
|
|
501
|
+
return `Element @${filters.ref.replace(/^@/, "")} not found in cache. Use browser-snapshot to refresh.`;
|
|
502
|
+
}
|
|
503
|
+
return "No matching elements found.";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const lines: string[] = [
|
|
507
|
+
`Found ${elements.length} element${elements.length > 1 ? "s" : ""}:`,
|
|
508
|
+
"",
|
|
509
|
+
];
|
|
510
|
+
|
|
511
|
+
for (const node of elements) {
|
|
512
|
+
const icon = roleIcon(node.role);
|
|
513
|
+
const refTag = `@${node.ref}`;
|
|
514
|
+
const namePart = node.name ? ` "${node.name}"` : "";
|
|
515
|
+
const propsStr = node.props.length > 0 ? ` [${node.props.join(", ")}]` : "";
|
|
516
|
+
lines.push(` ${refTag} ${icon}${node.role}${namePart}${propsStr}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return lines.join("\n");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Format a role-count summary (fallback when no params provided).
|
|
524
|
+
*
|
|
525
|
+
* Only shows roles that have at least one element. Sorted by count descending.
|
|
526
|
+
*/
|
|
527
|
+
export function formatRoleCountSummary(
|
|
528
|
+
cache: Map<string, AriaCachedNode>,
|
|
529
|
+
): string {
|
|
530
|
+
const counts = new Map<string, number>();
|
|
531
|
+
for (const [, node] of cache) {
|
|
532
|
+
counts.set(node.role, (counts.get(node.role) ?? 0) + 1);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (counts.size === 0) {
|
|
536
|
+
return "No elements cached yet — use browser-snapshot to populate element cache.";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
|
|
540
|
+
|
|
541
|
+
const parts: string[] = [];
|
|
542
|
+
for (const [role, count] of sorted) {
|
|
543
|
+
const icon = roleIcon(role);
|
|
544
|
+
parts.push(`${count} ${icon}${role}${count > 1 ? "s" : ""}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
parts.join(", ") + ". Use role=, name=, or text=true to query further."
|
|
549
|
+
);
|
|
550
|
+
}
|