chrometools-mcp 3.3.6 → 3.3.8
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/CHANGELOG.md +47 -0
- package/browser/page-manager.js +83 -0
- package/index.js +120 -3
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +289 -32
- package/server/tool-definitions.js +3 -1
- package/server/tool-schemas.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.3.8] - 2026-02-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **analyzePage: addEventListener tracking for Angular/React/Vue** — Monkey-patch detection
|
|
9
|
+
- Injects tracker via `evaluateOnNewDocument` before page load
|
|
10
|
+
- Catches `addEventListener('click', ...)` calls from any framework
|
|
11
|
+
- Solves Angular detection (compiled `(click)` bindings now visible)
|
|
12
|
+
- WeakMap storage prevents memory leaks
|
|
13
|
+
- `hasExplicitClickBinding()` now checks `window.__hasClickListener()`
|
|
14
|
+
|
|
15
|
+
- **analyzePage: viewportOnly flag** — Filter to visible elements only
|
|
16
|
+
- Reduces output by 30-56% on large pages
|
|
17
|
+
- Useful for data-heavy pages with content below fold
|
|
18
|
+
|
|
19
|
+
- **analyzePage: diff mode** — Show only changes since last analysis
|
|
20
|
+
- Returns `{added, removed, changed}` structure
|
|
21
|
+
- ~80-90% size reduction for incremental updates
|
|
22
|
+
|
|
23
|
+
- **analyzePage: clickTarget legend** — Clarified in tool description
|
|
24
|
+
- Format: `"tag:id"` (e.g., `"div:container_19"`)
|
|
25
|
+
- No clickTarget = element handles its own click
|
|
26
|
+
|
|
27
|
+
## [3.3.7] - 2026-02-03
|
|
28
|
+
|
|
29
|
+
### Performance
|
|
30
|
+
- **analyzePage: 26% output size reduction** — Optimized JSON structure
|
|
31
|
+
- Removed `position` for static elements (default, no need to include)
|
|
32
|
+
- Removed `zIndex: "auto"` (default value)
|
|
33
|
+
- Removed `isStacking`, `hasBackdrop`, `isFullscreen` from position object
|
|
34
|
+
- Removed empty `children: []` arrays
|
|
35
|
+
- Filtered out `null`, `undefined`, empty string, and `false` values from metadata
|
|
36
|
+
- Google Search benchmark: ~38 KB → ~28 KB
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- **CLAUDE.md: analyzePage benchmark requirement** — Mandatory performance check
|
|
40
|
+
- Test URL: `https://www.google.com/search?q=puppeteer+mcp+server`
|
|
41
|
+
- Baseline: ~28 KB, threshold: < 40 KB
|
|
42
|
+
- Must run after any changes to analyzePage tool
|
|
43
|
+
|
|
44
|
+
## [3.3.6] - 2026-02-03
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- **Chrome Extension: POST request tracking** — Track POST requests via webRequest API
|
|
48
|
+
- Extension now captures POST/PUT/PATCH requests that Puppeteer may miss
|
|
49
|
+
- Shows in "Browser-level requests (via Extension)" section
|
|
50
|
+
- Useful for SPA apps with complex async request patterns
|
|
51
|
+
|
|
5
52
|
## [3.3.5] - 2026-01-30
|
|
6
53
|
|
|
7
54
|
### Fixed
|
package/browser/page-manager.js
CHANGED
|
@@ -7,6 +7,59 @@
|
|
|
7
7
|
import { getBrowser } from './browser-manager.js';
|
|
8
8
|
// Note: injectRecorder removed - now using Chrome Extension for recording
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Click listener tracking script - injected via evaluateOnNewDocument
|
|
12
|
+
* Monkey-patches addEventListener to track which elements have click listeners
|
|
13
|
+
* This enables detection of click handlers added by frameworks like Angular
|
|
14
|
+
*/
|
|
15
|
+
const CLICK_LISTENER_TRACKER_SCRIPT = `
|
|
16
|
+
(function() {
|
|
17
|
+
// Only inject once
|
|
18
|
+
if (window.__clickListenerTracker) return;
|
|
19
|
+
window.__clickListenerTracker = true;
|
|
20
|
+
|
|
21
|
+
// WeakMap to track elements with click listeners (prevents memory leaks)
|
|
22
|
+
const clickListeners = new WeakMap();
|
|
23
|
+
|
|
24
|
+
// Store original methods
|
|
25
|
+
const originalAddEventListener = EventTarget.prototype.addEventListener;
|
|
26
|
+
const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
|
|
27
|
+
|
|
28
|
+
// Patch addEventListener
|
|
29
|
+
EventTarget.prototype.addEventListener = function(type, listener, options) {
|
|
30
|
+
if (type === 'click' && this instanceof Element) {
|
|
31
|
+
// Track this element has click listener
|
|
32
|
+
const count = clickListeners.get(this) || 0;
|
|
33
|
+
clickListeners.set(this, count + 1);
|
|
34
|
+
}
|
|
35
|
+
return originalAddEventListener.call(this, type, listener, options);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Patch removeEventListener for accuracy
|
|
39
|
+
EventTarget.prototype.removeEventListener = function(type, listener, options) {
|
|
40
|
+
if (type === 'click' && this instanceof Element) {
|
|
41
|
+
const count = clickListeners.get(this) || 0;
|
|
42
|
+
if (count > 0) {
|
|
43
|
+
clickListeners.set(this, count - 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return originalRemoveEventListener.call(this, type, listener, options);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Global function to check if element has click listener
|
|
50
|
+
window.__hasClickListener = function(element) {
|
|
51
|
+
if (!element) return false;
|
|
52
|
+
const count = clickListeners.get(element);
|
|
53
|
+
return count && count > 0;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Also expose for debugging
|
|
57
|
+
window.__getClickListenerCount = function(element) {
|
|
58
|
+
return clickListeners.get(element) || 0;
|
|
59
|
+
};
|
|
60
|
+
})();
|
|
61
|
+
`;
|
|
62
|
+
|
|
10
63
|
/**
|
|
11
64
|
* Debug log helper (only logs to stderr when DEBUG=1)
|
|
12
65
|
* @param {...any} args - Arguments to log
|
|
@@ -159,6 +212,20 @@ export async function setupNetworkMonitoring(page) {
|
|
|
159
212
|
|
|
160
213
|
// Note: setupRecorderAutoReinjection removed - Chrome Extension handles recording now
|
|
161
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Inject click listener tracker into page
|
|
217
|
+
* Must be called BEFORE page.goto() to catch all listeners
|
|
218
|
+
* @param {Page} page - Puppeteer page instance
|
|
219
|
+
*/
|
|
220
|
+
async function injectClickListenerTracker(page) {
|
|
221
|
+
try {
|
|
222
|
+
await page.evaluateOnNewDocument(CLICK_LISTENER_TRACKER_SCRIPT);
|
|
223
|
+
debugLog('Click listener tracker injected');
|
|
224
|
+
} catch (error) {
|
|
225
|
+
debugLog('Failed to inject click listener tracker:', error.message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
162
229
|
/**
|
|
163
230
|
* Get or create page for URL
|
|
164
231
|
* @param {string} url - URL to navigate to
|
|
@@ -180,6 +247,9 @@ export async function getOrCreatePage(url) {
|
|
|
180
247
|
// Create new page
|
|
181
248
|
const page = await browser.newPage();
|
|
182
249
|
|
|
250
|
+
// Inject click listener tracker BEFORE navigation
|
|
251
|
+
await injectClickListenerTracker(page);
|
|
252
|
+
|
|
183
253
|
// Set up console log capture
|
|
184
254
|
const client = await page.target().createCDPSession();
|
|
185
255
|
await client.send('Runtime.enable');
|
|
@@ -277,6 +347,19 @@ export function setLastPage(page) {
|
|
|
277
347
|
* @returns {Promise<void>}
|
|
278
348
|
*/
|
|
279
349
|
export async function setupNewPage(page, source = 'manual') {
|
|
350
|
+
// Inject click listener tracker
|
|
351
|
+
// For new tabs, we need both:
|
|
352
|
+
// 1. evaluateOnNewDocument for future navigations
|
|
353
|
+
// 2. evaluate for already-loaded content (won't catch existing listeners, but will catch new ones)
|
|
354
|
+
try {
|
|
355
|
+
await page.evaluateOnNewDocument(CLICK_LISTENER_TRACKER_SCRIPT);
|
|
356
|
+
// Also inject immediately for current page (in case content already loaded)
|
|
357
|
+
await page.evaluate(CLICK_LISTENER_TRACKER_SCRIPT);
|
|
358
|
+
debugLog(`Click listener tracker injected for ${source} page`);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
debugLog('Failed to inject click listener tracker:', error.message);
|
|
361
|
+
}
|
|
362
|
+
|
|
280
363
|
// Set up console log capture
|
|
281
364
|
try {
|
|
282
365
|
const client = await page.target().createCDPSession();
|
package/index.js
CHANGED
|
@@ -2212,20 +2212,25 @@ Start coding now.`;
|
|
|
2212
2212
|
};
|
|
2213
2213
|
}
|
|
2214
2214
|
|
|
2215
|
+
// Store previous analysis for diff calculation
|
|
2216
|
+
if (!global.previousApomAnalysis) {
|
|
2217
|
+
global.previousApomAnalysis = new Map(); // pageUrl -> analysis data
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2215
2220
|
if (name === "analyzePage") {
|
|
2216
2221
|
const validatedArgs = schemas.AnalyzePageSchema.parse(args);
|
|
2217
2222
|
const page = await getLastOpenPage();
|
|
2218
2223
|
const pageUrl = page.url();
|
|
2219
2224
|
|
|
2220
2225
|
// APOM Tree format (default) - v2 with tree structure and positioning
|
|
2221
|
-
const apomResult = await page.evaluate((apomTreeConverterCode, selectorResolverCode, shouldRegister, includeAll) => {
|
|
2226
|
+
const apomResult = await page.evaluate((apomTreeConverterCode, selectorResolverCode, shouldRegister, includeAll, viewportOnly) => {
|
|
2222
2227
|
// Inject utilities
|
|
2223
2228
|
eval(apomTreeConverterCode);
|
|
2224
2229
|
eval(selectorResolverCode);
|
|
2225
2230
|
|
|
2226
2231
|
// Build APOM tree
|
|
2227
2232
|
// interactiveOnly = !includeAll (if includeAll is true, we want ALL elements)
|
|
2228
|
-
const apomData = buildAPOMTree(!includeAll);
|
|
2233
|
+
const apomData = buildAPOMTree(!includeAll, viewportOnly);
|
|
2229
2234
|
|
|
2230
2235
|
// Register elements in selector resolver if requested
|
|
2231
2236
|
if (shouldRegister) {
|
|
@@ -2258,7 +2263,43 @@ Start coding now.`;
|
|
|
2258
2263
|
}
|
|
2259
2264
|
|
|
2260
2265
|
return apomData;
|
|
2261
|
-
}, apomTreeConverter, selectorResolver, validatedArgs.registerElements !== false, validatedArgs.includeAll || false);
|
|
2266
|
+
}, apomTreeConverter, selectorResolver, validatedArgs.registerElements !== false, validatedArgs.includeAll || false, validatedArgs.viewportOnly || false);
|
|
2267
|
+
|
|
2268
|
+
// Handle diff mode
|
|
2269
|
+
if (validatedArgs.diff) {
|
|
2270
|
+
const previousAnalysis = global.previousApomAnalysis.get(pageUrl);
|
|
2271
|
+
|
|
2272
|
+
if (previousAnalysis) {
|
|
2273
|
+
// Calculate diff
|
|
2274
|
+
const diff = calculateApomDiff(previousAnalysis, apomResult);
|
|
2275
|
+
|
|
2276
|
+
// Store current analysis for next diff
|
|
2277
|
+
global.previousApomAnalysis.set(pageUrl, apomResult);
|
|
2278
|
+
|
|
2279
|
+
return {
|
|
2280
|
+
content: [{
|
|
2281
|
+
type: 'text',
|
|
2282
|
+
text: JSON.stringify({
|
|
2283
|
+
mode: 'diff',
|
|
2284
|
+
pageId: apomResult.pageId,
|
|
2285
|
+
url: apomResult.url,
|
|
2286
|
+
timestamp: apomResult.timestamp,
|
|
2287
|
+
previousTimestamp: previousAnalysis.timestamp,
|
|
2288
|
+
diff,
|
|
2289
|
+
metadata: apomResult.metadata,
|
|
2290
|
+
alerts: apomResult.alerts
|
|
2291
|
+
})
|
|
2292
|
+
}]
|
|
2293
|
+
};
|
|
2294
|
+
} else {
|
|
2295
|
+
// No previous analysis, return full result with note
|
|
2296
|
+
global.previousApomAnalysis.set(pageUrl, apomResult);
|
|
2297
|
+
apomResult._note = 'First analysis for this page, no diff available';
|
|
2298
|
+
}
|
|
2299
|
+
} else {
|
|
2300
|
+
// Store for future diff
|
|
2301
|
+
global.previousApomAnalysis.set(pageUrl, apomResult);
|
|
2302
|
+
}
|
|
2262
2303
|
|
|
2263
2304
|
return {
|
|
2264
2305
|
content: [{
|
|
@@ -2268,6 +2309,82 @@ Start coding now.`;
|
|
|
2268
2309
|
};
|
|
2269
2310
|
}
|
|
2270
2311
|
|
|
2312
|
+
/**
|
|
2313
|
+
* Calculate diff between two APOM analyses
|
|
2314
|
+
*/
|
|
2315
|
+
function calculateApomDiff(previous, current) {
|
|
2316
|
+
const previousElements = flattenApomTree(previous.tree);
|
|
2317
|
+
const currentElements = flattenApomTree(current.tree);
|
|
2318
|
+
|
|
2319
|
+
const previousIds = new Set(previousElements.map(e => e.id));
|
|
2320
|
+
const currentIds = new Set(currentElements.map(e => e.id));
|
|
2321
|
+
|
|
2322
|
+
const added = currentElements.filter(e => !previousIds.has(e.id));
|
|
2323
|
+
const removed = previousElements.filter(e => !currentIds.has(e.id));
|
|
2324
|
+
|
|
2325
|
+
// Find changed elements (same ID but different content)
|
|
2326
|
+
const changed = [];
|
|
2327
|
+
for (const curr of currentElements) {
|
|
2328
|
+
if (previousIds.has(curr.id)) {
|
|
2329
|
+
const prev = previousElements.find(e => e.id === curr.id);
|
|
2330
|
+
if (prev && JSON.stringify(prev.metadata) !== JSON.stringify(curr.metadata)) {
|
|
2331
|
+
changed.push({
|
|
2332
|
+
id: curr.id,
|
|
2333
|
+
type: curr.type,
|
|
2334
|
+
before: prev.metadata,
|
|
2335
|
+
after: curr.metadata
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
return {
|
|
2342
|
+
added: added.length > 0 ? added : undefined,
|
|
2343
|
+
removed: removed.length > 0 ? removed : undefined,
|
|
2344
|
+
changed: changed.length > 0 ? changed : undefined,
|
|
2345
|
+
summary: {
|
|
2346
|
+
addedCount: added.length,
|
|
2347
|
+
removedCount: removed.length,
|
|
2348
|
+
changedCount: changed.length
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* Flatten APOM tree to array of elements
|
|
2355
|
+
*/
|
|
2356
|
+
function flattenApomTree(node, result = []) {
|
|
2357
|
+
if (!node) return result;
|
|
2358
|
+
|
|
2359
|
+
// Handle compact format: { "tag_id": [children] }
|
|
2360
|
+
if (typeof node === 'object' && !node.id && !node.tag) {
|
|
2361
|
+
const keys = Object.keys(node);
|
|
2362
|
+
for (const key of keys) {
|
|
2363
|
+
if (Array.isArray(node[key])) {
|
|
2364
|
+
node[key].forEach(child => flattenApomTree(child, result));
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
return result;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// Interactive element with id
|
|
2371
|
+
if (node.id) {
|
|
2372
|
+
result.push({
|
|
2373
|
+
id: node.id,
|
|
2374
|
+
tag: node.tag,
|
|
2375
|
+
type: node.type,
|
|
2376
|
+
metadata: node.metadata
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// Process children
|
|
2381
|
+
if (node.children) {
|
|
2382
|
+
node.children.forEach(child => flattenApomTree(child, result));
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
return result;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2271
2388
|
if (name === "getElementDetails") {
|
|
2272
2389
|
const validatedArgs = schemas.GetElementDetailsSchema.parse(args);
|
|
2273
2390
|
const page = await getLastOpenPage();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrometools-mcp",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.8",
|
|
4
4
|
"description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, UI framework detection (MUI, Ant Design, etc.), Page Object support, visual testing, Figma comparison. Works seamlessly in WSL, Linux, macOS, and Windows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* Runs in browser context via page.evaluate()
|
|
11
11
|
*
|
|
12
12
|
* @param {boolean} interactiveOnly - Only include interactive elements and their parents
|
|
13
|
+
* @param {boolean} viewportOnly - Only include elements visible in current viewport
|
|
13
14
|
* @returns {Object} APOM tree structure
|
|
14
15
|
*/
|
|
15
|
-
function buildAPOMTree(interactiveOnly = true) {
|
|
16
|
+
function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
16
17
|
const pageId = `page_${btoa(window.location.href).replace(/[^a-zA-Z0-9]/g, '').substring(0, 20)}_${Date.now()}`;
|
|
17
18
|
|
|
18
19
|
const result = {
|
|
@@ -26,7 +27,14 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
26
27
|
interactiveCount: 0,
|
|
27
28
|
formCount: 0,
|
|
28
29
|
modalCount: 0,
|
|
29
|
-
maxDepth: 0
|
|
30
|
+
maxDepth: 0,
|
|
31
|
+
viewportOnly: viewportOnly || undefined,
|
|
32
|
+
viewport: viewportOnly ? {
|
|
33
|
+
width: window.innerWidth || document.documentElement.clientWidth,
|
|
34
|
+
height: window.innerHeight || document.documentElement.clientHeight,
|
|
35
|
+
scrollX: window.scrollX || window.pageXOffset,
|
|
36
|
+
scrollY: window.scrollY || window.pageYOffset
|
|
37
|
+
} : undefined
|
|
30
38
|
}
|
|
31
39
|
};
|
|
32
40
|
|
|
@@ -51,8 +59,122 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
51
59
|
// Collect radio and checkbox groups for easier agent access
|
|
52
60
|
result.groups = collectInputGroups(result.tree);
|
|
53
61
|
|
|
62
|
+
// Detect page alerts, warnings, errors, and restrictions
|
|
63
|
+
result.alerts = detectPageAlerts();
|
|
64
|
+
|
|
54
65
|
return result;
|
|
55
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Detect page alerts, warnings, errors, toasts, and restrictions
|
|
69
|
+
* Uses heuristics to find notification elements
|
|
70
|
+
*/
|
|
71
|
+
function detectPageAlerts() {
|
|
72
|
+
const alerts = [];
|
|
73
|
+
const seenTexts = new Set();
|
|
74
|
+
const MIN_TEXT_LENGTH = 10; // Ignore very short texts like "19", "OK"
|
|
75
|
+
|
|
76
|
+
// Helper to add alert avoiding duplicates and substrings
|
|
77
|
+
function addAlert(type, text, source) {
|
|
78
|
+
text = text?.trim();
|
|
79
|
+
if (!text || text.length < MIN_TEXT_LENGTH || text.length > 300) return;
|
|
80
|
+
|
|
81
|
+
// Normalize text
|
|
82
|
+
const normalizedText = text.substring(0, 200);
|
|
83
|
+
|
|
84
|
+
// Skip if already seen or is substring of existing
|
|
85
|
+
if (seenTexts.has(normalizedText)) return;
|
|
86
|
+
for (const seen of seenTexts) {
|
|
87
|
+
if (seen.includes(normalizedText) || normalizedText.includes(seen)) return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
seenTexts.add(normalizedText);
|
|
91
|
+
alerts.push({ type, text: normalizedText, source });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 1. Elements with role="alert" or aria-live (highest priority)
|
|
95
|
+
document.querySelectorAll('[role="alert"], [aria-live="assertive"]').forEach(el => {
|
|
96
|
+
const text = el.textContent?.trim();
|
|
97
|
+
addAlert('alert', text, 'aria');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 2. Classes containing restriction keywords (high priority - affects functionality)
|
|
101
|
+
const restrictionSelector = '[class*="deactivat"], [class*="disabled"], [class*="blocked"], [class*="restrict"], [class*="suspend"], [class*="inactive"]';
|
|
102
|
+
document.querySelectorAll(restrictionSelector).forEach(el => {
|
|
103
|
+
if (!isVisibleForAlert(el)) return;
|
|
104
|
+
const text = el.textContent?.trim();
|
|
105
|
+
addAlert('restriction', text, 'class');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 3. Error classes
|
|
109
|
+
document.querySelectorAll('[class*="error"], [class*="danger"], [class*="invalid"]').forEach(el => {
|
|
110
|
+
if (!isVisibleForAlert(el)) return;
|
|
111
|
+
const text = el.textContent?.trim();
|
|
112
|
+
addAlert('error', text, 'class');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 4. Warning classes
|
|
116
|
+
document.querySelectorAll('[class*="warning"], [class*="warn"], [class*="caution"]').forEach(el => {
|
|
117
|
+
if (!isVisibleForAlert(el)) return;
|
|
118
|
+
const text = el.textContent?.trim();
|
|
119
|
+
addAlert('warning', text, 'class');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 5. Toast/Snackbar notifications (usually important messages)
|
|
123
|
+
document.querySelectorAll('[class*="toast"], [class*="snackbar"], [class*="alert-banner"]').forEach(el => {
|
|
124
|
+
if (!isVisibleForAlert(el)) return;
|
|
125
|
+
const text = el.textContent?.trim();
|
|
126
|
+
addAlert('notification', text, 'toast');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 6. SVG icons with warning colors - only for restriction/deactivated contexts
|
|
130
|
+
const warningColors = ['#FF3B30', '#FAB32F', '#FFCC00', '#FF9500', '#F44336'];
|
|
131
|
+
document.querySelectorAll('svg').forEach(svg => {
|
|
132
|
+
const svgHtml = svg.outerHTML.toLowerCase();
|
|
133
|
+
const hasWarningColor = warningColors.some(c => svgHtml.includes(c.toLowerCase()));
|
|
134
|
+
if (!hasWarningColor) return;
|
|
135
|
+
|
|
136
|
+
// Only consider if parent has restriction-related class or text
|
|
137
|
+
let parent = svg.parentElement;
|
|
138
|
+
let attempts = 0;
|
|
139
|
+
while (parent && attempts < 3) {
|
|
140
|
+
const className = parent.className?.toLowerCase() || '';
|
|
141
|
+
const text = parent.textContent?.trim() || '';
|
|
142
|
+
|
|
143
|
+
// Check if context suggests restriction/warning
|
|
144
|
+
const isRestrictionContext =
|
|
145
|
+
/deactivat|disabled|blocked|restrict|suspend|приостановлен|заблокирован/.test(className + ' ' + text.toLowerCase());
|
|
146
|
+
|
|
147
|
+
if (isRestrictionContext && text.length >= MIN_TEXT_LENGTH) {
|
|
148
|
+
addAlert('restriction', text, 'icon-color');
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
parent = parent.parentElement;
|
|
152
|
+
attempts++;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// 7. Elements with semantic key/name attributes
|
|
157
|
+
document.querySelectorAll('[key*="error"], [key*="warning"], [key*="deactivat"], [key*="block"]').forEach(el => {
|
|
158
|
+
const parent = el.closest('div, span, p');
|
|
159
|
+
if (parent) {
|
|
160
|
+
const text = parent.textContent?.trim();
|
|
161
|
+
addAlert('warning', text, 'semantic-attr');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Return only if there are meaningful alerts
|
|
166
|
+
return alerts.length > 0 ? alerts : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if element is visible (for alert detection)
|
|
171
|
+
*/
|
|
172
|
+
function isVisibleForAlert(el) {
|
|
173
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0) return false;
|
|
174
|
+
const style = window.getComputedStyle(el);
|
|
175
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
176
|
+
}
|
|
177
|
+
|
|
56
178
|
/**
|
|
57
179
|
* Collect radio and checkbox groups from the tree
|
|
58
180
|
*/
|
|
@@ -148,6 +270,32 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
148
270
|
return node;
|
|
149
271
|
}
|
|
150
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Filter out null, undefined, and empty string values from object
|
|
275
|
+
* to reduce JSON output size
|
|
276
|
+
*/
|
|
277
|
+
function filterNullValues(obj) {
|
|
278
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
279
|
+
|
|
280
|
+
const result = {};
|
|
281
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
282
|
+
// Skip null, undefined, and empty strings
|
|
283
|
+
if (value === null || value === undefined || value === '') continue;
|
|
284
|
+
// Skip false for boolean fields (keep true)
|
|
285
|
+
if (value === false) continue;
|
|
286
|
+
// Recursively filter nested objects (but not arrays)
|
|
287
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
288
|
+
const filtered = filterNullValues(value);
|
|
289
|
+
if (Object.keys(filtered).length > 0) {
|
|
290
|
+
result[key] = filtered;
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
result[key] = value;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
151
299
|
/**
|
|
152
300
|
* Check if cursor:pointer is explicitly set (not inherited)
|
|
153
301
|
*/
|
|
@@ -229,6 +377,23 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
229
377
|
interactiveElements.add(document.body);
|
|
230
378
|
}
|
|
231
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Check if element is in viewport
|
|
382
|
+
*/
|
|
383
|
+
function isInViewport(el) {
|
|
384
|
+
const rect = el.getBoundingClientRect();
|
|
385
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
386
|
+
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
387
|
+
|
|
388
|
+
// Element is in viewport if any part of it is visible
|
|
389
|
+
return (
|
|
390
|
+
rect.bottom > 0 &&
|
|
391
|
+
rect.right > 0 &&
|
|
392
|
+
rect.top < viewportHeight &&
|
|
393
|
+
rect.left < viewportWidth
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
232
397
|
/**
|
|
233
398
|
* Check if element is visible
|
|
234
399
|
* More reliable check that works with position:fixed elements (Angular Material, etc.)
|
|
@@ -248,6 +413,11 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
248
413
|
// For body element, always consider visible if dimensions > 0
|
|
249
414
|
if (el === document.body) return true;
|
|
250
415
|
|
|
416
|
+
// Check viewport if viewportOnly mode is enabled
|
|
417
|
+
if (viewportOnly && !isInViewport(el)) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
251
421
|
// Additional check: element should be in viewport or have offsetParent
|
|
252
422
|
// This handles elements inside position:fixed containers (Angular Material)
|
|
253
423
|
return el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky';
|
|
@@ -297,19 +467,23 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
297
467
|
node = {
|
|
298
468
|
id,
|
|
299
469
|
tag: element.tagName.toLowerCase(),
|
|
300
|
-
position,
|
|
301
470
|
type: elementType.type,
|
|
302
471
|
children: []
|
|
303
472
|
};
|
|
304
473
|
|
|
474
|
+
// Only include position if not static (position is null for static)
|
|
475
|
+
if (position) {
|
|
476
|
+
node.position = position;
|
|
477
|
+
}
|
|
478
|
+
|
|
305
479
|
// Add selector only in includeAll mode
|
|
306
480
|
if (!interactiveOnly) {
|
|
307
481
|
node.selector = selector;
|
|
308
482
|
}
|
|
309
483
|
|
|
310
|
-
// Add metadata for interactive elements
|
|
484
|
+
// Add metadata for interactive elements, filtering out null/undefined values
|
|
311
485
|
if (elementType.metadata) {
|
|
312
|
-
node.metadata = elementType.metadata;
|
|
486
|
+
node.metadata = filterNullValues(elementType.metadata);
|
|
313
487
|
}
|
|
314
488
|
} else {
|
|
315
489
|
// Containers: compact format "tag_id": [children] when interactiveOnly
|
|
@@ -340,8 +514,8 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
340
514
|
if (elementType.type === 'form') {
|
|
341
515
|
result.metadata.formCount++;
|
|
342
516
|
}
|
|
343
|
-
if (position.type === 'fixed' || position.type === 'absolute') {
|
|
344
|
-
if (position.zIndex >= 100) {
|
|
517
|
+
if (position && (position.type === 'fixed' || position.type === 'absolute')) {
|
|
518
|
+
if (position.zIndex && position.zIndex >= 100) {
|
|
345
519
|
result.metadata.modalCount++;
|
|
346
520
|
}
|
|
347
521
|
}
|
|
@@ -365,6 +539,11 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
365
539
|
return compactNode;
|
|
366
540
|
}
|
|
367
541
|
|
|
542
|
+
// Remove empty children array to reduce output size
|
|
543
|
+
if (node.children && node.children.length === 0) {
|
|
544
|
+
delete node.children;
|
|
545
|
+
}
|
|
546
|
+
|
|
368
547
|
return node;
|
|
369
548
|
}
|
|
370
549
|
|
|
@@ -379,37 +558,30 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
379
558
|
|
|
380
559
|
/**
|
|
381
560
|
* Get positioning information
|
|
561
|
+
* Returns null for static position (default) to reduce output size
|
|
382
562
|
*/
|
|
383
563
|
function getPositionInfo(element) {
|
|
384
564
|
const style = window.getComputedStyle(element);
|
|
385
565
|
const position = style.position;
|
|
386
|
-
const zIndex = style.zIndex === 'auto' ? 'auto' : parseInt(style.zIndex, 10);
|
|
387
566
|
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
(position === 'absolute' && zIndex !== 'auto') ||
|
|
393
|
-
(position === 'relative' && zIndex !== 'auto') ||
|
|
394
|
-
parseFloat(style.opacity) < 1 ||
|
|
395
|
-
style.transform !== 'none' ||
|
|
396
|
-
style.filter !== 'none' ||
|
|
397
|
-
style.perspective !== 'none' ||
|
|
398
|
-
style.clipPath !== 'none' ||
|
|
399
|
-
style.mask !== 'none' ||
|
|
400
|
-
style.mixBlendMode !== 'normal' ||
|
|
401
|
-
style.isolation === 'isolate';
|
|
567
|
+
// Skip static position (default) - no need to include
|
|
568
|
+
if (position === 'static') {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
402
571
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
hasBackdrop: style.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
|
|
409
|
-
(position === 'fixed' || position === 'absolute'),
|
|
410
|
-
isFullscreen: element.offsetWidth >= window.innerWidth * 0.9 &&
|
|
411
|
-
element.offsetHeight >= window.innerHeight * 0.9
|
|
572
|
+
const zIndex = style.zIndex === 'auto' ? 'auto' : parseInt(style.zIndex, 10);
|
|
573
|
+
|
|
574
|
+
// Only include non-default values
|
|
575
|
+
const result = {
|
|
576
|
+
type: position
|
|
412
577
|
};
|
|
578
|
+
|
|
579
|
+
// Only include zIndex if it's not 'auto'
|
|
580
|
+
if (zIndex !== 'auto') {
|
|
581
|
+
result.zIndex = zIndex;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return result;
|
|
413
585
|
}
|
|
414
586
|
|
|
415
587
|
/**
|
|
@@ -446,6 +618,83 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
446
618
|
}
|
|
447
619
|
}
|
|
448
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Check if tag is a custom element (Web Component / Framework component)
|
|
623
|
+
*/
|
|
624
|
+
function isCustomElement(tag) {
|
|
625
|
+
// Custom elements must contain a hyphen
|
|
626
|
+
return tag.includes('-');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Check if element has explicit click binding (framework attributes or addEventListener)
|
|
631
|
+
* Uses monkey-patched addEventListener tracker for framework detection (Angular, React, Vue)
|
|
632
|
+
*/
|
|
633
|
+
function hasExplicitClickBinding(element) {
|
|
634
|
+
// Check for framework-specific click bindings (attributes)
|
|
635
|
+
const attrs = element.attributes;
|
|
636
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
637
|
+
const name = attrs[i].name.toLowerCase();
|
|
638
|
+
// Angular: (click), Angular.js: ng-click
|
|
639
|
+
// Vue: @click, v-on:click
|
|
640
|
+
// React: onClick (but this is a property, not attribute)
|
|
641
|
+
if (name === '(click)' || name === 'ng-click' ||
|
|
642
|
+
name === '@click' || name === 'v-on:click' ||
|
|
643
|
+
name.startsWith('on') && name.includes('click')) {
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Check onclick property
|
|
648
|
+
if (element.onclick) return true;
|
|
649
|
+
// Check onclick attribute
|
|
650
|
+
if (element.hasAttribute('onclick')) return true;
|
|
651
|
+
|
|
652
|
+
// Check for click listeners added via addEventListener (framework detection)
|
|
653
|
+
// This is injected by page-manager.js via evaluateOnNewDocument
|
|
654
|
+
if (typeof window.__hasClickListener === 'function' && window.__hasClickListener(element)) {
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Find click handler for interactive elements (bubbling search)
|
|
663
|
+
* Searches UP from element to find ancestor with explicit click binding
|
|
664
|
+
* Returns "tag:id" format like "div:container_19" or null if not found
|
|
665
|
+
*/
|
|
666
|
+
function findClickHandler(element) {
|
|
667
|
+
const tag = element.tagName.toLowerCase();
|
|
668
|
+
|
|
669
|
+
// Check self first - native interactive elements handle their own clicks
|
|
670
|
+
if (hasExplicitClickBinding(element) ||
|
|
671
|
+
['button', 'a', 'input', 'select'].includes(tag) ||
|
|
672
|
+
element.getAttribute('role') === 'button') {
|
|
673
|
+
return null; // element handles its own click, no need for clickTarget
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Search UP (like event bubbling) - find ancestor with explicit click handler
|
|
677
|
+
let parent = element.parentElement;
|
|
678
|
+
let depth = 0;
|
|
679
|
+
const maxDepth = 5; // limit to prevent hanging
|
|
680
|
+
|
|
681
|
+
while (parent && depth < maxDepth) {
|
|
682
|
+
if (hasExplicitClickBinding(parent)) {
|
|
683
|
+
// Found real click handler
|
|
684
|
+
const parentId = elementIds.get(parent);
|
|
685
|
+
if (parentId) {
|
|
686
|
+
const parentTag = parent.tagName.toLowerCase();
|
|
687
|
+
return `${parentTag}:${parentId}`;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
parent = parent.parentElement;
|
|
691
|
+
depth++;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// No click handler found - return null (no fallback!)
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
449
698
|
/**
|
|
450
699
|
* Check if element is interactive based on various signals
|
|
451
700
|
*/
|
|
@@ -680,12 +929,20 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
680
929
|
|
|
681
930
|
// Generic container - check for JavaScript interactivity
|
|
682
931
|
const interactivityCheck = checkInteractivity(element);
|
|
932
|
+
|
|
933
|
+
// For ALL interactive elements, search for click handler (bubbling)
|
|
934
|
+
// Returns "tag:id" format or null if not found
|
|
935
|
+
const clickTarget = interactivityCheck.isInteractive
|
|
936
|
+
? findClickHandler(element)
|
|
937
|
+
: null;
|
|
938
|
+
|
|
683
939
|
return {
|
|
684
940
|
type: 'container',
|
|
685
941
|
isInteractive: interactivityCheck.isInteractive,
|
|
686
942
|
metadata: interactivityCheck.isInteractive ? {
|
|
687
943
|
text: element.textContent?.trim().substring(0, 100) || '',
|
|
688
|
-
interactivityReason: interactivityCheck.reason
|
|
944
|
+
interactivityReason: interactivityCheck.reason,
|
|
945
|
+
clickTarget: clickTarget || undefined
|
|
689
946
|
} : null
|
|
690
947
|
};
|
|
691
948
|
}
|
|
@@ -477,7 +477,7 @@ export const toolDefinitions = [
|
|
|
477
477
|
},
|
|
478
478
|
{
|
|
479
479
|
name: "analyzePage",
|
|
480
|
-
description: "PRIMARY tool for reading page state. Returns APOM tree: {tree, metadata, groups}. Compact format (default): containers as \"tag_id\":[children] keys, interactive elements as {id, tag, type, position, metadata} without selectors. Use element IDs (e.g., button_45, input_20) with click/type tools. Selectors registered internally for resolution. Use refresh:true after clicks. Efficient: 8-10k tokens vs screenshot 15-25k.",
|
|
480
|
+
description: "PRIMARY tool for reading page state. Returns APOM tree: {tree, metadata, groups}. Compact format (default): containers as \"tag_id\":[children] keys, interactive elements as {id, tag, type, position, metadata} without selectors. Use element IDs (e.g., button_45, input_20) with click/type tools. Selectors registered internally for resolution. Use refresh:true after clicks. Efficient: 8-10k tokens vs screenshot 15-25k. Legend: clickTarget format is \"tag:id\" (e.g., \"kp-chats-list-item:container_58\") - use the id part for clicking. No clickTarget = element handles its own click.",
|
|
481
481
|
inputSchema: {
|
|
482
482
|
type: "object",
|
|
483
483
|
properties: {
|
|
@@ -486,6 +486,8 @@ export const toolDefinitions = [
|
|
|
486
486
|
useLegacyFormat: { type: "boolean", description: "Return legacy format instead of APOM (default: false - APOM is now default)" },
|
|
487
487
|
registerElements: { type: "boolean", description: "Auto-register elements in selector resolver (default: true)" },
|
|
488
488
|
groupBy: { type: "string", description: "Group elements: 'type' or 'flat' (default: 'type')", enum: ["type", "flat"] },
|
|
489
|
+
viewportOnly: { type: "boolean", description: "Only analyze elements in current viewport (default: false). Reduces output for long pages." },
|
|
490
|
+
diff: { type: "boolean", description: "Return only changes since last analysis: {added, removed, changed} (default: false)." },
|
|
489
491
|
},
|
|
490
492
|
},
|
|
491
493
|
},
|
package/server/tool-schemas.js
CHANGED
|
@@ -263,6 +263,8 @@ export const AnalyzePageSchema = z.object({
|
|
|
263
263
|
useLegacyFormat: z.boolean().optional().describe("Return legacy format instead of APOM (default: false - APOM is now the default format)"),
|
|
264
264
|
registerElements: z.boolean().optional().describe("Automatically register elements in selector resolver (default: true)"),
|
|
265
265
|
groupBy: z.enum(['type', 'flat']).optional().describe("Group elements by type or return flat structure (default: 'type')"),
|
|
266
|
+
viewportOnly: z.boolean().optional().describe("Only analyze elements visible in current viewport (default: false). Reduces output for long pages."),
|
|
267
|
+
diff: z.boolean().optional().describe("Return only changes since last analysis: {added, removed, changed} (default: false). Useful after clicks to see what changed."),
|
|
266
268
|
});
|
|
267
269
|
|
|
268
270
|
export const GetElementDetailsSchema = z.object({
|