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.

@@ -0,0 +1,146 @@
1
+ """CDP event data inspection and analysis commands."""
2
+
3
+ import json
4
+
5
+ from webtap.app import app
6
+ from webtap.commands._utils import evaluate_expression, format_expression_result
7
+ from webtap.commands._errors import check_connection
8
+ from webtap.commands._builders import error_response
9
+ from webtap.commands._tips import get_mcp_description
10
+
11
+
12
+ mcp_desc = get_mcp_description("inspect")
13
+
14
+
15
+ @app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
16
+ def inspect(state, event: int = None, expr: str = None) -> dict: # pyright: ignore[reportArgumentType]
17
+ """Inspect CDP event or evaluate expression.
18
+
19
+ Args:
20
+ event: Event row ID to inspect (optional)
21
+ expr: Python expression to evaluate (optional)
22
+
23
+ Returns:
24
+ Evaluation result or full CDP event
25
+ """
26
+ if event is None and expr is None:
27
+ return error_response("Must provide at least one of: event (int) or expr (str)")
28
+
29
+ if error := check_connection(state):
30
+ return error
31
+
32
+ # Handle pure expression evaluation (no event)
33
+ if expr and event is None:
34
+ try:
35
+ # Create namespace with cdp and state
36
+ namespace = {"cdp": state.cdp, "state": state}
37
+
38
+ # Execute and get result + output
39
+ result, output = evaluate_expression(expr, namespace)
40
+ formatted_result = format_expression_result(result, output)
41
+
42
+ # Build markdown response
43
+ return {
44
+ "elements": [
45
+ {"type": "heading", "content": "Expression Evaluation", "level": 2},
46
+ {"type": "text", "content": "**Expression:**"},
47
+ {"type": "code_block", "content": expr, "language": "python"},
48
+ {"type": "text", "content": "**Result:**"},
49
+ {"type": "code_block", "content": formatted_result, "language": ""},
50
+ ]
51
+ }
52
+ except Exception as e:
53
+ return error_response(
54
+ f"{type(e).__name__}: {e}", suggestions=["cdp and state objects are available in namespace"]
55
+ )
56
+
57
+ # Handle event inspection (with optional expression)
58
+ # Fetch event directly from DuckDB
59
+ result = state.cdp.query("SELECT event FROM events WHERE rowid = ?", [event])
60
+
61
+ if not result:
62
+ return error_response(f"Event with rowid {event} not found")
63
+
64
+ # Parse the CDP event
65
+ data = json.loads(result[0][0])
66
+
67
+ # No expression: show the raw data
68
+ if not expr:
69
+ # Pretty print the full CDP event as JSON
70
+ elements = [{"type": "heading", "content": f"Event {event}", "level": 2}]
71
+
72
+ # Add event method if available
73
+ if isinstance(data, dict) and "method" in data:
74
+ elements.append({"type": "text", "content": f"**Method:** `{data['method']}`"})
75
+
76
+ # Add the full data as JSON code block
77
+ # DATA-LEVEL TRUNCATION for memory/performance (similar to body.py)
78
+ MAX_EVENT_SIZE = 2000
79
+ if isinstance(data, dict):
80
+ formatted = json.dumps(data, indent=2)
81
+ if len(formatted) > MAX_EVENT_SIZE:
82
+ elements.append({"type": "code_block", "content": formatted[:MAX_EVENT_SIZE], "language": "json"})
83
+ elements.append(
84
+ {"type": "text", "content": f"_[truncated at {MAX_EVENT_SIZE} chars, {len(formatted)} total]_"}
85
+ )
86
+ else:
87
+ elements.append({"type": "code_block", "content": formatted, "language": "json"})
88
+ else:
89
+ elements.append({"type": "code_block", "content": str(data), "language": ""})
90
+
91
+ return {"elements": elements}
92
+
93
+ # Execute code with data available (Jupyter-style)
94
+ try:
95
+ # Create namespace with data
96
+ namespace = {"data": data}
97
+
98
+ # Execute and get result + output
99
+ result, output = evaluate_expression(expr, namespace)
100
+ formatted_result = format_expression_result(result, output)
101
+
102
+ # Build markdown response
103
+ elements = [{"type": "heading", "content": f"Inspect Event {event}", "level": 2}]
104
+
105
+ # Add event method if available
106
+ if isinstance(data, dict) and "method" in data:
107
+ elements.append({"type": "text", "content": f"**Method:** `{data['method']}`"})
108
+
109
+ elements.extend(
110
+ [
111
+ {"type": "text", "content": "**Expression:**"},
112
+ {"type": "code_block", "content": expr, "language": "python"},
113
+ {"type": "text", "content": "**Result:**"},
114
+ {"type": "code_block", "content": formatted_result, "language": ""},
115
+ ]
116
+ )
117
+
118
+ return {"elements": elements}
119
+
120
+ except Exception as e:
121
+ # Provide helpful suggestions based on the error type
122
+ suggestions = ["The event data is available as 'data' dict"]
123
+
124
+ if "NameError" in str(type(e).__name__):
125
+ suggestions.extend(
126
+ [
127
+ "Common libraries are pre-imported: re, json, bs4, jwt, base64",
128
+ "Example: re.findall(r'pattern', str(data))",
129
+ ]
130
+ )
131
+ elif "KeyError" in str(e):
132
+ suggestions.extend(
133
+ [
134
+ "Key not found. Try: list(data.keys()) to see available keys",
135
+ "CDP events are nested. Try: data.get('params', {}).get('response', {})",
136
+ ]
137
+ )
138
+ elif "TypeError" in str(e):
139
+ suggestions.extend(
140
+ [
141
+ "Check data type: type(data)",
142
+ "For nested access, use: data.get('params', {}).get('field')",
143
+ ]
144
+ )
145
+
146
+ return error_response(f"{type(e).__name__}: {e}", suggestions=suggestions)
@@ -0,0 +1,87 @@
1
+ """JavaScript code execution in browser context."""
2
+
3
+ import json
4
+ from webtap.app import app
5
+ from webtap.commands._errors import check_connection
6
+ from webtap.commands._builders import info_response, error_response
7
+ from webtap.commands._tips import get_mcp_description
8
+
9
+
10
+ mcp_desc = get_mcp_description("js")
11
+
12
+
13
+ @app.command(
14
+ display="markdown",
15
+ truncate={
16
+ "Expression": {"max": 50, "mode": "end"} # Only truncate for display in info response
17
+ },
18
+ fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"},
19
+ )
20
+ def js(state, code: str, wait_return: bool = True, await_promise: bool = False) -> dict:
21
+ """Execute JavaScript in the browser.
22
+
23
+ Args:
24
+ code: JavaScript code to execute
25
+ wait_return: Wait for and return result (default: True)
26
+ await_promise: Await promises before returning (default: False)
27
+
28
+ Examples:
29
+ js("document.title") # Get page title
30
+ js("document.body.innerText.length") # Get text length
31
+ js("console.log('test')", wait_return=False) # Fire and forget
32
+ js("[...document.links].map(a => a.href)") # Get all links
33
+
34
+ # Async operations
35
+ js("fetch('/api').then(r => r.json())", await_promise=True)
36
+
37
+ # DOM manipulation (no return needed)
38
+ js("document.querySelectorAll('.ad').forEach(e => e.remove())", wait_return=False)
39
+
40
+ # Install interceptors
41
+ js("window.fetch = new Proxy(window.fetch, {get: (t, p) => console.log(p)})", wait_return=False)
42
+
43
+ Returns:
44
+ The evaluated result if wait_return=True, otherwise execution status
45
+ """
46
+ if error := check_connection(state):
47
+ return error
48
+
49
+ result = state.cdp.execute(
50
+ "Runtime.evaluate", {"expression": code, "returnByValue": wait_return, "awaitPromise": await_promise}
51
+ )
52
+
53
+ # Check for exceptions
54
+ if result.get("exceptionDetails"):
55
+ exception = result["exceptionDetails"]
56
+ error_text = exception.get("exception", {}).get("description", str(exception))
57
+
58
+ return error_response(f"JavaScript error: {error_text}")
59
+
60
+ # Return based on wait_return flag
61
+ if wait_return:
62
+ value = result.get("result", {}).get("value")
63
+
64
+ # Format the result in markdown
65
+ elements = [
66
+ {"type": "heading", "content": "JavaScript Result", "level": 2},
67
+ {"type": "code_block", "content": code, "language": "javascript"}, # Full code
68
+ ]
69
+
70
+ # Add the result
71
+ if value is not None:
72
+ if isinstance(value, (dict, list)):
73
+ elements.append({"type": "code_block", "content": json.dumps(value, indent=2), "language": "json"})
74
+ else:
75
+ elements.append({"type": "text", "content": f"**Result:** `{value}`"})
76
+ else:
77
+ elements.append({"type": "text", "content": "**Result:** _(no return value)_"})
78
+
79
+ return {"elements": elements}
80
+ else:
81
+ return info_response(
82
+ title="JavaScript Execution",
83
+ fields={
84
+ "Status": "Executed",
85
+ "Expression": code, # Full expression, truncation in decorator
86
+ },
87
+ )
@@ -0,0 +1,86 @@
1
+ """Chrome launch commands for WebTap."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from webtap.app import app
8
+ from webtap.commands._builders import success_response, error_response
9
+
10
+
11
+ @app.command(
12
+ display="markdown",
13
+ typer={"name": "run-chrome", "help": "Launch Chrome with debugging enabled"},
14
+ fastmcp={"enabled": False},
15
+ )
16
+ def run_chrome(state, detach: bool = True, port: int = 9222) -> dict:
17
+ """Launch Chrome with debugging enabled for WebTap.
18
+
19
+ Args:
20
+ detach: Run Chrome in background (default: True)
21
+ port: Debugging port (default: 9222)
22
+
23
+ Returns:
24
+ Status message
25
+ """
26
+ # Find Chrome executable
27
+ chrome_paths = [
28
+ "google-chrome-stable",
29
+ "google-chrome",
30
+ "chromium-browser",
31
+ "chromium",
32
+ ]
33
+
34
+ chrome_exe = None
35
+ for path in chrome_paths:
36
+ if shutil.which(path):
37
+ chrome_exe = path
38
+ break
39
+
40
+ if not chrome_exe:
41
+ return error_response(
42
+ "Chrome not found",
43
+ suggestions=[
44
+ "Install google-chrome-stable: sudo apt install google-chrome-stable",
45
+ "Or install chromium: sudo apt install chromium-browser",
46
+ ],
47
+ )
48
+
49
+ # Setup temp profile with symlinks to real profile
50
+ temp_config = Path("/tmp/webtap-chrome-debug")
51
+ real_config = Path.home() / ".config" / "google-chrome"
52
+
53
+ if not temp_config.exists():
54
+ temp_config.mkdir(parents=True)
55
+
56
+ # Symlink Default profile
57
+ default_profile = real_config / "Default"
58
+ if default_profile.exists():
59
+ (temp_config / "Default").symlink_to(default_profile)
60
+
61
+ # Copy essential files
62
+ for file in ["Local State", "First Run"]:
63
+ src = real_config / file
64
+ if src.exists():
65
+ (temp_config / file).write_text(src.read_text())
66
+
67
+ # Launch Chrome
68
+ cmd = [chrome_exe, f"--remote-debugging-port={port}", "--remote-allow-origins=*", f"--user-data-dir={temp_config}"]
69
+
70
+ if detach:
71
+ subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
72
+ return success_response(
73
+ f"Launched {chrome_exe}",
74
+ details={
75
+ "Port": str(port),
76
+ "Mode": "Background (detached)",
77
+ "Profile": str(temp_config),
78
+ "Next step": "Run connect() to attach WebTap",
79
+ },
80
+ )
81
+ else:
82
+ result = subprocess.run(cmd)
83
+ if result.returncode == 0:
84
+ return success_response("Chrome closed normally")
85
+ else:
86
+ return error_response(f"Chrome exited with code {result.returncode}")
@@ -0,0 +1,199 @@
1
+ """Browser page navigation and history commands."""
2
+
3
+ from webtap.app import app
4
+ from webtap.commands._errors import check_connection
5
+ from webtap.commands._builders import info_response, table_response, error_response
6
+
7
+
8
+ @app.command(display="markdown", fastmcp={"type": "tool"})
9
+ def navigate(state, url: str) -> dict:
10
+ """Navigate to URL.
11
+
12
+ Args:
13
+ url: URL to navigate to
14
+
15
+ Returns:
16
+ Navigation result in markdown
17
+ """
18
+ if error := check_connection(state):
19
+ return error
20
+
21
+ result = state.cdp.execute("Page.navigate", {"url": url})
22
+
23
+ return info_response(
24
+ title="Navigation",
25
+ fields={
26
+ "URL": url,
27
+ "Frame ID": result.get("frameId", "None"),
28
+ "Loader ID": result.get("loaderId", "None"),
29
+ },
30
+ )
31
+
32
+
33
+ @app.command(display="markdown", fastmcp={"type": "tool"})
34
+ def reload(state, ignore_cache: bool = False) -> dict:
35
+ """Reload the current page.
36
+
37
+ Args:
38
+ ignore_cache: Force reload ignoring cache
39
+
40
+ Returns:
41
+ Reload status in markdown
42
+ """
43
+ if error := check_connection(state):
44
+ return error
45
+
46
+ state.cdp.execute("Page.reload", {"ignoreCache": ignore_cache})
47
+
48
+ return info_response(
49
+ title="Page Reload", fields={"Status": "Page reloaded", "Cache": "Ignored" if ignore_cache else "Used"}
50
+ )
51
+
52
+
53
+ @app.command(display="markdown", fastmcp={"type": "tool"})
54
+ def back(state) -> dict:
55
+ """Navigate back in history.
56
+
57
+ Returns:
58
+ Navigation result in markdown
59
+ """
60
+ if error := check_connection(state):
61
+ return error
62
+
63
+ # Get history
64
+ history = state.cdp.execute("Page.getNavigationHistory")
65
+ entries = history.get("entries", [])
66
+ current_index = history.get("currentIndex", 0)
67
+
68
+ if current_index > 0:
69
+ # Navigate to previous entry
70
+ target_id = entries[current_index - 1]["id"]
71
+ state.cdp.execute("Page.navigateToHistoryEntry", {"entryId": target_id})
72
+
73
+ prev_entry = entries[current_index - 1]
74
+ return info_response(
75
+ title="Navigation Back",
76
+ fields={
77
+ "Status": "Navigated back",
78
+ "Page": prev_entry.get("title", "Untitled"),
79
+ "URL": prev_entry.get("url", ""), # Full URL, no truncation
80
+ "Index": f"{current_index - 1} of {len(entries) - 1}",
81
+ },
82
+ )
83
+
84
+ return error_response("No history to go back")
85
+
86
+
87
+ @app.command(display="markdown", fastmcp={"type": "tool"})
88
+ def forward(state) -> dict:
89
+ """Navigate forward in history.
90
+
91
+ Returns:
92
+ Navigation result in markdown
93
+ """
94
+ if error := check_connection(state):
95
+ return error
96
+
97
+ # Get history
98
+ history = state.cdp.execute("Page.getNavigationHistory")
99
+ entries = history.get("entries", [])
100
+ current_index = history.get("currentIndex", 0)
101
+
102
+ if current_index < len(entries) - 1:
103
+ # Navigate to next entry
104
+ target_id = entries[current_index + 1]["id"]
105
+ state.cdp.execute("Page.navigateToHistoryEntry", {"entryId": target_id})
106
+
107
+ next_entry = entries[current_index + 1]
108
+ return info_response(
109
+ title="Navigation Forward",
110
+ fields={
111
+ "Status": "Navigated forward",
112
+ "Page": next_entry.get("title", "Untitled"),
113
+ "URL": next_entry.get("url", ""), # Full URL, no truncation
114
+ "Index": f"{current_index + 1} of {len(entries) - 1}",
115
+ },
116
+ )
117
+
118
+ return error_response("No history to go forward")
119
+
120
+
121
+ @app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "application/json"})
122
+ def page(state) -> dict:
123
+ """Get current page information.
124
+
125
+ Returns:
126
+ Current page information in markdown
127
+ """
128
+ # Check connection - return error dict if not connected
129
+ if error := check_connection(state):
130
+ return error
131
+
132
+ # Get from navigation history
133
+ history = state.cdp.execute("Page.getNavigationHistory")
134
+ entries = history.get("entries", [])
135
+ current_index = history.get("currentIndex", 0)
136
+
137
+ if entries and current_index < len(entries):
138
+ current = entries[current_index]
139
+
140
+ # Also get title from Runtime
141
+ try:
142
+ title = (
143
+ state.cdp.execute("Runtime.evaluate", {"expression": "document.title", "returnByValue": True})
144
+ .get("result", {})
145
+ .get("value", current.get("title", ""))
146
+ )
147
+ except Exception:
148
+ title = current.get("title", "")
149
+
150
+ # Build formatted response
151
+ return info_response(
152
+ title=title or "Untitled Page",
153
+ fields={
154
+ "URL": current.get("url", ""), # Full URL
155
+ "ID": current.get("id", ""),
156
+ "Type": current.get("transitionType", ""),
157
+ },
158
+ )
159
+
160
+ return error_response("No navigation history available")
161
+
162
+
163
+ @app.command(
164
+ display="markdown",
165
+ truncate={"Title": {"max": 40, "mode": "end"}, "URL": {"max": 50, "mode": "middle"}},
166
+ fastmcp={"type": "resource", "mime_type": "application/json"},
167
+ )
168
+ def history(state) -> dict:
169
+ """Get navigation history.
170
+
171
+ Returns:
172
+ Table of history entries with current marked
173
+ """
174
+ # Check connection - return error dict if not connected
175
+ if error := check_connection(state):
176
+ return error
177
+
178
+ history = state.cdp.execute("Page.getNavigationHistory")
179
+ entries = history.get("entries", [])
180
+ current_index = history.get("currentIndex", 0)
181
+
182
+ # Format rows for table with FULL data
183
+ rows = [
184
+ {
185
+ "Index": str(i),
186
+ "Current": "Yes" if i == current_index else "",
187
+ "Title": entry.get("title", ""), # Full title
188
+ "URL": entry.get("url", ""), # Full URL
189
+ }
190
+ for i, entry in enumerate(entries)
191
+ ]
192
+
193
+ # Build markdown response
194
+ return table_response(
195
+ title="Navigation History",
196
+ headers=["Index", "Current", "Title", "URL"],
197
+ rows=rows,
198
+ summary=f"{len(entries)} entries, current index: {current_index}",
199
+ )
@@ -0,0 +1,85 @@
1
+ """Network request monitoring and display commands."""
2
+
3
+ from typing import List
4
+
5
+ from webtap.app import app
6
+ from webtap.commands._builders import table_response
7
+ from webtap.commands._errors import check_connection
8
+ from webtap.commands._tips import get_tips
9
+
10
+
11
+ @app.command(
12
+ display="markdown",
13
+ truncate={"ReqID": {"max": 12, "mode": "end"}, "URL": {"max": 60, "mode": "middle"}},
14
+ transforms={"Size": "format_size"},
15
+ fastmcp=[{"type": "resource", "mime_type": "application/json"}, {"type": "tool"}],
16
+ )
17
+ def network(state, limit: int = 20, filters: List[str] = None, no_filters: bool = False) -> dict: # pyright: ignore[reportArgumentType]
18
+ """Show network requests with full data.
19
+
20
+ As Resource (no parameters):
21
+ network # Returns last 20 requests with enabled filters
22
+
23
+ As Tool (with parameters):
24
+ network(limit=50) # More results
25
+ network(filters=["ads"]) # Specific filter only
26
+ network(no_filters=True, limit=50) # Everything unfiltered
27
+
28
+ Args:
29
+ limit: Maximum results to show (default: 20)
30
+ filters: Specific filter categories to apply
31
+ no_filters: Show everything unfiltered (default: False)
32
+
33
+ Returns:
34
+ Table of network requests with full data
35
+ """
36
+ # Check connection
37
+ if error := check_connection(state):
38
+ return error
39
+
40
+ # Get filter SQL from service
41
+ if no_filters:
42
+ filter_sql = ""
43
+ elif filters:
44
+ filter_sql = state.service.filters.get_filter_sql(use_all=False, categories=filters)
45
+ else:
46
+ filter_sql = state.service.filters.get_filter_sql(use_all=True)
47
+
48
+ # Get data from service
49
+ results = state.service.network.get_recent_requests(limit=limit, filter_sql=filter_sql)
50
+
51
+ # Build rows with FULL data
52
+ rows = []
53
+ for row in results:
54
+ rowid, request_id, method, status, url, type_val, size = row
55
+ rows.append(
56
+ {
57
+ "ID": str(rowid),
58
+ "ReqID": request_id or "", # Full request ID
59
+ "Method": method or "GET",
60
+ "Status": str(status) if status else "-",
61
+ "URL": url or "", # Full URL
62
+ "Type": type_val or "-",
63
+ "Size": size or 0, # Raw bytes
64
+ }
65
+ )
66
+
67
+ # Build response with developer guidance
68
+ warnings = []
69
+ if limit and len(results) == limit:
70
+ warnings.append(f"Showing first {limit} results (use limit parameter to see more)")
71
+
72
+ # Get tips from TIPS.md with context
73
+ tips = None
74
+ if rows:
75
+ example_id = rows[0]["ID"]
76
+ tips = get_tips("network", context={"id": example_id})
77
+
78
+ return table_response(
79
+ title="Network Requests",
80
+ headers=["ID", "ReqID", "Method", "Status", "URL", "Type", "Size"],
81
+ rows=rows,
82
+ summary=f"{len(rows)} requests",
83
+ warnings=warnings,
84
+ tips=tips,
85
+ )