webtap-tool 0.1.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/VISION.md +234 -0
- webtap/__init__.py +56 -0
- webtap/api.py +222 -0
- webtap/app.py +76 -0
- webtap/cdp/README.md +268 -0
- webtap/cdp/__init__.py +14 -0
- webtap/cdp/query.py +107 -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 +365 -0
- webtap/commands/DEVELOPER_GUIDE.md +314 -0
- webtap/commands/TIPS.md +153 -0
- webtap/commands/__init__.py +7 -0
- webtap/commands/_builders.py +127 -0
- webtap/commands/_errors.py +108 -0
- webtap/commands/_tips.py +147 -0
- webtap/commands/_utils.py +227 -0
- webtap/commands/body.py +161 -0
- webtap/commands/connection.py +168 -0
- webtap/commands/console.py +69 -0
- webtap/commands/events.py +109 -0
- webtap/commands/fetch.py +219 -0
- webtap/commands/filters.py +224 -0
- webtap/commands/inspect.py +146 -0
- webtap/commands/javascript.py +87 -0
- webtap/commands/launch.py +86 -0
- webtap/commands/navigation.py +199 -0
- webtap/commands/network.py +85 -0
- webtap/commands/setup.py +127 -0
- webtap/filters.py +289 -0
- webtap/services/README.md +83 -0
- webtap/services/__init__.py +15 -0
- webtap/services/body.py +113 -0
- webtap/services/console.py +116 -0
- webtap/services/fetch.py +397 -0
- webtap/services/main.py +175 -0
- webtap/services/network.py +105 -0
- webtap/services/setup.py +219 -0
- webtap_tool-0.1.1.dist-info/METADATA +427 -0
- webtap_tool-0.1.1.dist-info/RECORD +43 -0
- webtap_tool-0.1.1.dist-info/WHEEL +4 -0
- webtap_tool-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Browser": "Chrome/139.0.7258.138",
|
|
3
|
+
"Protocol-Version": "1.3",
|
|
4
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
|
|
5
|
+
"V8-Version": "13.9.205.20",
|
|
6
|
+
"WebKit-Version": "537.36 (@884e54ea8d42947ed636779015c5b4815e069838)",
|
|
7
|
+
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/e2c22d46-fafc-483e-a512-caccea649b20"
|
|
8
|
+
}
|
webtap/cdp/session.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""CDP Session with native event storage.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- CDPSession: WebSocket-based CDP client with DuckDB event storage
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from concurrent.futures import Future, TimeoutError
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import duckdb
|
|
14
|
+
import requests
|
|
15
|
+
import websocket
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CDPSession:
|
|
21
|
+
"""WebSocket-based CDP client with native event storage.
|
|
22
|
+
|
|
23
|
+
Stores CDP events as-is in DuckDB for minimal overhead and maximum flexibility.
|
|
24
|
+
Provides field discovery and query capabilities for dynamic data exploration.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
port: Chrome debugging port.
|
|
28
|
+
timeout: Default timeout for execute() calls.
|
|
29
|
+
db: DuckDB connection for event storage.
|
|
30
|
+
field_paths: Live field lookup for query building.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, port: int = 9222, timeout: float = 30):
|
|
34
|
+
"""Initialize CDP session with WebSocket and DuckDB storage.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
port: Chrome debugging port. Defaults to 9222.
|
|
38
|
+
timeout: Default timeout for execute() calls. Defaults to 30.
|
|
39
|
+
"""
|
|
40
|
+
self.port = port
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
|
|
43
|
+
# WebSocketApp instance
|
|
44
|
+
self.ws_app: websocket.WebSocketApp | None = None
|
|
45
|
+
self.ws_thread: threading.Thread | None = None
|
|
46
|
+
|
|
47
|
+
# Connection state
|
|
48
|
+
self.connected = threading.Event()
|
|
49
|
+
self.page_info: dict | None = None
|
|
50
|
+
|
|
51
|
+
# CDP request/response tracking
|
|
52
|
+
self._next_id = 1
|
|
53
|
+
self._pending: dict[int, Future] = {}
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
# DuckDB storage - store events AS-IS
|
|
57
|
+
self.db = duckdb.connect(":memory:")
|
|
58
|
+
|
|
59
|
+
self.db.execute("CREATE TABLE events (event JSON)")
|
|
60
|
+
|
|
61
|
+
# Live field path lookup for fast discovery
|
|
62
|
+
# Maps lowercase field names to their full paths with original case
|
|
63
|
+
self.field_paths: dict[str, set[str]] = {}
|
|
64
|
+
|
|
65
|
+
def list_pages(self) -> list[dict]:
|
|
66
|
+
"""List available Chrome pages via HTTP API.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of page dictionaries with webSocketDebuggerUrl.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
resp = requests.get(f"http://localhost:{self.port}/json", timeout=2)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
pages = resp.json()
|
|
75
|
+
return [p for p in pages if p.get("type") == "page" and "webSocketDebuggerUrl" in p]
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error(f"Failed to list pages: {e}")
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def connect(self, page_index: int | None = None, page_id: str | None = None) -> None:
|
|
81
|
+
"""Connect to Chrome page via WebSocket.
|
|
82
|
+
|
|
83
|
+
Establishes WebSocket connection and starts event collection.
|
|
84
|
+
Does not auto-enable CDP domains - use execute() for that.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
page_index: Index of page to connect to. Defaults to 0.
|
|
88
|
+
page_id: Stable page ID across tab reordering.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
RuntimeError: If already connected or no pages available.
|
|
92
|
+
ValueError: If page_id not found.
|
|
93
|
+
IndexError: If page_index out of range.
|
|
94
|
+
TimeoutError: If connection fails within 5 seconds.
|
|
95
|
+
"""
|
|
96
|
+
if self.ws_app:
|
|
97
|
+
raise RuntimeError("Already connected")
|
|
98
|
+
|
|
99
|
+
pages = self.list_pages()
|
|
100
|
+
if not pages:
|
|
101
|
+
raise RuntimeError("No pages available")
|
|
102
|
+
|
|
103
|
+
# Find the page by ID or index
|
|
104
|
+
if page_id:
|
|
105
|
+
page = next((p for p in pages if p.get("id") == page_id), None)
|
|
106
|
+
if not page:
|
|
107
|
+
raise ValueError(f"Page with ID {page_id} not found")
|
|
108
|
+
elif page_index is not None:
|
|
109
|
+
if page_index >= len(pages):
|
|
110
|
+
raise IndexError(f"Page {page_index} out of range")
|
|
111
|
+
page = pages[page_index]
|
|
112
|
+
else:
|
|
113
|
+
# Default to first page
|
|
114
|
+
page = pages[0]
|
|
115
|
+
|
|
116
|
+
ws_url = page["webSocketDebuggerUrl"]
|
|
117
|
+
self.page_info = page
|
|
118
|
+
|
|
119
|
+
# Create WebSocketApp with callbacks
|
|
120
|
+
self.ws_app = websocket.WebSocketApp(
|
|
121
|
+
ws_url, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, on_close=self._on_close
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Let WebSocketApp handle everything in a thread
|
|
125
|
+
self.ws_thread = threading.Thread(
|
|
126
|
+
target=self.ws_app.run_forever,
|
|
127
|
+
kwargs={
|
|
128
|
+
"ping_interval": 30, # Ping every 30s
|
|
129
|
+
"ping_timeout": 10, # Wait 10s for pong
|
|
130
|
+
"reconnect": 5, # Auto-reconnect with max 5s delay
|
|
131
|
+
"skip_utf8_validation": True, # Faster
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
self.ws_thread.daemon = True
|
|
135
|
+
self.ws_thread.start()
|
|
136
|
+
|
|
137
|
+
# Wait for connection
|
|
138
|
+
if not self.connected.wait(timeout=5):
|
|
139
|
+
self.disconnect()
|
|
140
|
+
raise TimeoutError("Failed to connect to Chrome")
|
|
141
|
+
|
|
142
|
+
def disconnect(self) -> None:
|
|
143
|
+
"""Disconnect WebSocket and clean up resources."""
|
|
144
|
+
if self.ws_app:
|
|
145
|
+
self.ws_app.close()
|
|
146
|
+
self.ws_app = None
|
|
147
|
+
|
|
148
|
+
if self.ws_thread and self.ws_thread.is_alive():
|
|
149
|
+
self.ws_thread.join(timeout=2)
|
|
150
|
+
self.ws_thread = None
|
|
151
|
+
|
|
152
|
+
self.connected.clear()
|
|
153
|
+
self.page_info = None
|
|
154
|
+
|
|
155
|
+
def send(self, method: str, params: dict | None = None) -> Future:
|
|
156
|
+
"""Send CDP command asynchronously.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
method: CDP method like "Page.navigate" or "Network.enable".
|
|
160
|
+
params: Optional command parameters.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Future containing CDP response 'result' field.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
RuntimeError: If not connected to Chrome.
|
|
167
|
+
"""
|
|
168
|
+
if not self.ws_app:
|
|
169
|
+
raise RuntimeError("Not connected")
|
|
170
|
+
|
|
171
|
+
with self._lock:
|
|
172
|
+
msg_id = self._next_id
|
|
173
|
+
self._next_id += 1
|
|
174
|
+
|
|
175
|
+
future = Future()
|
|
176
|
+
self._pending[msg_id] = future
|
|
177
|
+
|
|
178
|
+
# Send CDP command
|
|
179
|
+
message = {"id": msg_id, "method": method}
|
|
180
|
+
if params:
|
|
181
|
+
message["params"] = params
|
|
182
|
+
|
|
183
|
+
self.ws_app.send(json.dumps(message))
|
|
184
|
+
|
|
185
|
+
return future
|
|
186
|
+
|
|
187
|
+
def execute(self, method: str, params: dict | None = None, timeout: float | None = None) -> Any:
|
|
188
|
+
"""Send CDP command synchronously.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
method: CDP method like "Page.navigate" or "Network.enable".
|
|
192
|
+
params: Optional command parameters.
|
|
193
|
+
timeout: Override default timeout.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
CDP response 'result' field.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
TimeoutError: If command times out.
|
|
200
|
+
RuntimeError: If CDP returns error or not connected.
|
|
201
|
+
"""
|
|
202
|
+
future = self.send(method, params)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
return future.result(timeout=timeout or self.timeout)
|
|
206
|
+
except TimeoutError:
|
|
207
|
+
# Clean up the pending future
|
|
208
|
+
with self._lock:
|
|
209
|
+
for msg_id, f in list(self._pending.items()):
|
|
210
|
+
if f is future:
|
|
211
|
+
self._pending.pop(msg_id, None)
|
|
212
|
+
break
|
|
213
|
+
raise TimeoutError(f"Command {method} timed out")
|
|
214
|
+
|
|
215
|
+
def _on_open(self, ws):
|
|
216
|
+
"""WebSocket connection established."""
|
|
217
|
+
logger.info("WebSocket connected")
|
|
218
|
+
self.connected.set()
|
|
219
|
+
|
|
220
|
+
def _on_message(self, ws, message):
|
|
221
|
+
"""Handle CDP messages - store events as-is, resolve command futures."""
|
|
222
|
+
try:
|
|
223
|
+
data = json.loads(message)
|
|
224
|
+
|
|
225
|
+
# Command response - resolve future
|
|
226
|
+
if "id" in data:
|
|
227
|
+
msg_id = data["id"]
|
|
228
|
+
with self._lock:
|
|
229
|
+
future = self._pending.pop(msg_id, None)
|
|
230
|
+
|
|
231
|
+
if future:
|
|
232
|
+
if "error" in data:
|
|
233
|
+
future.set_exception(RuntimeError(data["error"]))
|
|
234
|
+
else:
|
|
235
|
+
future.set_result(data.get("result", {}))
|
|
236
|
+
|
|
237
|
+
# CDP event - store AS-IS in DuckDB and update field lookup
|
|
238
|
+
elif "method" in data:
|
|
239
|
+
self.db.execute("INSERT INTO events VALUES (?)", [json.dumps(data)])
|
|
240
|
+
self._update_field_lookup(data)
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error(f"Error handling message: {e}")
|
|
244
|
+
|
|
245
|
+
def _on_error(self, ws, error):
|
|
246
|
+
"""Handle WebSocket errors."""
|
|
247
|
+
logger.error(f"WebSocket error: {error}")
|
|
248
|
+
|
|
249
|
+
def _on_close(self, ws, code, reason):
|
|
250
|
+
"""Handle WebSocket closure and cleanup."""
|
|
251
|
+
logger.info(f"WebSocket closed: {code} {reason}")
|
|
252
|
+
self.connected.clear()
|
|
253
|
+
|
|
254
|
+
# Fail pending commands
|
|
255
|
+
with self._lock:
|
|
256
|
+
for future in self._pending.values():
|
|
257
|
+
future.set_exception(RuntimeError("Connection closed"))
|
|
258
|
+
self._pending.clear()
|
|
259
|
+
|
|
260
|
+
def _extract_paths(self, obj, parent_key=""):
|
|
261
|
+
"""Extract all JSON paths from nested dict structure.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
obj: Dictionary to extract paths from.
|
|
265
|
+
parent_key: Current path prefix.
|
|
266
|
+
"""
|
|
267
|
+
paths = []
|
|
268
|
+
if isinstance(obj, dict):
|
|
269
|
+
for k, v in obj.items():
|
|
270
|
+
new_key = f"{parent_key}.{k}" if parent_key else k
|
|
271
|
+
paths.append(new_key)
|
|
272
|
+
if isinstance(v, dict):
|
|
273
|
+
paths.extend(self._extract_paths(v, new_key))
|
|
274
|
+
return paths
|
|
275
|
+
|
|
276
|
+
def _update_field_lookup(self, data):
|
|
277
|
+
"""Update field_paths lookup with new event data.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
data: CDP event dictionary.
|
|
281
|
+
"""
|
|
282
|
+
event_type = data.get("method", "unknown")
|
|
283
|
+
paths = self._extract_paths(data)
|
|
284
|
+
|
|
285
|
+
for path in paths:
|
|
286
|
+
# Store with event type prefix using colon separator
|
|
287
|
+
full_path = f"{event_type}:{path}"
|
|
288
|
+
|
|
289
|
+
# Index by each part of the path for flexible searching
|
|
290
|
+
parts = path.split(".")
|
|
291
|
+
for part in parts:
|
|
292
|
+
key = part.lower()
|
|
293
|
+
if key not in self.field_paths:
|
|
294
|
+
self.field_paths[key] = set()
|
|
295
|
+
self.field_paths[key].add(full_path) # Store with event type and original case
|
|
296
|
+
|
|
297
|
+
def discover_field_paths(self, search_key: str) -> list[str]:
|
|
298
|
+
"""Discover all JSON paths containing the search key.
|
|
299
|
+
|
|
300
|
+
Used by build_query for dynamic field discovery.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
search_key: Field name to search for like "url" or "status".
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Sorted list of full paths with event type prefixes.
|
|
307
|
+
"""
|
|
308
|
+
search_key = search_key.lower()
|
|
309
|
+
paths = set()
|
|
310
|
+
|
|
311
|
+
# Find all field names that contain our search key
|
|
312
|
+
for field_name, field_paths in self.field_paths.items():
|
|
313
|
+
if search_key in field_name:
|
|
314
|
+
paths.update(field_paths)
|
|
315
|
+
|
|
316
|
+
return sorted(list(paths)) # Sort for consistent results
|
|
317
|
+
|
|
318
|
+
def clear_events(self) -> None:
|
|
319
|
+
"""Clear all stored events and reset field lookup."""
|
|
320
|
+
self.db.execute("DELETE FROM events")
|
|
321
|
+
self.field_paths.clear()
|
|
322
|
+
|
|
323
|
+
def query(self, sql: str, params: list | None = None) -> list:
|
|
324
|
+
"""Query stored CDP events using DuckDB SQL.
|
|
325
|
+
|
|
326
|
+
Events are stored in 'events' table with single JSON 'event' column.
|
|
327
|
+
Use json_extract_string() for accessing nested fields.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
sql: DuckDB SQL query string.
|
|
331
|
+
params: Optional query parameters.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
List of result rows.
|
|
335
|
+
|
|
336
|
+
Examples:
|
|
337
|
+
query("SELECT * FROM events WHERE json_extract_string(event, '$.method') = 'Network.responseReceived'")
|
|
338
|
+
query("SELECT json_extract_string(event, '$.params.request.url') as url FROM events")
|
|
339
|
+
"""
|
|
340
|
+
result = self.db.execute(sql, params or [])
|
|
341
|
+
return result.fetchall() if result else []
|
|
342
|
+
|
|
343
|
+
def fetch_body(self, request_id: str) -> dict | None:
|
|
344
|
+
"""Fetch response body via Network.getResponseBody CDP call.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
request_id: Network request ID from CDP events.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Dict with 'body' and 'base64Encoded' keys, or None if failed.
|
|
351
|
+
"""
|
|
352
|
+
try:
|
|
353
|
+
return self.execute("Network.getResponseBody", {"requestId": request_id})
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.debug(f"Failed to fetch body for {request_id}: {e}")
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def is_connected(self) -> bool:
|
|
360
|
+
"""Check if WebSocket connection is active.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
True if connected to Chrome page.
|
|
364
|
+
"""
|
|
365
|
+
return self.connected.is_set()
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# WebTap Commands Developer Guide
|
|
2
|
+
|
|
3
|
+
This guide documents the patterns and conventions for developing WebTap commands with MCP compatibility.
|
|
4
|
+
|
|
5
|
+
## Command Patterns (Post-Refinement)
|
|
6
|
+
|
|
7
|
+
### 1. Simple Commands (No Parameters)
|
|
8
|
+
```python
|
|
9
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
10
|
+
def disconnect(state) -> dict:
|
|
11
|
+
"""Disconnect from Chrome."""
|
|
12
|
+
# Implementation
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### 2. Single Required Parameter
|
|
16
|
+
```python
|
|
17
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
18
|
+
def navigate(state, url: str) -> dict:
|
|
19
|
+
"""Navigate to URL."""
|
|
20
|
+
# Implementation
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 3. Optional Boolean/Simple Parameters (Direct)
|
|
24
|
+
```python
|
|
25
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
26
|
+
def reload(state, ignore_cache: bool = False) -> dict:
|
|
27
|
+
"""Reload current page."""
|
|
28
|
+
# Implementation
|
|
29
|
+
|
|
30
|
+
# Multiple boolean flags
|
|
31
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
32
|
+
def clear(state, events: bool = True, console: bool = False, cache: bool = False) -> dict:
|
|
33
|
+
"""Clear various data stores."""
|
|
34
|
+
# Implementation
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 4. Mutually Exclusive Parameters (Direct)
|
|
38
|
+
Use direct parameters when you have different ways to identify the same thing:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
42
|
+
def connect(state, page: int = None, page_id: str = None) -> dict:
|
|
43
|
+
"""Connect to Chrome page.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
page: Connect by page index (0-based)
|
|
47
|
+
page_id: Connect by page ID
|
|
48
|
+
|
|
49
|
+
Note: Cannot specify both page and page_id.
|
|
50
|
+
"""
|
|
51
|
+
if page is not None and page_id is not None:
|
|
52
|
+
return error_response("invalid_parameters",
|
|
53
|
+
"Cannot specify both 'page' and 'page_id'")
|
|
54
|
+
# Implementation
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 5. Multiple Optional Parameters (Direct)
|
|
58
|
+
Use direct parameters for cleaner API when parameters are well-defined:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
62
|
+
def network(state, limit: int = 20, filters: list = None, no_filters: bool = False) -> dict:
|
|
63
|
+
"""Show network requests.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
limit: Maximum results to show
|
|
67
|
+
filters: Specific filter categories to apply
|
|
68
|
+
no_filters: Show everything unfiltered
|
|
69
|
+
"""
|
|
70
|
+
# Implementation
|
|
71
|
+
|
|
72
|
+
# With expression evaluation
|
|
73
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
74
|
+
def body(state, response: int, expr: str = None, decode: bool = True, cache: bool = True) -> dict:
|
|
75
|
+
"""Get response body for network request."""
|
|
76
|
+
# Implementation
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 6. Mixed Parameters (Direct + Dict)
|
|
80
|
+
Use dict only for complex/variable configurations:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
84
|
+
def resume(state, request: int, wait: float = 0.5, modifications: dict = None) -> dict:
|
|
85
|
+
"""Resume a paused request.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
request: Request row ID
|
|
89
|
+
wait: Wait time for next event
|
|
90
|
+
modifications: Request/response modifications
|
|
91
|
+
- {"url": "..."} - Change URL
|
|
92
|
+
- {"method": "POST"} - Change method
|
|
93
|
+
"""
|
|
94
|
+
mods = modifications or {}
|
|
95
|
+
# Implementation
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 7. Dynamic Field Discovery (Keep Dict)
|
|
99
|
+
Use dict when field names are dynamic/unknown:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
103
|
+
def events(state, filters: dict = None, limit: int = 20) -> dict:
|
|
104
|
+
"""Query CDP events by field values.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
filters: Field filters (any CDP field name)
|
|
108
|
+
- {"method": "Network.*"}
|
|
109
|
+
- {"status": 200}
|
|
110
|
+
- {"url": "*api*"}
|
|
111
|
+
"""
|
|
112
|
+
# Fields are discovered dynamically from CDP events
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 8. Action + Config Pattern (Complex Operations)
|
|
116
|
+
Keep for commands with varied operations:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
120
|
+
def filters(state, action: str = "list", config: dict = None) -> dict:
|
|
121
|
+
"""Manage filters.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
action: Operation to perform
|
|
125
|
+
- "list" - Show all filters
|
|
126
|
+
- "add" - Add filter
|
|
127
|
+
- "remove" - Remove filter
|
|
128
|
+
config: Action-specific configuration
|
|
129
|
+
- For add: {"category": "ads", "patterns": ["*ad*"]}
|
|
130
|
+
- For remove: {"patterns": ["*ad*"]}
|
|
131
|
+
"""
|
|
132
|
+
cfg = config or {}
|
|
133
|
+
|
|
134
|
+
if action == "add":
|
|
135
|
+
category = cfg.get("category", "custom")
|
|
136
|
+
patterns = cfg.get("patterns", [])
|
|
137
|
+
# Implementation
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## MCP Type Requirements
|
|
141
|
+
|
|
142
|
+
### ❌ Avoid These (Not MCP Compatible)
|
|
143
|
+
```python
|
|
144
|
+
# Union types
|
|
145
|
+
def command(state, param: str | None = None)
|
|
146
|
+
|
|
147
|
+
# Optional types
|
|
148
|
+
from typing import Optional
|
|
149
|
+
def command(state, param: Optional[str] = None)
|
|
150
|
+
|
|
151
|
+
# Complex types
|
|
152
|
+
from typing import Dict, List
|
|
153
|
+
def command(state, data: Dict[str, List[str]])
|
|
154
|
+
|
|
155
|
+
# **kwargs
|
|
156
|
+
def command(state, **fields)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### ✅ Use These Instead
|
|
160
|
+
```python
|
|
161
|
+
# Simple defaults
|
|
162
|
+
def command(state, param: str = "default")
|
|
163
|
+
def command(state, param: dict = None) # pyright: ignore[reportArgumentType]
|
|
164
|
+
def command(state, param: list = None) # pyright: ignore[reportArgumentType]
|
|
165
|
+
def command(state, param: bool = False)
|
|
166
|
+
def command(state, param: int = 0)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Response Patterns
|
|
170
|
+
|
|
171
|
+
### Resources (Read-Only Data)
|
|
172
|
+
```python
|
|
173
|
+
@app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "text/markdown"})
|
|
174
|
+
def pages(state) -> dict:
|
|
175
|
+
"""List available pages."""
|
|
176
|
+
return build_table_response(
|
|
177
|
+
title="Chrome Pages",
|
|
178
|
+
headers=["Index", "Title", "URL"],
|
|
179
|
+
rows=rows,
|
|
180
|
+
summary=f"{len(rows)} pages"
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Tools (Actions with Side Effects)
|
|
185
|
+
```python
|
|
186
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
187
|
+
def navigate(state, url: str) -> dict:
|
|
188
|
+
"""Navigate to URL."""
|
|
189
|
+
# Perform action
|
|
190
|
+
return build_info_response(
|
|
191
|
+
title="Navigation Complete",
|
|
192
|
+
fields={"URL": url, "Status": "Success"}
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Error Handling
|
|
197
|
+
|
|
198
|
+
Always use the error utilities from `_errors.py`:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from webtap.commands._errors import check_connection, error_response
|
|
202
|
+
|
|
203
|
+
def my_command(state, ...):
|
|
204
|
+
# Check connection first for commands that need it
|
|
205
|
+
if error := check_connection(state):
|
|
206
|
+
return error
|
|
207
|
+
|
|
208
|
+
# Validate parameters
|
|
209
|
+
if not valid:
|
|
210
|
+
return error_response("invalid_param", "Parameter X must be Y")
|
|
211
|
+
|
|
212
|
+
# Custom errors
|
|
213
|
+
return error_response("custom", custom_message="Specific error message")
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Utility Functions
|
|
217
|
+
|
|
218
|
+
Use helpers from `_utils.py`:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from webtap.commands._utils import (
|
|
222
|
+
build_table_response, # For tables
|
|
223
|
+
build_info_response, # For key-value info
|
|
224
|
+
parse_options, # Parse dict with defaults
|
|
225
|
+
extract_option, # Extract single option
|
|
226
|
+
truncate_string, # Truncate long strings
|
|
227
|
+
format_size, # Format byte sizes
|
|
228
|
+
format_id, # Format IDs
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Text Over Symbols
|
|
233
|
+
|
|
234
|
+
Use explicit text instead of symbols for clarity:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
# Status text
|
|
238
|
+
"Connected" / "Disconnected"
|
|
239
|
+
"Enabled" / "Disabled"
|
|
240
|
+
"Yes" / "No"
|
|
241
|
+
|
|
242
|
+
# For empty values
|
|
243
|
+
"-" or "None" or ""
|
|
244
|
+
|
|
245
|
+
# Descriptive status
|
|
246
|
+
"3 requests paused" instead of symbols
|
|
247
|
+
"Request Failed" instead of error symbols
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Decision Tree for Parameter Patterns (Updated)
|
|
251
|
+
|
|
252
|
+
1. **No parameters?** → Simple command
|
|
253
|
+
2. **One required param?** → Single parameter
|
|
254
|
+
3. **Few well-defined params?** → Direct parameters with defaults
|
|
255
|
+
4. **Multiple ways to identify same thing?** → Direct mutually exclusive params
|
|
256
|
+
5. **Dynamic/unknown field names?** → Dict for filters
|
|
257
|
+
6. **Complex variable config?** → Dict for modifications
|
|
258
|
+
7. **Different operations based on input?** → Action + config pattern
|
|
259
|
+
|
|
260
|
+
### When to Use Direct Parameters
|
|
261
|
+
- Parameters are well-defined and limited (< 5)
|
|
262
|
+
- Parameters are commonly used
|
|
263
|
+
- Makes the API more intuitive
|
|
264
|
+
- Boolean flags or simple types
|
|
265
|
+
|
|
266
|
+
### When to Keep Dict Parameters
|
|
267
|
+
- Field names are dynamic (like CDP event fields)
|
|
268
|
+
- Configuration varies significantly by action
|
|
269
|
+
- Many optional parameters rarely used together
|
|
270
|
+
- Complex nested structures needed
|
|
271
|
+
|
|
272
|
+
## Examples by Category (Current Implementation)
|
|
273
|
+
|
|
274
|
+
### Navigation Commands
|
|
275
|
+
- `navigate(url: str)` - Single required parameter
|
|
276
|
+
- `reload(ignore_cache: bool = False)` - Optional boolean
|
|
277
|
+
- `back()`, `forward()` - No parameters
|
|
278
|
+
|
|
279
|
+
### Query Commands
|
|
280
|
+
- `network(limit: int = 20, filters: list = None, no_filters: bool = False)` - Direct params
|
|
281
|
+
- `events(filters: dict = None, limit: int = 20)` - Dict for dynamic fields + limit
|
|
282
|
+
- `inspect(event: int = None, expr: str = None)` - Direct optional params
|
|
283
|
+
- `body(response: int, expr: str = None, decode: bool = True, cache: bool = True)` - Mixed direct params
|
|
284
|
+
|
|
285
|
+
### Management Commands
|
|
286
|
+
- `connect(page: int = None, page_id: str = None)` - Mutually exclusive direct params
|
|
287
|
+
- `clear(events: bool = True, console: bool = False, cache: bool = False)` - Boolean flags
|
|
288
|
+
- `filters(action: str = "list", config: dict = None)` - Action + config pattern
|
|
289
|
+
|
|
290
|
+
### JavaScript & Fetch Commands
|
|
291
|
+
- `js(code: str, wait_return: bool = True, await_promise: bool = False)` - Direct params
|
|
292
|
+
- `fetch(action: str, options: dict = None)` - Action pattern
|
|
293
|
+
- `resume(request: int, wait: float = 0.5, modifications: dict = None)` - Direct + dict
|
|
294
|
+
|
|
295
|
+
## Testing Your Command
|
|
296
|
+
|
|
297
|
+
1. **Type checking**: Run `basedpyright` to ensure types are correct
|
|
298
|
+
2. **Linting**: Run `ruff check` for code style
|
|
299
|
+
3. **REPL mode**: Test with `webtap` command
|
|
300
|
+
4. **MCP mode**: Test with `webtap --mcp` command
|
|
301
|
+
5. **Markdown rendering**: Verify output displays correctly
|
|
302
|
+
|
|
303
|
+
## Checklist for New Commands
|
|
304
|
+
|
|
305
|
+
- [ ] Use `@app.command()` decorator with `display="markdown"`
|
|
306
|
+
- [ ] Add `fastmcp` metadata (type: "resource" or "tool")
|
|
307
|
+
- [ ] Use simple types only (no unions, no Optional)
|
|
308
|
+
- [ ] Add `# pyright: ignore[reportArgumentType]` for `dict = None`
|
|
309
|
+
- [ ] Import utilities from `_utils.py` and `_errors.py`
|
|
310
|
+
- [ ] Use `build_table_response()` or `build_info_response()`
|
|
311
|
+
- [ ] Check connection with `check_connection()` if needed
|
|
312
|
+
- [ ] Document parameters clearly in docstring
|
|
313
|
+
- [ ] Provide usage examples in docstring
|
|
314
|
+
- [ ] Test in both REPL and MCP modes
|