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.
Files changed (64) hide show
  1. webtap/VISION.md +246 -0
  2. webtap/__init__.py +84 -0
  3. webtap/__main__.py +6 -0
  4. webtap/api/__init__.py +9 -0
  5. webtap/api/app.py +26 -0
  6. webtap/api/models.py +69 -0
  7. webtap/api/server.py +111 -0
  8. webtap/api/sse.py +182 -0
  9. webtap/api/state.py +89 -0
  10. webtap/app.py +79 -0
  11. webtap/cdp/README.md +275 -0
  12. webtap/cdp/__init__.py +12 -0
  13. webtap/cdp/har.py +302 -0
  14. webtap/cdp/schema/README.md +41 -0
  15. webtap/cdp/schema/cdp_protocol.json +32785 -0
  16. webtap/cdp/schema/cdp_version.json +8 -0
  17. webtap/cdp/session.py +667 -0
  18. webtap/client.py +81 -0
  19. webtap/commands/DEVELOPER_GUIDE.md +401 -0
  20. webtap/commands/TIPS.md +269 -0
  21. webtap/commands/__init__.py +29 -0
  22. webtap/commands/_builders.py +331 -0
  23. webtap/commands/_code_generation.py +110 -0
  24. webtap/commands/_tips.py +147 -0
  25. webtap/commands/_utils.py +273 -0
  26. webtap/commands/connection.py +220 -0
  27. webtap/commands/console.py +87 -0
  28. webtap/commands/fetch.py +310 -0
  29. webtap/commands/filters.py +116 -0
  30. webtap/commands/javascript.py +73 -0
  31. webtap/commands/js_export.py +73 -0
  32. webtap/commands/launch.py +72 -0
  33. webtap/commands/navigation.py +197 -0
  34. webtap/commands/network.py +136 -0
  35. webtap/commands/quicktype.py +306 -0
  36. webtap/commands/request.py +93 -0
  37. webtap/commands/selections.py +138 -0
  38. webtap/commands/setup.py +219 -0
  39. webtap/commands/to_model.py +163 -0
  40. webtap/daemon.py +185 -0
  41. webtap/daemon_state.py +53 -0
  42. webtap/filters.py +219 -0
  43. webtap/rpc/__init__.py +14 -0
  44. webtap/rpc/errors.py +49 -0
  45. webtap/rpc/framework.py +223 -0
  46. webtap/rpc/handlers.py +625 -0
  47. webtap/rpc/machine.py +84 -0
  48. webtap/services/README.md +83 -0
  49. webtap/services/__init__.py +15 -0
  50. webtap/services/console.py +124 -0
  51. webtap/services/dom.py +547 -0
  52. webtap/services/fetch.py +415 -0
  53. webtap/services/main.py +392 -0
  54. webtap/services/network.py +401 -0
  55. webtap/services/setup/__init__.py +185 -0
  56. webtap/services/setup/chrome.py +233 -0
  57. webtap/services/setup/desktop.py +255 -0
  58. webtap/services/setup/extension.py +147 -0
  59. webtap/services/setup/platform.py +162 -0
  60. webtap/services/state_snapshot.py +86 -0
  61. webtap_tool-0.11.0.dist-info/METADATA +535 -0
  62. webtap_tool-0.11.0.dist-info/RECORD +64 -0
  63. webtap_tool-0.11.0.dist-info/WHEEL +4 -0
  64. 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"]