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