webtap-tool 0.4.0__py3-none-any.whl → 0.5.1__py3-none-any.whl
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.
Potentially problematic release.
This version of webtap-tool might be problematic. Click here for more details.
- webtap/api.py +318 -9
- webtap/app.py +12 -5
- webtap/cdp/session.py +101 -1
- webtap/commands/DEVELOPER_GUIDE.md +108 -22
- webtap/commands/TIPS.md +24 -1
- webtap/commands/_builders.py +139 -1
- webtap/commands/body.py +1 -2
- webtap/commands/connection.py +1 -2
- webtap/commands/console.py +1 -2
- webtap/commands/events.py +1 -2
- webtap/commands/fetch.py +1 -2
- webtap/commands/inspect.py +1 -2
- webtap/commands/javascript.py +61 -28
- webtap/commands/navigation.py +1 -2
- webtap/commands/network.py +17 -35
- webtap/commands/selections.py +129 -0
- webtap/commands/server.py +1 -0
- webtap/services/dom.py +512 -0
- webtap/services/main.py +14 -0
- {webtap_tool-0.4.0.dist-info → webtap_tool-0.5.1.dist-info}/METADATA +1 -1
- {webtap_tool-0.4.0.dist-info → webtap_tool-0.5.1.dist-info}/RECORD +23 -22
- webtap/commands/_errors.py +0 -108
- {webtap_tool-0.4.0.dist-info → webtap_tool-0.5.1.dist-info}/WHEEL +0 -0
- {webtap_tool-0.4.0.dist-info → webtap_tool-0.5.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Browser element selection and prompt analysis commands.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- browser: Analyze browser element selections with prompt
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from webtap.app import app
|
|
8
|
+
from webtap.commands._utils import evaluate_expression, format_expression_result
|
|
9
|
+
from webtap.commands._builders import error_response
|
|
10
|
+
from webtap.commands._tips import get_tips
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command(
|
|
14
|
+
display="markdown",
|
|
15
|
+
fastmcp=[{"type": "resource", "mime_type": "application/json"}, {"type": "tool"}],
|
|
16
|
+
)
|
|
17
|
+
def selections(state, expr: str = None) -> dict: # pyright: ignore[reportArgumentType]
|
|
18
|
+
"""Browser element selections with prompt and analysis.
|
|
19
|
+
|
|
20
|
+
As Resource (no parameters):
|
|
21
|
+
browser # Returns current prompt and all selections
|
|
22
|
+
|
|
23
|
+
As Tool (with parameters):
|
|
24
|
+
browser(expr="data['prompt']") # Get prompt text
|
|
25
|
+
browser(expr="data['selections']['1']['styles']") # Get styles for #1
|
|
26
|
+
browser(expr="len(data['selections'])") # Count selections
|
|
27
|
+
browser(expr="{k: v['selector'] for k, v in data['selections'].items()}") # All selectors
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
expr: Python expression with 'data' variable containing prompt and selections
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Formatted browser data or expression result
|
|
34
|
+
"""
|
|
35
|
+
# Check if browser data exists
|
|
36
|
+
if not hasattr(state, "browser_data") or not state.browser_data:
|
|
37
|
+
return error_response(
|
|
38
|
+
"No browser selections available",
|
|
39
|
+
suggestions=[
|
|
40
|
+
"Use the Chrome extension to select elements",
|
|
41
|
+
"Click 'Start Selection Mode' in the extension popup",
|
|
42
|
+
"Select elements on the page and submit a prompt",
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
data = state.browser_data
|
|
47
|
+
|
|
48
|
+
# No expression - RESOURCE MODE: Return formatted view
|
|
49
|
+
if not expr:
|
|
50
|
+
return _format_browser_data(data)
|
|
51
|
+
|
|
52
|
+
# TOOL MODE: Evaluate expression
|
|
53
|
+
try:
|
|
54
|
+
namespace = {"data": data}
|
|
55
|
+
result, output = evaluate_expression(expr, namespace)
|
|
56
|
+
formatted_result = format_expression_result(result, output)
|
|
57
|
+
|
|
58
|
+
# Build markdown response
|
|
59
|
+
return {
|
|
60
|
+
"elements": [
|
|
61
|
+
{"type": "heading", "content": "Expression Result", "level": 2},
|
|
62
|
+
{"type": "code_block", "content": expr, "language": "python"},
|
|
63
|
+
{"type": "text", "content": "**Result:**"},
|
|
64
|
+
{"type": "code_block", "content": formatted_result, "language": ""},
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
except Exception as e:
|
|
68
|
+
# Provide helpful suggestions
|
|
69
|
+
suggestions = [
|
|
70
|
+
"The data is available as 'data' variable",
|
|
71
|
+
"Access prompt: data['prompt']",
|
|
72
|
+
"Access selections: data['selections']",
|
|
73
|
+
"Access specific element: data['selections']['1']",
|
|
74
|
+
"Available fields: outerHTML, selector, jsPath, styles, xpath, fullXpath, preview",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
if "KeyError" in str(type(e).__name__):
|
|
78
|
+
suggestions.extend(
|
|
79
|
+
[
|
|
80
|
+
"Check available selection IDs: list(data['selections'].keys())",
|
|
81
|
+
"Check available fields: data['selections']['1'].keys()",
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return error_response(f"{type(e).__name__}: {e}", suggestions=suggestions)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _format_browser_data(data: dict) -> dict:
|
|
89
|
+
"""Format browser data as markdown for resource view."""
|
|
90
|
+
elements = []
|
|
91
|
+
|
|
92
|
+
# Show prompt
|
|
93
|
+
elements.append({"type": "heading", "content": "Browser Prompt", "level": 2})
|
|
94
|
+
elements.append({"type": "text", "content": data.get("prompt", "")})
|
|
95
|
+
|
|
96
|
+
# Show selection count
|
|
97
|
+
selection_count = len(data.get("selections", {}))
|
|
98
|
+
elements.append({"type": "text", "content": f"\n**Selected Elements:** {selection_count}"})
|
|
99
|
+
|
|
100
|
+
# Show each selection with preview
|
|
101
|
+
if selection_count > 0:
|
|
102
|
+
elements.append({"type": "heading", "content": "Element Selections", "level": 3})
|
|
103
|
+
|
|
104
|
+
for sel_id in sorted(data["selections"].keys(), key=lambda x: int(x)):
|
|
105
|
+
sel = data["selections"][sel_id]
|
|
106
|
+
preview = sel.get("preview", {})
|
|
107
|
+
|
|
108
|
+
# Build preview line
|
|
109
|
+
preview_parts = [f"**#{sel_id}:**", preview.get("tag", "unknown")]
|
|
110
|
+
if preview.get("id"):
|
|
111
|
+
preview_parts.append(f"#{preview['id']}")
|
|
112
|
+
if preview.get("classes"):
|
|
113
|
+
preview_parts.append(f".{preview['classes'][0]}")
|
|
114
|
+
|
|
115
|
+
elements.append({"type": "text", "content": " ".join(preview_parts)})
|
|
116
|
+
|
|
117
|
+
# Show selector
|
|
118
|
+
elements.append({"type": "code_block", "content": sel.get("selector", ""), "language": "css"})
|
|
119
|
+
|
|
120
|
+
# Show usage tips from TIPS.md
|
|
121
|
+
tips = get_tips("selections")
|
|
122
|
+
if tips:
|
|
123
|
+
elements.append({"type": "heading", "content": "Next Steps", "level": 3})
|
|
124
|
+
elements.append({"type": "list", "items": tips})
|
|
125
|
+
|
|
126
|
+
return {"elements": elements}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = ["selections"]
|
webtap/commands/server.py
CHANGED
webtap/services/dom.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""DOM inspection service using Chrome DevTools Protocol.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- DOMService: Manages element inspection and selection via CDP Overlay domain
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from typing import Any, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from webtap.cdp.session import CDPSession
|
|
15
|
+
from webtap.app import WebTapState
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DOMService:
|
|
21
|
+
"""Manages element inspection and selection via CDP Overlay domain.
|
|
22
|
+
|
|
23
|
+
Uses CDP's native inspect mode (Overlay.setInspectMode) which provides:
|
|
24
|
+
- Native Chrome highlight on hover (no custom overlay needed)
|
|
25
|
+
- Click events via Overlay.inspectNodeRequested
|
|
26
|
+
- Accurate element data via DOM.describeNode, CSS.getComputedStyleForNode
|
|
27
|
+
|
|
28
|
+
Selections are stored in state.browser_data (not DuckDB) as they are
|
|
29
|
+
ephemeral session data cleared after prompt submission.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
cdp: CDP session for executing commands
|
|
33
|
+
state: WebTap state for storing selections
|
|
34
|
+
_inspection_active: Whether inspect mode is currently active
|
|
35
|
+
_next_id: Counter for assigning selection IDs
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, cdp: "CDPSession | None" = None, state: "WebTapState | None" = None):
|
|
39
|
+
"""Initialize DOM service.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
cdp: CDPSession instance. Can be None initially, set via set_cdp().
|
|
43
|
+
state: WebTapState instance. Can be None initially, set via set_state().
|
|
44
|
+
"""
|
|
45
|
+
self.cdp = cdp
|
|
46
|
+
self.state = state
|
|
47
|
+
self._inspection_active = False
|
|
48
|
+
self._next_id = 1
|
|
49
|
+
self._broadcast_queue: "Any | None" = None # asyncio.Queue for thread-safe broadcasts
|
|
50
|
+
self._state_lock = threading.Lock() # Protect state mutations
|
|
51
|
+
self._pending_selections = 0 # Track in-flight selection processing
|
|
52
|
+
self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="dom-worker")
|
|
53
|
+
|
|
54
|
+
def set_cdp(self, cdp: "CDPSession") -> None:
|
|
55
|
+
"""Set CDP session after initialization."""
|
|
56
|
+
self.cdp = cdp
|
|
57
|
+
|
|
58
|
+
def set_state(self, state: "WebTapState") -> None:
|
|
59
|
+
"""Set state after initialization."""
|
|
60
|
+
self.state = state
|
|
61
|
+
|
|
62
|
+
def set_broadcast_queue(self, queue: "Any") -> None:
|
|
63
|
+
"""Set queue for broadcasting state changes.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
queue: asyncio.Queue for thread-safe signaling
|
|
67
|
+
"""
|
|
68
|
+
self._broadcast_queue = queue
|
|
69
|
+
|
|
70
|
+
def start_inspect(self) -> dict[str, Any]:
|
|
71
|
+
"""Enable CDP element inspection mode.
|
|
72
|
+
|
|
73
|
+
Enables Overlay.setInspectMode with searchForNode mode, which:
|
|
74
|
+
- Shows native Chrome highlight on hover
|
|
75
|
+
- Fires Overlay.inspectNodeRequested on click
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Success status dictionary.
|
|
79
|
+
"""
|
|
80
|
+
if not self.cdp or not self.cdp.ws_app:
|
|
81
|
+
return {"error": "Not connected to page"}
|
|
82
|
+
|
|
83
|
+
if self._inspection_active:
|
|
84
|
+
return {"error": "Inspection already active"}
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Enable DOM domain first (Overlay depends on it)
|
|
88
|
+
self.cdp.execute("DOM.enable")
|
|
89
|
+
|
|
90
|
+
# Request document to establish DOM tree context
|
|
91
|
+
# REQUIRED: BackendNodeIds only work after getDocument() is called
|
|
92
|
+
self.cdp.execute("DOM.getDocument", {"depth": -1})
|
|
93
|
+
|
|
94
|
+
# Enable CSS domain (needed for computed styles)
|
|
95
|
+
self.cdp.execute("CSS.enable")
|
|
96
|
+
|
|
97
|
+
# Enable Overlay domain
|
|
98
|
+
self.cdp.execute("Overlay.enable")
|
|
99
|
+
|
|
100
|
+
# Set inspect mode with native Chrome highlighting
|
|
101
|
+
self.cdp.execute(
|
|
102
|
+
"Overlay.setInspectMode",
|
|
103
|
+
{
|
|
104
|
+
"mode": "searchForNode",
|
|
105
|
+
"highlightConfig": {
|
|
106
|
+
"showInfo": True,
|
|
107
|
+
"showStyles": True,
|
|
108
|
+
"contentColor": {"r": 111, "g": 168, "b": 220, "a": 0.66},
|
|
109
|
+
"paddingColor": {"r": 147, "g": 196, "b": 125, "a": 0.55},
|
|
110
|
+
"borderColor": {"r": 255, "g": 229, "b": 153, "a": 0.66},
|
|
111
|
+
"marginColor": {"r": 246, "g": 178, "b": 107, "a": 0.66},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self._inspection_active = True
|
|
117
|
+
logger.info("Element inspection mode enabled")
|
|
118
|
+
|
|
119
|
+
return {"success": True, "inspect_active": True}
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Failed to enable inspection mode: {e}")
|
|
123
|
+
return {"error": str(e)}
|
|
124
|
+
|
|
125
|
+
def stop_inspect(self) -> dict[str, Any]:
|
|
126
|
+
"""Disable CDP element inspection mode.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Success status dictionary.
|
|
130
|
+
"""
|
|
131
|
+
if not self.cdp or not self.cdp.ws_app:
|
|
132
|
+
return {"error": "Not connected to page"}
|
|
133
|
+
|
|
134
|
+
if not self._inspection_active:
|
|
135
|
+
return {"success": True, "inspect_active": False}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
# Disable inspect mode
|
|
139
|
+
# NOTE: highlightConfig required even for mode=none, otherwise CDP throws:
|
|
140
|
+
# "Internal error: highlight configuration parameter is missing"
|
|
141
|
+
self.cdp.execute("Overlay.setInspectMode", {"mode": "none", "highlightConfig": {}})
|
|
142
|
+
|
|
143
|
+
self._inspection_active = False
|
|
144
|
+
logger.info("Element inspection mode disabled")
|
|
145
|
+
|
|
146
|
+
return {"success": True, "inspect_active": False}
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Failed to disable inspection mode: {e}")
|
|
150
|
+
return {"error": str(e)}
|
|
151
|
+
|
|
152
|
+
def handle_inspect_node_requested(self, event: dict) -> None:
|
|
153
|
+
"""Handle Overlay.inspectNodeRequested event (user clicked element).
|
|
154
|
+
|
|
155
|
+
CRITICAL: Called from WebSocket thread - MUST NOT make blocking CDP calls!
|
|
156
|
+
Offload to background thread to avoid deadlock.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
event: CDP event with method and params
|
|
160
|
+
"""
|
|
161
|
+
if not self.cdp or not self.state:
|
|
162
|
+
logger.error("DOMService not properly initialized (missing cdp or state)")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
params = event.get("params", {})
|
|
166
|
+
backend_node_id = params.get("backendNodeId")
|
|
167
|
+
if not backend_node_id:
|
|
168
|
+
logger.warning("inspectNodeRequested event missing backendNodeId")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Increment pending counter (thread-safe)
|
|
172
|
+
with self._state_lock:
|
|
173
|
+
self._pending_selections += 1
|
|
174
|
+
self._trigger_broadcast()
|
|
175
|
+
|
|
176
|
+
# Submit to background thread - returns immediately, no blocking
|
|
177
|
+
self._executor.submit(self._process_node_selection, backend_node_id)
|
|
178
|
+
|
|
179
|
+
def handle_frame_navigated(self, event: dict) -> None:
|
|
180
|
+
"""Handle Page.frameNavigated event (page navigation).
|
|
181
|
+
|
|
182
|
+
Clears selections when main frame navigates to keep state in sync with page.
|
|
183
|
+
Called from WebSocket thread - must be non-blocking.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
event: CDP event with method and params
|
|
187
|
+
"""
|
|
188
|
+
params = event.get("params", {})
|
|
189
|
+
frame = params.get("frame", {})
|
|
190
|
+
|
|
191
|
+
# Only clear on main frame navigation (not iframes)
|
|
192
|
+
if frame.get("parentId"):
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
logger.info("Main frame navigated - clearing selections")
|
|
196
|
+
self.clear_selections()
|
|
197
|
+
self._trigger_broadcast()
|
|
198
|
+
|
|
199
|
+
def _process_node_selection(self, backend_node_id: int) -> None:
|
|
200
|
+
"""Process node selection in background thread.
|
|
201
|
+
|
|
202
|
+
Safe to make blocking CDP calls here - we're not in WebSocket thread.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
backend_node_id: CDP backend node ID from inspectNodeRequested event
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
# Make blocking CDP calls (OK in background thread)
|
|
209
|
+
data = self._extract_node_data(backend_node_id)
|
|
210
|
+
|
|
211
|
+
# Thread-safe state update
|
|
212
|
+
with self._state_lock:
|
|
213
|
+
if not self.state:
|
|
214
|
+
logger.error("DOMService state not initialized")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
selection_id = str(self._next_id)
|
|
218
|
+
self._next_id += 1
|
|
219
|
+
|
|
220
|
+
if not self.state.browser_data:
|
|
221
|
+
self.state.browser_data = {"selections": {}, "prompt": ""}
|
|
222
|
+
if "selections" not in self.state.browser_data:
|
|
223
|
+
self.state.browser_data["selections"] = {}
|
|
224
|
+
|
|
225
|
+
self.state.browser_data["selections"][selection_id] = data
|
|
226
|
+
|
|
227
|
+
logger.info(f"Element selected: {selection_id} - {data.get('preview', {}).get('tag', 'unknown')}")
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.error(f"Failed to process node selection: {e}")
|
|
231
|
+
# Set error state for UI display
|
|
232
|
+
if self.state:
|
|
233
|
+
import time
|
|
234
|
+
|
|
235
|
+
error_msg = str(e)
|
|
236
|
+
# Provide user-friendly message for common errors
|
|
237
|
+
if "timed out" in error_msg.lower() or isinstance(e, TimeoutError):
|
|
238
|
+
error_msg = "Element selection timed out - page may be unresponsive"
|
|
239
|
+
self.state.error_state = {"message": error_msg, "timestamp": time.time()}
|
|
240
|
+
finally:
|
|
241
|
+
# Decrement pending counter (thread-safe)
|
|
242
|
+
with self._state_lock:
|
|
243
|
+
self._pending_selections -= 1
|
|
244
|
+
self._trigger_broadcast()
|
|
245
|
+
|
|
246
|
+
def _trigger_broadcast(self) -> None:
|
|
247
|
+
"""Trigger SSE broadcast via queue (thread-safe helper)."""
|
|
248
|
+
if self._broadcast_queue:
|
|
249
|
+
try:
|
|
250
|
+
self._broadcast_queue.put_nowait({"type": "dom_update"})
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logger.debug(f"Failed to queue broadcast: {e}")
|
|
253
|
+
|
|
254
|
+
def _extract_node_data(self, backend_node_id: int) -> dict[str, Any]:
|
|
255
|
+
"""Extract complete element data via CDP.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
backend_node_id: CDP backend node ID from inspectNodeRequested event
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Dictionary with element data compatible with browser_data schema
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
RuntimeError: If CDP is not connected or commands fail
|
|
265
|
+
TimeoutError: If CDP commands timeout (page busy, heavy load)
|
|
266
|
+
"""
|
|
267
|
+
if not self.cdp:
|
|
268
|
+
raise RuntimeError("CDP session not initialized")
|
|
269
|
+
|
|
270
|
+
# Use 15s timeout for interactive operations (balanced between responsiveness and heavy pages)
|
|
271
|
+
# Still shorter than default 30s to provide faster failure feedback
|
|
272
|
+
timeout = 15.0
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
# Describe node directly with backendNodeId (no need for resolveNode first!)
|
|
276
|
+
describe_result = self.cdp.execute("DOM.describeNode", {"backendNodeId": backend_node_id}, timeout=timeout)
|
|
277
|
+
|
|
278
|
+
if "node" not in describe_result:
|
|
279
|
+
raise RuntimeError(f"Failed to describe node {backend_node_id}")
|
|
280
|
+
|
|
281
|
+
node = describe_result["node"]
|
|
282
|
+
node_id = node["nodeId"]
|
|
283
|
+
|
|
284
|
+
# Get outer HTML
|
|
285
|
+
html_result = self.cdp.execute("DOM.getOuterHTML", {"nodeId": node_id}, timeout=timeout)
|
|
286
|
+
outer_html = html_result.get("outerHTML", "")
|
|
287
|
+
|
|
288
|
+
# Get computed styles
|
|
289
|
+
styles_result = self.cdp.execute("CSS.getComputedStyleForNode", {"nodeId": node_id}, timeout=timeout)
|
|
290
|
+
|
|
291
|
+
# Convert styles to dict
|
|
292
|
+
styles = {}
|
|
293
|
+
for prop in styles_result.get("computedStyle", []):
|
|
294
|
+
styles[prop["name"]] = prop["value"]
|
|
295
|
+
|
|
296
|
+
# Get box model for badge positioning
|
|
297
|
+
try:
|
|
298
|
+
box_result = self.cdp.execute("DOM.getBoxModel", {"nodeId": node_id}, timeout=timeout)
|
|
299
|
+
# Use top-left corner of content box
|
|
300
|
+
content_box = box_result["model"]["content"]
|
|
301
|
+
badge_x = int(content_box[0]) # Top-left x
|
|
302
|
+
badge_y = int(content_box[1]) # Top-left y
|
|
303
|
+
except Exception:
|
|
304
|
+
# Fallback if element has no box model (display: none, etc.)
|
|
305
|
+
badge_x = 0
|
|
306
|
+
badge_y = 0
|
|
307
|
+
|
|
308
|
+
except TimeoutError as e:
|
|
309
|
+
logger.warning(f"Timeout extracting node {backend_node_id}: {e}")
|
|
310
|
+
raise RuntimeError("Element selection timed out - page may be busy or unresponsive") from e
|
|
311
|
+
|
|
312
|
+
# Generate CSS selector
|
|
313
|
+
css_selector = self._generate_css_selector(node)
|
|
314
|
+
|
|
315
|
+
# Generate XPath
|
|
316
|
+
xpath = self._generate_xpath(node)
|
|
317
|
+
|
|
318
|
+
# Generate jsPath (for js() command integration)
|
|
319
|
+
js_path = f"document.querySelector('{css_selector}')"
|
|
320
|
+
|
|
321
|
+
# Build preview
|
|
322
|
+
tag = node.get("nodeName", "").lower()
|
|
323
|
+
node_attrs = node.get("attributes", [])
|
|
324
|
+
attrs_dict = {}
|
|
325
|
+
for i in range(0, len(node_attrs), 2):
|
|
326
|
+
if i + 1 < len(node_attrs):
|
|
327
|
+
attrs_dict[node_attrs[i]] = node_attrs[i + 1]
|
|
328
|
+
|
|
329
|
+
preview = {
|
|
330
|
+
"tag": tag,
|
|
331
|
+
"id": attrs_dict.get("id", ""),
|
|
332
|
+
"classes": attrs_dict.get("class", "").split() if attrs_dict.get("class") else [],
|
|
333
|
+
"text": self._get_node_text(outer_html)[:100], # First 100 chars
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
# Build complete data structure (compatible with existing schema)
|
|
337
|
+
return {
|
|
338
|
+
"outerHTML": outer_html,
|
|
339
|
+
"selector": css_selector,
|
|
340
|
+
"jsPath": js_path,
|
|
341
|
+
"styles": styles,
|
|
342
|
+
"xpath": xpath,
|
|
343
|
+
"fullXpath": xpath, # CDP doesn't distinguish, use same
|
|
344
|
+
"preview": preview,
|
|
345
|
+
"badge": {"x": badge_x, "y": badge_y},
|
|
346
|
+
"nodeId": node_id,
|
|
347
|
+
"backendNodeId": backend_node_id,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
def _generate_css_selector(self, node: dict) -> str:
|
|
351
|
+
"""Generate unique CSS selector for node.
|
|
352
|
+
|
|
353
|
+
Uses a combination of strategies to ensure uniqueness:
|
|
354
|
+
1. ID if available (most unique)
|
|
355
|
+
2. Tag + classes + nth-child for specificity
|
|
356
|
+
3. Falls back to full path if needed
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
node: CDP node description
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
CSS selector string
|
|
363
|
+
"""
|
|
364
|
+
# Parse attributes
|
|
365
|
+
attrs_dict = self._parse_node_attributes(node)
|
|
366
|
+
|
|
367
|
+
# Strategy 1: ID selector (unique by definition)
|
|
368
|
+
if "id" in attrs_dict and attrs_dict["id"]:
|
|
369
|
+
return f"#{attrs_dict['id']}"
|
|
370
|
+
|
|
371
|
+
# Strategy 2: Build selector with tag + classes + nth-child
|
|
372
|
+
tag = node.get("nodeName", "").lower()
|
|
373
|
+
selector = tag
|
|
374
|
+
|
|
375
|
+
# Add first 2 classes for specificity without being too brittle
|
|
376
|
+
if "class" in attrs_dict and attrs_dict["class"]:
|
|
377
|
+
classes = attrs_dict["class"].split()[:2]
|
|
378
|
+
if classes:
|
|
379
|
+
selector += "." + ".".join(classes)
|
|
380
|
+
|
|
381
|
+
# Add nth-child for uniqueness within parent
|
|
382
|
+
# This is key to distinguishing elements with same tag/class
|
|
383
|
+
parent_id = node.get("parentId")
|
|
384
|
+
if parent_id and self.cdp:
|
|
385
|
+
try:
|
|
386
|
+
# Get parent node to count children
|
|
387
|
+
parent_result = self.cdp.execute("DOM.describeNode", {"nodeId": parent_id}, timeout=5.0)
|
|
388
|
+
|
|
389
|
+
if "node" in parent_result:
|
|
390
|
+
parent_node = parent_result["node"]
|
|
391
|
+
child_node_ids = parent_node.get("childNodeIds", [])
|
|
392
|
+
|
|
393
|
+
# Find our position among siblings
|
|
394
|
+
node_id = node.get("nodeId")
|
|
395
|
+
if node_id in child_node_ids:
|
|
396
|
+
nth = child_node_ids.index(node_id) + 1
|
|
397
|
+
selector += f":nth-child({nth})"
|
|
398
|
+
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.debug(f"Could not add nth-child to selector: {e}")
|
|
401
|
+
|
|
402
|
+
return selector
|
|
403
|
+
|
|
404
|
+
def _parse_node_attributes(self, node: dict) -> dict:
|
|
405
|
+
"""Parse CDP node attributes array into dictionary.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
node: CDP node with attributes array [name1, value1, name2, value2, ...]
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Dictionary of {name: value}
|
|
412
|
+
"""
|
|
413
|
+
attrs = node.get("attributes", [])
|
|
414
|
+
attrs_dict = {}
|
|
415
|
+
for i in range(0, len(attrs), 2):
|
|
416
|
+
if i + 1 < len(attrs):
|
|
417
|
+
attrs_dict[attrs[i]] = attrs[i + 1]
|
|
418
|
+
return attrs_dict
|
|
419
|
+
|
|
420
|
+
def _generate_xpath(self, node: dict) -> str:
|
|
421
|
+
"""Generate XPath for node.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
node: CDP node description
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
XPath string
|
|
428
|
+
"""
|
|
429
|
+
tag = node.get("nodeName", "").lower()
|
|
430
|
+
attrs_dict = self._parse_node_attributes(node)
|
|
431
|
+
|
|
432
|
+
# Prefer ID (unique)
|
|
433
|
+
if "id" in attrs_dict and attrs_dict["id"]:
|
|
434
|
+
return f"//{tag}[@id='{attrs_dict['id']}']"
|
|
435
|
+
|
|
436
|
+
# Use class attribute if available
|
|
437
|
+
if "class" in attrs_dict and attrs_dict["class"]:
|
|
438
|
+
# XPath class matching (contains all classes)
|
|
439
|
+
classes = attrs_dict["class"].split()
|
|
440
|
+
if classes:
|
|
441
|
+
return f"//{tag}[@class='{attrs_dict['class']}']"
|
|
442
|
+
|
|
443
|
+
# Fallback to tag only
|
|
444
|
+
return f"//{tag}"
|
|
445
|
+
|
|
446
|
+
def _get_node_text(self, html: str) -> str:
|
|
447
|
+
"""Extract text content from HTML (simple implementation).
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
html: Outer HTML string
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Extracted text content
|
|
454
|
+
"""
|
|
455
|
+
# Simple regex to strip tags
|
|
456
|
+
text = re.sub(r"<[^>]+>", "", html)
|
|
457
|
+
return text.strip()
|
|
458
|
+
|
|
459
|
+
def get_state(self) -> dict[str, Any]:
|
|
460
|
+
"""Get current DOM service state (thread-safe).
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
State dictionary with inspect_active, selections, and pending count
|
|
464
|
+
"""
|
|
465
|
+
# Thread-safe read: protect against concurrent writes from WebSocket thread
|
|
466
|
+
with self._state_lock:
|
|
467
|
+
selections = {}
|
|
468
|
+
prompt = ""
|
|
469
|
+
|
|
470
|
+
if self.state is not None and self.state.browser_data:
|
|
471
|
+
# Deep copy to prevent mutations during SSE broadcast
|
|
472
|
+
selections = dict(self.state.browser_data.get("selections", {}))
|
|
473
|
+
prompt = self.state.browser_data.get("prompt", "")
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
"inspect_active": self._inspection_active,
|
|
477
|
+
"selections": selections,
|
|
478
|
+
"prompt": prompt,
|
|
479
|
+
"pending_count": self._pending_selections, # For progress indicator
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
def clear_selections(self) -> None:
|
|
483
|
+
"""Clear all selections (thread-safe)."""
|
|
484
|
+
with self._state_lock:
|
|
485
|
+
if self.state is not None and self.state.browser_data:
|
|
486
|
+
self.state.browser_data["selections"] = {}
|
|
487
|
+
self._next_id = 1
|
|
488
|
+
logger.info("Selections cleared")
|
|
489
|
+
|
|
490
|
+
def cleanup(self) -> None:
|
|
491
|
+
"""Cleanup resources (executor, callbacks).
|
|
492
|
+
|
|
493
|
+
Call this before disconnect or app exit.
|
|
494
|
+
"""
|
|
495
|
+
# Shutdown executor - wait=False to avoid blocking on stuck tasks
|
|
496
|
+
# cancel_futures=True prevents hanging on incomplete selections (Python 3.9+)
|
|
497
|
+
if hasattr(self, "_executor"):
|
|
498
|
+
try:
|
|
499
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
500
|
+
logger.info("ThreadPoolExecutor shut down")
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.debug(f"Executor shutdown error (non-fatal): {e}")
|
|
503
|
+
|
|
504
|
+
# Clear inspection state
|
|
505
|
+
if self._inspection_active:
|
|
506
|
+
try:
|
|
507
|
+
self.stop_inspect()
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.debug(f"Failed to stop inspect on cleanup: {e}")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
__all__ = ["DOMService"]
|